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