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 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::{HistoryStore, outline};
1066 use agent_client_protocol as acp;
1067 use assistant_text_thread::TextThreadStore;
1068 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1069 use fs::FakeFs;
1070 use futures::StreamExt as _;
1071 use gpui::{
1072 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1073 };
1074 use language_model::LanguageModelRegistry;
1075 use lsp::{CompletionContext, CompletionTriggerKind};
1076 use project::{CompletionIntent, Project, ProjectPath};
1077 use serde_json::json;
1078 use text::Point;
1079 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1080 use util::{path, paths::PathStyle, rel_path::rel_path};
1081 use workspace::{AppState, Item, Workspace};
1082
1083 use crate::acp::{
1084 message_editor::{Mention, MessageEditor},
1085 thread_view::tests::init_test,
1086 };
1087
1088 #[gpui::test]
1089 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1090 init_test(cx);
1091
1092 let fs = FakeFs::new(cx.executor());
1093 fs.insert_tree("/project", json!({"file": ""})).await;
1094 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1095
1096 let (workspace, cx) =
1097 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1098
1099 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1100 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1101
1102 let message_editor = cx.update(|window, cx| {
1103 cx.new(|cx| {
1104 MessageEditor::new(
1105 workspace.downgrade(),
1106 project.downgrade(),
1107 history_store.clone(),
1108 None,
1109 Default::default(),
1110 Default::default(),
1111 "Test Agent".into(),
1112 "Test",
1113 EditorMode::AutoHeight {
1114 min_lines: 1,
1115 max_lines: None,
1116 },
1117 window,
1118 cx,
1119 )
1120 })
1121 });
1122 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1123
1124 cx.run_until_parked();
1125
1126 let excerpt_id = editor.update(cx, |editor, cx| {
1127 editor
1128 .buffer()
1129 .read(cx)
1130 .excerpt_ids()
1131 .into_iter()
1132 .next()
1133 .unwrap()
1134 });
1135 let completions = editor.update_in(cx, |editor, window, cx| {
1136 editor.set_text("Hello @file ", window, cx);
1137 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1138 let completion_provider = editor.completion_provider().unwrap();
1139 completion_provider.completions(
1140 excerpt_id,
1141 &buffer,
1142 text::Anchor::MAX,
1143 CompletionContext {
1144 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1145 trigger_character: Some("@".into()),
1146 },
1147 window,
1148 cx,
1149 )
1150 });
1151 let [_, completion]: [_; 2] = completions
1152 .await
1153 .unwrap()
1154 .into_iter()
1155 .flat_map(|response| response.completions)
1156 .collect::<Vec<_>>()
1157 .try_into()
1158 .unwrap();
1159
1160 editor.update_in(cx, |editor, window, cx| {
1161 let snapshot = editor.buffer().read(cx).snapshot(cx);
1162 let range = snapshot
1163 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1164 .unwrap();
1165 editor.edit([(range, completion.new_text)], cx);
1166 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1167 });
1168
1169 cx.run_until_parked();
1170
1171 // Backspace over the inserted crease (and the following space).
1172 editor.update_in(cx, |editor, window, cx| {
1173 editor.backspace(&Default::default(), window, cx);
1174 editor.backspace(&Default::default(), window, cx);
1175 });
1176
1177 let (content, _) = message_editor
1178 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1179 .await
1180 .unwrap();
1181
1182 // We don't send a resource link for the deleted crease.
1183 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1184 }
1185
1186 #[gpui::test]
1187 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1188 init_test(cx);
1189 let fs = FakeFs::new(cx.executor());
1190 fs.insert_tree(
1191 "/test",
1192 json!({
1193 ".zed": {
1194 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1195 },
1196 "src": {
1197 "main.rs": "fn main() {}",
1198 },
1199 }),
1200 )
1201 .await;
1202
1203 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1204 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1205 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1206 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1207 // Start with no available commands - simulating Claude which doesn't support slash commands
1208 let available_commands = Rc::new(RefCell::new(vec![]));
1209
1210 let (workspace, cx) =
1211 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1212 let workspace_handle = workspace.downgrade();
1213 let message_editor = workspace.update_in(cx, |_, window, cx| {
1214 cx.new(|cx| {
1215 MessageEditor::new(
1216 workspace_handle.clone(),
1217 project.downgrade(),
1218 history_store.clone(),
1219 None,
1220 prompt_capabilities.clone(),
1221 available_commands.clone(),
1222 "Claude Code".into(),
1223 "Test",
1224 EditorMode::AutoHeight {
1225 min_lines: 1,
1226 max_lines: None,
1227 },
1228 window,
1229 cx,
1230 )
1231 })
1232 });
1233 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1234
1235 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1236 editor.update_in(cx, |editor, window, cx| {
1237 editor.set_text("/file test.txt", window, cx);
1238 });
1239
1240 let contents_result = message_editor
1241 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1242 .await;
1243
1244 // Should fail because available_commands is empty (no commands supported)
1245 assert!(contents_result.is_err());
1246 let error_message = contents_result.unwrap_err().to_string();
1247 assert!(error_message.contains("not supported by Claude Code"));
1248 assert!(error_message.contains("Available commands: none"));
1249
1250 // Now simulate Claude providing its list of available commands (which doesn't include file)
1251 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1252
1253 // Test that unsupported slash commands trigger an error when we have a list of available commands
1254 editor.update_in(cx, |editor, window, cx| {
1255 editor.set_text("/file test.txt", window, cx);
1256 });
1257
1258 let contents_result = message_editor
1259 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1260 .await;
1261
1262 assert!(contents_result.is_err());
1263 let error_message = contents_result.unwrap_err().to_string();
1264 assert!(error_message.contains("not supported by Claude Code"));
1265 assert!(error_message.contains("/file"));
1266 assert!(error_message.contains("Available commands: /help"));
1267
1268 // Test that supported commands work fine
1269 editor.update_in(cx, |editor, window, cx| {
1270 editor.set_text("/help", window, cx);
1271 });
1272
1273 let contents_result = message_editor
1274 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1275 .await;
1276
1277 // Should succeed because /help is in available_commands
1278 assert!(contents_result.is_ok());
1279
1280 // Test that regular text works fine
1281 editor.update_in(cx, |editor, window, cx| {
1282 editor.set_text("Hello Claude!", window, cx);
1283 });
1284
1285 let (content, _) = message_editor
1286 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1287 .await
1288 .unwrap();
1289
1290 assert_eq!(content.len(), 1);
1291 if let acp::ContentBlock::Text(text) = &content[0] {
1292 assert_eq!(text.text, "Hello Claude!");
1293 } else {
1294 panic!("Expected ContentBlock::Text");
1295 }
1296
1297 // Test that @ mentions still work
1298 editor.update_in(cx, |editor, window, cx| {
1299 editor.set_text("Check this @", window, cx);
1300 });
1301
1302 // The @ mention functionality should not be affected
1303 let (content, _) = message_editor
1304 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1305 .await
1306 .unwrap();
1307
1308 assert_eq!(content.len(), 1);
1309 if let acp::ContentBlock::Text(text) = &content[0] {
1310 assert_eq!(text.text, "Check this @");
1311 } else {
1312 panic!("Expected ContentBlock::Text");
1313 }
1314 }
1315
1316 struct MessageEditorItem(Entity<MessageEditor>);
1317
1318 impl Item for MessageEditorItem {
1319 type Event = ();
1320
1321 fn include_in_nav_history() -> bool {
1322 false
1323 }
1324
1325 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1326 "Test".into()
1327 }
1328 }
1329
1330 impl EventEmitter<()> for MessageEditorItem {}
1331
1332 impl Focusable for MessageEditorItem {
1333 fn focus_handle(&self, cx: &App) -> FocusHandle {
1334 self.0.read(cx).focus_handle(cx)
1335 }
1336 }
1337
1338 impl Render for MessageEditorItem {
1339 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1340 self.0.clone().into_any_element()
1341 }
1342 }
1343
1344 #[gpui::test]
1345 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1346 init_test(cx);
1347
1348 let app_state = cx.update(AppState::test);
1349
1350 cx.update(|cx| {
1351 editor::init(cx);
1352 workspace::init(app_state.clone(), cx);
1353 });
1354
1355 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1356 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1357 let workspace = window.root(cx).unwrap();
1358
1359 let mut cx = VisualTestContext::from_window(*window, cx);
1360
1361 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1362 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1363 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1364 let available_commands = Rc::new(RefCell::new(vec![
1365 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1366 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1367 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1368 "<name>",
1369 )),
1370 ),
1371 ]));
1372
1373 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1374 let workspace_handle = cx.weak_entity();
1375 let message_editor = cx.new(|cx| {
1376 MessageEditor::new(
1377 workspace_handle,
1378 project.downgrade(),
1379 history_store.clone(),
1380 None,
1381 prompt_capabilities.clone(),
1382 available_commands.clone(),
1383 "Test Agent".into(),
1384 "Test",
1385 EditorMode::AutoHeight {
1386 max_lines: None,
1387 min_lines: 1,
1388 },
1389 window,
1390 cx,
1391 )
1392 });
1393 workspace.active_pane().update(cx, |pane, cx| {
1394 pane.add_item(
1395 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1396 true,
1397 true,
1398 None,
1399 window,
1400 cx,
1401 );
1402 });
1403 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1404 message_editor.read(cx).editor().clone()
1405 });
1406
1407 cx.simulate_input("/");
1408
1409 editor.update_in(&mut cx, |editor, window, cx| {
1410 assert_eq!(editor.text(cx), "/");
1411 assert!(editor.has_visible_completions_menu());
1412
1413 assert_eq!(
1414 current_completion_labels_with_documentation(editor),
1415 &[
1416 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1417 ("say-hello".into(), "Say hello to whoever you want".into())
1418 ]
1419 );
1420 editor.set_text("", window, cx);
1421 });
1422
1423 cx.simulate_input("/qui");
1424
1425 editor.update_in(&mut cx, |editor, window, cx| {
1426 assert_eq!(editor.text(cx), "/qui");
1427 assert!(editor.has_visible_completions_menu());
1428
1429 assert_eq!(
1430 current_completion_labels_with_documentation(editor),
1431 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1432 );
1433 editor.set_text("", window, cx);
1434 });
1435
1436 editor.update_in(&mut cx, |editor, window, cx| {
1437 assert!(editor.has_visible_completions_menu());
1438 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1439 });
1440
1441 cx.run_until_parked();
1442
1443 editor.update_in(&mut cx, |editor, window, cx| {
1444 assert_eq!(editor.display_text(cx), "/quick-math ");
1445 assert!(!editor.has_visible_completions_menu());
1446 editor.set_text("", window, cx);
1447 });
1448
1449 cx.simulate_input("/say");
1450
1451 editor.update_in(&mut cx, |editor, _window, cx| {
1452 assert_eq!(editor.display_text(cx), "/say");
1453 assert!(editor.has_visible_completions_menu());
1454
1455 assert_eq!(
1456 current_completion_labels_with_documentation(editor),
1457 &[("say-hello".into(), "Say hello to whoever you want".into())]
1458 );
1459 });
1460
1461 editor.update_in(&mut cx, |editor, window, cx| {
1462 assert!(editor.has_visible_completions_menu());
1463 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1464 });
1465
1466 cx.run_until_parked();
1467
1468 editor.update_in(&mut cx, |editor, _window, cx| {
1469 assert_eq!(editor.text(cx), "/say-hello ");
1470 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1471 assert!(!editor.has_visible_completions_menu());
1472 });
1473
1474 cx.simulate_input("GPT5");
1475
1476 cx.run_until_parked();
1477
1478 editor.update_in(&mut cx, |editor, window, cx| {
1479 assert_eq!(editor.text(cx), "/say-hello GPT5");
1480 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1481 assert!(!editor.has_visible_completions_menu());
1482
1483 // Delete argument
1484 for _ in 0..5 {
1485 editor.backspace(&editor::actions::Backspace, window, cx);
1486 }
1487 });
1488
1489 cx.run_until_parked();
1490
1491 editor.update_in(&mut cx, |editor, window, cx| {
1492 assert_eq!(editor.text(cx), "/say-hello");
1493 // Hint is visible because argument was deleted
1494 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1495
1496 // Delete last command letter
1497 editor.backspace(&editor::actions::Backspace, window, cx);
1498 });
1499
1500 cx.run_until_parked();
1501
1502 editor.update_in(&mut cx, |editor, _window, cx| {
1503 // Hint goes away once command no longer matches an available one
1504 assert_eq!(editor.text(cx), "/say-hell");
1505 assert_eq!(editor.display_text(cx), "/say-hell");
1506 assert!(!editor.has_visible_completions_menu());
1507 });
1508 }
1509
1510 #[gpui::test]
1511 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1512 init_test(cx);
1513
1514 let app_state = cx.update(AppState::test);
1515
1516 cx.update(|cx| {
1517 editor::init(cx);
1518 workspace::init(app_state.clone(), cx);
1519 });
1520
1521 app_state
1522 .fs
1523 .as_fake()
1524 .insert_tree(
1525 path!("/dir"),
1526 json!({
1527 "editor": "",
1528 "a": {
1529 "one.txt": "1",
1530 "two.txt": "2",
1531 "three.txt": "3",
1532 "four.txt": "4"
1533 },
1534 "b": {
1535 "five.txt": "5",
1536 "six.txt": "6",
1537 "seven.txt": "7",
1538 "eight.txt": "8",
1539 },
1540 "x.png": "",
1541 }),
1542 )
1543 .await;
1544
1545 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1546 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1547 let workspace = window.root(cx).unwrap();
1548
1549 let worktree = project.update(cx, |project, cx| {
1550 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1551 assert_eq!(worktrees.len(), 1);
1552 worktrees.pop().unwrap()
1553 });
1554 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1555
1556 let mut cx = VisualTestContext::from_window(*window, cx);
1557
1558 let paths = vec![
1559 rel_path("a/one.txt"),
1560 rel_path("a/two.txt"),
1561 rel_path("a/three.txt"),
1562 rel_path("a/four.txt"),
1563 rel_path("b/five.txt"),
1564 rel_path("b/six.txt"),
1565 rel_path("b/seven.txt"),
1566 rel_path("b/eight.txt"),
1567 ];
1568
1569 let slash = PathStyle::local().primary_separator();
1570
1571 let mut opened_editors = Vec::new();
1572 for path in paths {
1573 let buffer = workspace
1574 .update_in(&mut cx, |workspace, window, cx| {
1575 workspace.open_path(
1576 ProjectPath {
1577 worktree_id,
1578 path: path.into(),
1579 },
1580 None,
1581 false,
1582 window,
1583 cx,
1584 )
1585 })
1586 .await
1587 .unwrap();
1588 opened_editors.push(buffer);
1589 }
1590
1591 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1592 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1593 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1594
1595 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1596 let workspace_handle = cx.weak_entity();
1597 let message_editor = cx.new(|cx| {
1598 MessageEditor::new(
1599 workspace_handle,
1600 project.downgrade(),
1601 history_store.clone(),
1602 None,
1603 prompt_capabilities.clone(),
1604 Default::default(),
1605 "Test Agent".into(),
1606 "Test",
1607 EditorMode::AutoHeight {
1608 max_lines: None,
1609 min_lines: 1,
1610 },
1611 window,
1612 cx,
1613 )
1614 });
1615 workspace.active_pane().update(cx, |pane, cx| {
1616 pane.add_item(
1617 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1618 true,
1619 true,
1620 None,
1621 window,
1622 cx,
1623 );
1624 });
1625 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1626 let editor = message_editor.read(cx).editor().clone();
1627 (message_editor, editor)
1628 });
1629
1630 cx.simulate_input("Lorem @");
1631
1632 editor.update_in(&mut cx, |editor, window, cx| {
1633 assert_eq!(editor.text(cx), "Lorem @");
1634 assert!(editor.has_visible_completions_menu());
1635
1636 assert_eq!(
1637 current_completion_labels(editor),
1638 &[
1639 format!("eight.txt b{slash}"),
1640 format!("seven.txt b{slash}"),
1641 format!("six.txt b{slash}"),
1642 format!("five.txt b{slash}"),
1643 "Files & Directories".into(),
1644 "Symbols".into()
1645 ]
1646 );
1647 editor.set_text("", window, cx);
1648 });
1649
1650 prompt_capabilities.replace(
1651 acp::PromptCapabilities::new()
1652 .image(true)
1653 .audio(true)
1654 .embedded_context(true),
1655 );
1656
1657 cx.simulate_input("Lorem ");
1658
1659 editor.update(&mut cx, |editor, cx| {
1660 assert_eq!(editor.text(cx), "Lorem ");
1661 assert!(!editor.has_visible_completions_menu());
1662 });
1663
1664 cx.simulate_input("@");
1665
1666 editor.update(&mut cx, |editor, cx| {
1667 assert_eq!(editor.text(cx), "Lorem @");
1668 assert!(editor.has_visible_completions_menu());
1669 assert_eq!(
1670 current_completion_labels(editor),
1671 &[
1672 format!("eight.txt b{slash}"),
1673 format!("seven.txt b{slash}"),
1674 format!("six.txt b{slash}"),
1675 format!("five.txt b{slash}"),
1676 "Files & Directories".into(),
1677 "Symbols".into(),
1678 "Threads".into(),
1679 "Fetch".into()
1680 ]
1681 );
1682 });
1683
1684 // Select and confirm "File"
1685 editor.update_in(&mut cx, |editor, window, cx| {
1686 assert!(editor.has_visible_completions_menu());
1687 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1688 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1689 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1690 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1691 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1692 });
1693
1694 cx.run_until_parked();
1695
1696 editor.update(&mut cx, |editor, cx| {
1697 assert_eq!(editor.text(cx), "Lorem @file ");
1698 assert!(editor.has_visible_completions_menu());
1699 });
1700
1701 cx.simulate_input("one");
1702
1703 editor.update(&mut cx, |editor, cx| {
1704 assert_eq!(editor.text(cx), "Lorem @file one");
1705 assert!(editor.has_visible_completions_menu());
1706 assert_eq!(
1707 current_completion_labels(editor),
1708 vec![format!("one.txt a{slash}")]
1709 );
1710 });
1711
1712 editor.update_in(&mut cx, |editor, window, cx| {
1713 assert!(editor.has_visible_completions_menu());
1714 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1715 });
1716
1717 let url_one = MentionUri::File {
1718 abs_path: path!("/dir/a/one.txt").into(),
1719 }
1720 .to_uri()
1721 .to_string();
1722 editor.update(&mut cx, |editor, cx| {
1723 let text = editor.text(cx);
1724 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1725 assert!(!editor.has_visible_completions_menu());
1726 assert_eq!(fold_ranges(editor, cx).len(), 1);
1727 });
1728
1729 let contents = message_editor
1730 .update(&mut cx, |message_editor, cx| {
1731 message_editor
1732 .mention_set()
1733 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1734 })
1735 .await
1736 .unwrap()
1737 .into_values()
1738 .collect::<Vec<_>>();
1739
1740 {
1741 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1742 panic!("Unexpected mentions");
1743 };
1744 pretty_assertions::assert_eq!(content, "1");
1745 pretty_assertions::assert_eq!(
1746 uri,
1747 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1748 );
1749 }
1750
1751 cx.simulate_input(" ");
1752
1753 editor.update(&mut cx, |editor, cx| {
1754 let text = editor.text(cx);
1755 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1756 assert!(!editor.has_visible_completions_menu());
1757 assert_eq!(fold_ranges(editor, cx).len(), 1);
1758 });
1759
1760 cx.simulate_input("Ipsum ");
1761
1762 editor.update(&mut cx, |editor, cx| {
1763 let text = editor.text(cx);
1764 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
1765 assert!(!editor.has_visible_completions_menu());
1766 assert_eq!(fold_ranges(editor, cx).len(), 1);
1767 });
1768
1769 cx.simulate_input("@file ");
1770
1771 editor.update(&mut cx, |editor, cx| {
1772 let text = editor.text(cx);
1773 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
1774 assert!(editor.has_visible_completions_menu());
1775 assert_eq!(fold_ranges(editor, cx).len(), 1);
1776 });
1777
1778 editor.update_in(&mut cx, |editor, window, cx| {
1779 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1780 });
1781
1782 cx.run_until_parked();
1783
1784 let contents = message_editor
1785 .update(&mut cx, |message_editor, cx| {
1786 message_editor
1787 .mention_set()
1788 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1789 })
1790 .await
1791 .unwrap()
1792 .into_values()
1793 .collect::<Vec<_>>();
1794
1795 let url_eight = MentionUri::File {
1796 abs_path: path!("/dir/b/eight.txt").into(),
1797 }
1798 .to_uri()
1799 .to_string();
1800
1801 {
1802 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1803 panic!("Unexpected mentions");
1804 };
1805 pretty_assertions::assert_eq!(content, "8");
1806 pretty_assertions::assert_eq!(
1807 uri,
1808 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1809 );
1810 }
1811
1812 editor.update(&mut cx, |editor, cx| {
1813 assert_eq!(
1814 editor.text(cx),
1815 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
1816 );
1817 assert!(!editor.has_visible_completions_menu());
1818 assert_eq!(fold_ranges(editor, cx).len(), 2);
1819 });
1820
1821 let plain_text_language = Arc::new(language::Language::new(
1822 language::LanguageConfig {
1823 name: "Plain Text".into(),
1824 matcher: language::LanguageMatcher {
1825 path_suffixes: vec!["txt".to_string()],
1826 ..Default::default()
1827 },
1828 ..Default::default()
1829 },
1830 None,
1831 ));
1832
1833 // Register the language and fake LSP
1834 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1835 language_registry.add(plain_text_language);
1836
1837 let mut fake_language_servers = language_registry.register_fake_lsp(
1838 "Plain Text",
1839 language::FakeLspAdapter {
1840 capabilities: lsp::ServerCapabilities {
1841 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1842 ..Default::default()
1843 },
1844 ..Default::default()
1845 },
1846 );
1847
1848 // Open the buffer to trigger LSP initialization
1849 let buffer = project
1850 .update(&mut cx, |project, cx| {
1851 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1852 })
1853 .await
1854 .unwrap();
1855
1856 // Register the buffer with language servers
1857 let _handle = project.update(&mut cx, |project, cx| {
1858 project.register_buffer_with_language_servers(&buffer, cx)
1859 });
1860
1861 cx.run_until_parked();
1862
1863 let fake_language_server = fake_language_servers.next().await.unwrap();
1864 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1865 move |_, _| async move {
1866 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1867 #[allow(deprecated)]
1868 lsp::SymbolInformation {
1869 name: "MySymbol".into(),
1870 location: lsp::Location {
1871 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1872 range: lsp::Range::new(
1873 lsp::Position::new(0, 0),
1874 lsp::Position::new(0, 1),
1875 ),
1876 },
1877 kind: lsp::SymbolKind::CONSTANT,
1878 tags: None,
1879 container_name: None,
1880 deprecated: None,
1881 },
1882 ])))
1883 },
1884 );
1885
1886 cx.simulate_input("@symbol ");
1887
1888 editor.update(&mut cx, |editor, cx| {
1889 assert_eq!(
1890 editor.text(cx),
1891 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
1892 );
1893 assert!(editor.has_visible_completions_menu());
1894 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1895 });
1896
1897 editor.update_in(&mut cx, |editor, window, cx| {
1898 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1899 });
1900
1901 let symbol = MentionUri::Symbol {
1902 abs_path: path!("/dir/a/one.txt").into(),
1903 name: "MySymbol".into(),
1904 line_range: 0..=0,
1905 };
1906
1907 let contents = message_editor
1908 .update(&mut cx, |message_editor, cx| {
1909 message_editor
1910 .mention_set()
1911 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1912 })
1913 .await
1914 .unwrap()
1915 .into_values()
1916 .collect::<Vec<_>>();
1917
1918 {
1919 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1920 panic!("Unexpected mentions");
1921 };
1922 pretty_assertions::assert_eq!(content, "1");
1923 pretty_assertions::assert_eq!(uri, &symbol);
1924 }
1925
1926 cx.run_until_parked();
1927
1928 editor.read_with(&cx, |editor, cx| {
1929 assert_eq!(
1930 editor.text(cx),
1931 format!(
1932 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1933 symbol.to_uri(),
1934 )
1935 );
1936 });
1937
1938 // Try to mention an "image" file that will fail to load
1939 cx.simulate_input("@file x.png");
1940
1941 editor.update(&mut cx, |editor, cx| {
1942 assert_eq!(
1943 editor.text(cx),
1944 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1945 );
1946 assert!(editor.has_visible_completions_menu());
1947 assert_eq!(current_completion_labels(editor), &["x.png "]);
1948 });
1949
1950 editor.update_in(&mut cx, |editor, window, cx| {
1951 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1952 });
1953
1954 // Getting the message contents fails
1955 message_editor
1956 .update(&mut cx, |message_editor, cx| {
1957 message_editor
1958 .mention_set()
1959 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1960 })
1961 .await
1962 .expect_err("Should fail to load x.png");
1963
1964 cx.run_until_parked();
1965
1966 // Mention was removed
1967 editor.read_with(&cx, |editor, cx| {
1968 assert_eq!(
1969 editor.text(cx),
1970 format!(
1971 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1972 symbol.to_uri()
1973 )
1974 );
1975 });
1976
1977 // Once more
1978 cx.simulate_input("@file x.png");
1979
1980 editor.update(&mut cx, |editor, cx| {
1981 assert_eq!(
1982 editor.text(cx),
1983 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1984 );
1985 assert!(editor.has_visible_completions_menu());
1986 assert_eq!(current_completion_labels(editor), &["x.png "]);
1987 });
1988
1989 editor.update_in(&mut cx, |editor, window, cx| {
1990 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1991 });
1992
1993 // This time don't immediately get the contents, just let the confirmed completion settle
1994 cx.run_until_parked();
1995
1996 // Mention was removed
1997 editor.read_with(&cx, |editor, cx| {
1998 assert_eq!(
1999 editor.text(cx),
2000 format!(
2001 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2002 symbol.to_uri()
2003 )
2004 );
2005 });
2006
2007 // Now getting the contents succeeds, because the invalid mention was removed
2008 let contents = message_editor
2009 .update(&mut cx, |message_editor, cx| {
2010 message_editor
2011 .mention_set()
2012 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2013 })
2014 .await
2015 .unwrap();
2016 assert_eq!(contents.len(), 3);
2017 }
2018
2019 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2020 let snapshot = editor.buffer().read(cx).snapshot(cx);
2021 editor.display_map.update(cx, |display_map, cx| {
2022 display_map
2023 .snapshot(cx)
2024 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2025 .map(|fold| fold.range.to_point(&snapshot))
2026 .collect()
2027 })
2028 }
2029
2030 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2031 let completions = editor.current_completions().expect("Missing completions");
2032 completions
2033 .into_iter()
2034 .map(|completion| completion.label.text)
2035 .collect::<Vec<_>>()
2036 }
2037
2038 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2039 let completions = editor.current_completions().expect("Missing completions");
2040 completions
2041 .into_iter()
2042 .map(|completion| {
2043 (
2044 completion.label.text,
2045 completion
2046 .documentation
2047 .map(|d| d.text().to_string())
2048 .unwrap_or_default(),
2049 )
2050 })
2051 .collect::<Vec<_>>()
2052 }
2053
2054 #[gpui::test]
2055 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2056 init_test(cx);
2057
2058 let fs = FakeFs::new(cx.executor());
2059
2060 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2061 // Using plain text without a configured language, so no outline is available
2062 const LINE: &str = "This is a line of text in the file\n";
2063 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2064 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2065
2066 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2067 let small_content = "fn small_function() { /* small */ }\n";
2068 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2069
2070 fs.insert_tree(
2071 "/project",
2072 json!({
2073 "large_file.txt": large_content.clone(),
2074 "small_file.txt": small_content,
2075 }),
2076 )
2077 .await;
2078
2079 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2080
2081 let (workspace, cx) =
2082 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2083
2084 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2085 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2086
2087 let message_editor = cx.update(|window, cx| {
2088 cx.new(|cx| {
2089 let editor = MessageEditor::new(
2090 workspace.downgrade(),
2091 project.downgrade(),
2092 history_store.clone(),
2093 None,
2094 Default::default(),
2095 Default::default(),
2096 "Test Agent".into(),
2097 "Test",
2098 EditorMode::AutoHeight {
2099 min_lines: 1,
2100 max_lines: None,
2101 },
2102 window,
2103 cx,
2104 );
2105 // Enable embedded context so files are actually included
2106 editor
2107 .prompt_capabilities
2108 .replace(acp::PromptCapabilities::new().embedded_context(true));
2109 editor
2110 })
2111 });
2112
2113 // Test large file mention
2114 // Get the absolute path using the project's worktree
2115 let large_file_abs_path = project.read_with(cx, |project, cx| {
2116 let worktree = project.worktrees(cx).next().unwrap();
2117 let worktree_root = worktree.read(cx).abs_path();
2118 worktree_root.join("large_file.txt")
2119 });
2120 let large_file_task = message_editor.update(cx, |editor, cx| {
2121 editor.mention_set().update(cx, |set, cx| {
2122 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2123 })
2124 });
2125
2126 let large_file_mention = large_file_task.await.unwrap();
2127 match large_file_mention {
2128 Mention::Text { content, .. } => {
2129 // Should contain some of the content but not all of it
2130 assert!(
2131 content.contains(LINE),
2132 "Should contain some of the file content"
2133 );
2134 assert!(
2135 !content.contains(&LINE.repeat(100)),
2136 "Should not contain the full file"
2137 );
2138 // Should be much smaller than original
2139 assert!(
2140 content.len() < large_content.len() / 10,
2141 "Should be significantly truncated"
2142 );
2143 }
2144 _ => panic!("Expected Text mention for large file"),
2145 }
2146
2147 // Test small file mention
2148 // Get the absolute path using the project's worktree
2149 let small_file_abs_path = project.read_with(cx, |project, cx| {
2150 let worktree = project.worktrees(cx).next().unwrap();
2151 let worktree_root = worktree.read(cx).abs_path();
2152 worktree_root.join("small_file.txt")
2153 });
2154 let small_file_task = message_editor.update(cx, |editor, cx| {
2155 editor.mention_set().update(cx, |set, cx| {
2156 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2157 })
2158 });
2159
2160 let small_file_mention = small_file_task.await.unwrap();
2161 match small_file_mention {
2162 Mention::Text { content, .. } => {
2163 // Should contain the full actual content
2164 assert_eq!(content, small_content);
2165 }
2166 _ => panic!("Expected Text mention for small file"),
2167 }
2168 }
2169
2170 #[gpui::test]
2171 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2172 init_test(cx);
2173 cx.update(LanguageModelRegistry::test);
2174
2175 let fs = FakeFs::new(cx.executor());
2176 fs.insert_tree("/project", json!({"file": ""})).await;
2177 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2178
2179 let (workspace, cx) =
2180 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2181
2182 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2183 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2184
2185 // Create a thread metadata to insert as summary
2186 let thread_metadata = agent::DbThreadMetadata {
2187 id: acp::SessionId::new("thread-123"),
2188 title: "Previous Conversation".into(),
2189 updated_at: chrono::Utc::now(),
2190 };
2191
2192 let message_editor = cx.update(|window, cx| {
2193 cx.new(|cx| {
2194 let mut editor = MessageEditor::new(
2195 workspace.downgrade(),
2196 project.downgrade(),
2197 history_store.clone(),
2198 None,
2199 Default::default(),
2200 Default::default(),
2201 "Test Agent".into(),
2202 "Test",
2203 EditorMode::AutoHeight {
2204 min_lines: 1,
2205 max_lines: None,
2206 },
2207 window,
2208 cx,
2209 );
2210 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2211 editor
2212 })
2213 });
2214
2215 // Construct expected values for verification
2216 let expected_uri = MentionUri::Thread {
2217 id: thread_metadata.id.clone(),
2218 name: thread_metadata.title.to_string(),
2219 };
2220 let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2221
2222 message_editor.read_with(cx, |editor, cx| {
2223 let text = editor.text(cx);
2224
2225 assert!(
2226 text.contains(&expected_link),
2227 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2228 expected_link,
2229 text
2230 );
2231
2232 let mentions = editor.mention_set().read(cx).mentions();
2233 assert_eq!(
2234 mentions.len(),
2235 1,
2236 "Expected exactly one mention after inserting thread summary"
2237 );
2238
2239 assert!(
2240 mentions.contains(&expected_uri),
2241 "Expected mentions to contain the thread URI"
2242 );
2243 });
2244 }
2245
2246 #[gpui::test]
2247 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2248 init_test(cx);
2249
2250 let fs = FakeFs::new(cx.executor());
2251 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2252 .await;
2253 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2254
2255 let (workspace, cx) =
2256 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2257
2258 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2259 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2260
2261 let message_editor = cx.update(|window, cx| {
2262 cx.new(|cx| {
2263 MessageEditor::new(
2264 workspace.downgrade(),
2265 project.downgrade(),
2266 history_store.clone(),
2267 None,
2268 Default::default(),
2269 Default::default(),
2270 "Test Agent".into(),
2271 "Test",
2272 EditorMode::AutoHeight {
2273 min_lines: 1,
2274 max_lines: None,
2275 },
2276 window,
2277 cx,
2278 )
2279 })
2280 });
2281 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2282
2283 cx.run_until_parked();
2284
2285 editor.update_in(cx, |editor, window, cx| {
2286 editor.set_text(" \u{A0}してhello world ", window, cx);
2287 });
2288
2289 let (content, _) = message_editor
2290 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2291 .await
2292 .unwrap();
2293
2294 assert_eq!(content, vec!["してhello world".into()]);
2295 }
2296
2297 #[gpui::test]
2298 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2299 init_test(cx);
2300
2301 let fs = FakeFs::new(cx.executor());
2302
2303 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2304
2305 fs.insert_tree(
2306 "/project",
2307 json!({
2308 "src": {
2309 "main.rs": file_content,
2310 }
2311 }),
2312 )
2313 .await;
2314
2315 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2316
2317 let (workspace, cx) =
2318 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2319
2320 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2321 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2322
2323 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2324 let workspace_handle = cx.weak_entity();
2325 let message_editor = cx.new(|cx| {
2326 MessageEditor::new(
2327 workspace_handle,
2328 project.downgrade(),
2329 history_store.clone(),
2330 None,
2331 Default::default(),
2332 Default::default(),
2333 "Test Agent".into(),
2334 "Test",
2335 EditorMode::AutoHeight {
2336 max_lines: None,
2337 min_lines: 1,
2338 },
2339 window,
2340 cx,
2341 )
2342 });
2343 workspace.active_pane().update(cx, |pane, cx| {
2344 pane.add_item(
2345 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2346 true,
2347 true,
2348 None,
2349 window,
2350 cx,
2351 );
2352 });
2353 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2354 let editor = message_editor.read(cx).editor().clone();
2355 (message_editor, editor)
2356 });
2357
2358 cx.simulate_input("What is in @file main");
2359
2360 editor.update_in(cx, |editor, window, cx| {
2361 assert!(editor.has_visible_completions_menu());
2362 assert_eq!(editor.text(cx), "What is in @file main");
2363 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2364 });
2365
2366 let content = message_editor
2367 .update(cx, |editor, cx| editor.contents(false, cx))
2368 .await
2369 .unwrap()
2370 .0;
2371
2372 let main_rs_uri = if cfg!(windows) {
2373 "file:///C:/project/src/main.rs"
2374 } else {
2375 "file:///project/src/main.rs"
2376 };
2377
2378 // When embedded context is `false` we should get a resource link
2379 pretty_assertions::assert_eq!(
2380 content,
2381 vec![
2382 "What is in ".into(),
2383 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2384 ]
2385 );
2386
2387 message_editor.update(cx, |editor, _cx| {
2388 editor
2389 .prompt_capabilities
2390 .replace(acp::PromptCapabilities::new().embedded_context(true))
2391 });
2392
2393 let content = message_editor
2394 .update(cx, |editor, cx| editor.contents(false, cx))
2395 .await
2396 .unwrap()
2397 .0;
2398
2399 // When embedded context is `true` we should get a resource
2400 pretty_assertions::assert_eq!(
2401 content,
2402 vec![
2403 "What is in ".into(),
2404 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2405 acp::EmbeddedResourceResource::TextResourceContents(
2406 acp::TextResourceContents::new(file_content, main_rs_uri)
2407 )
2408 ))
2409 ]
2410 );
2411 }
2412
2413 #[gpui::test]
2414 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2415 init_test(cx);
2416
2417 let app_state = cx.update(AppState::test);
2418
2419 cx.update(|cx| {
2420 editor::init(cx);
2421 workspace::init(app_state.clone(), cx);
2422 });
2423
2424 app_state
2425 .fs
2426 .as_fake()
2427 .insert_tree(
2428 path!("/dir"),
2429 json!({
2430 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2431 }),
2432 )
2433 .await;
2434
2435 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2436 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2437 let workspace = window.root(cx).unwrap();
2438
2439 let worktree = project.update(cx, |project, cx| {
2440 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2441 assert_eq!(worktrees.len(), 1);
2442 worktrees.pop().unwrap()
2443 });
2444 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2445
2446 let mut cx = VisualTestContext::from_window(*window, cx);
2447
2448 // Open a regular editor with the created file, and select a portion of
2449 // the text that will be used for the selections that are meant to be
2450 // inserted in the agent panel.
2451 let editor = workspace
2452 .update_in(&mut cx, |workspace, window, cx| {
2453 workspace.open_path(
2454 ProjectPath {
2455 worktree_id,
2456 path: rel_path("test.txt").into(),
2457 },
2458 None,
2459 false,
2460 window,
2461 cx,
2462 )
2463 })
2464 .await
2465 .unwrap()
2466 .downcast::<Editor>()
2467 .unwrap();
2468
2469 editor.update_in(&mut cx, |editor, window, cx| {
2470 editor.change_selections(Default::default(), window, cx, |selections| {
2471 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2472 });
2473 });
2474
2475 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2476 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2477
2478 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2479 // to ensure we have a fixed viewport, so we can eventually actually
2480 // place the cursor outside of the visible area.
2481 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2482 let workspace_handle = cx.weak_entity();
2483 let message_editor = cx.new(|cx| {
2484 MessageEditor::new(
2485 workspace_handle,
2486 project.downgrade(),
2487 history_store.clone(),
2488 None,
2489 Default::default(),
2490 Default::default(),
2491 "Test Agent".into(),
2492 "Test",
2493 EditorMode::full(),
2494 window,
2495 cx,
2496 )
2497 });
2498 workspace.active_pane().update(cx, |pane, cx| {
2499 pane.add_item(
2500 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2501 true,
2502 true,
2503 None,
2504 window,
2505 cx,
2506 );
2507 });
2508
2509 message_editor
2510 });
2511
2512 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2513 message_editor.editor.update(cx, |editor, cx| {
2514 // Update the Agent Panel's Message Editor text to have 100
2515 // lines, ensuring that the cursor is set at line 90 and that we
2516 // then scroll all the way to the top, so the cursor's position
2517 // remains off screen.
2518 let mut lines = String::new();
2519 for _ in 1..=100 {
2520 lines.push_str(&"Another line in the agent panel's message editor\n");
2521 }
2522 editor.set_text(lines.as_str(), window, cx);
2523 editor.change_selections(Default::default(), window, cx, |selections| {
2524 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2525 });
2526 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2527 });
2528 });
2529
2530 cx.run_until_parked();
2531
2532 // Before proceeding, let's assert that the cursor is indeed off screen,
2533 // otherwise the rest of the test doesn't make sense.
2534 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2535 message_editor.editor.update(cx, |editor, cx| {
2536 let snapshot = editor.snapshot(window, cx);
2537 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2538 let scroll_top = snapshot.scroll_position().y as u32;
2539 let visible_lines = editor.visible_line_count().unwrap() as u32;
2540 let visible_range = scroll_top..(scroll_top + visible_lines);
2541
2542 assert!(!visible_range.contains(&cursor_row));
2543 })
2544 });
2545
2546 // Now let's insert the selection in the Agent Panel's editor and
2547 // confirm that, after the insertion, the cursor is now in the visible
2548 // range.
2549 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2550 message_editor.insert_selections(window, cx);
2551 });
2552
2553 cx.run_until_parked();
2554
2555 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2556 message_editor.editor.update(cx, |editor, cx| {
2557 let snapshot = editor.snapshot(window, cx);
2558 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2559 let scroll_top = snapshot.scroll_position().y as u32;
2560 let visible_lines = editor.visible_line_count().unwrap() as u32;
2561 let visible_range = scroll_top..(scroll_top + visible_lines);
2562
2563 assert!(visible_range.contains(&cursor_row));
2564 })
2565 });
2566 }
2567}