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