1use crate::QueueMessage;
2use crate::{
3 ChatWithFollow,
4 completion_provider::{
5 PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
6 PromptContextType, SlashCommandCompletion,
7 },
8 mention_set::{
9 Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
10 },
11};
12use acp_thread::{AgentSessionInfo, AgentSessionList, MentionUri};
13use agent::ThreadStore;
14use agent_client_protocol as acp;
15use anyhow::{Result, anyhow};
16use collections::HashSet;
17use editor::{
18 Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
19 EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
20 MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
21 scroll::Autoscroll,
22};
23use futures::{FutureExt as _, future::join_all};
24use gpui::{
25 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
26 KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
27};
28use language::{Buffer, Language, language_settings::InlayHintKind};
29use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
30use prompt_store::PromptStore;
31use rope::Point;
32use settings::Settings;
33use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
34use theme::ThemeSettings;
35use ui::{ContextMenu, prelude::*};
36use util::{ResultExt, debug_panic};
37use workspace::{CollaboratorId, Workspace};
38use zed_actions::agent::{Chat, PasteRaw};
39
40pub struct MessageEditor {
41 mention_set: Entity<MentionSet>,
42 editor: Entity<Editor>,
43 workspace: WeakEntity<Workspace>,
44 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
45 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
46 agent_name: SharedString,
47 thread_store: Option<Entity<ThreadStore>>,
48 _subscriptions: Vec<Subscription>,
49 _parse_slash_command_task: Task<()>,
50}
51
52#[derive(Clone, Copy, Debug)]
53pub enum MessageEditorEvent {
54 Send,
55 Queue,
56 Cancel,
57 Focus,
58 LostFocus,
59}
60
61impl EventEmitter<MessageEditorEvent> for MessageEditor {}
62
63const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
64
65impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
66 fn supports_images(&self, cx: &App) -> bool {
67 self.read(cx).prompt_capabilities.borrow().image
68 }
69
70 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
71 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
72 if self.read(cx).prompt_capabilities.borrow().embedded_context {
73 if self.read(cx).thread_store.is_some() {
74 supported.push(PromptContextType::Thread);
75 }
76 supported.extend(&[PromptContextType::Fetch, PromptContextType::Rules]);
77 }
78 supported
79 }
80
81 fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
82 self.read(cx)
83 .available_commands
84 .borrow()
85 .iter()
86 .map(|cmd| crate::completion_provider::AvailableCommand {
87 name: cmd.name.clone().into(),
88 description: cmd.description.clone().into(),
89 requires_argument: cmd.input.is_some(),
90 })
91 .collect()
92 }
93
94 fn confirm_command(&self, cx: &mut App) {
95 self.update(cx, |this, cx| this.send(cx));
96 }
97}
98
99impl MessageEditor {
100 pub fn new(
101 workspace: WeakEntity<Workspace>,
102 project: WeakEntity<Project>,
103 thread_store: Option<Entity<ThreadStore>>,
104 session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
105 prompt_store: Option<Entity<PromptStore>>,
106 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
107 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
108 agent_name: SharedString,
109 placeholder: &str,
110 mode: EditorMode,
111 window: &mut Window,
112 cx: &mut Context<Self>,
113 ) -> Self {
114 let language = Language::new(
115 language::LanguageConfig {
116 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
117 ..Default::default()
118 },
119 None,
120 );
121
122 let editor = cx.new(|cx| {
123 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
124 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
125
126 let mut editor = Editor::new(mode, buffer, None, window, cx);
127 editor.set_placeholder_text(placeholder, window, cx);
128 editor.set_show_indent_guides(false, cx);
129 editor.set_show_completions_on_input(Some(true));
130 editor.set_soft_wrap();
131 editor.set_use_modal_editing(true);
132 editor.set_context_menu_options(ContextMenuOptions {
133 min_entries_visible: 12,
134 max_entries_visible: 12,
135 placement: Some(ContextMenuPlacement::Above),
136 });
137 editor.register_addon(MessageEditorAddon::new());
138
139 editor.set_custom_context_menu(|editor, _point, window, cx| {
140 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
141
142 Some(ContextMenu::build(window, cx, |menu, _, _| {
143 menu.action("Cut", Box::new(editor::actions::Cut))
144 .action_disabled_when(
145 !has_selection,
146 "Copy",
147 Box::new(editor::actions::Copy),
148 )
149 .action("Paste", Box::new(editor::actions::Paste))
150 }))
151 });
152
153 editor
154 });
155 let mention_set =
156 cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
157 let completion_provider = Rc::new(PromptCompletionProvider::new(
158 cx.entity(),
159 editor.downgrade(),
160 mention_set.clone(),
161 thread_store.clone(),
162 session_list,
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 session_list = Rc::new(RefCell::new(None));
1112
1113 let message_editor = cx.update(|window, cx| {
1114 cx.new(|cx| {
1115 MessageEditor::new(
1116 workspace.downgrade(),
1117 project.downgrade(),
1118 thread_store.clone(),
1119 session_list.clone(),
1120 None,
1121 Default::default(),
1122 Default::default(),
1123 "Test Agent".into(),
1124 "Test",
1125 EditorMode::AutoHeight {
1126 min_lines: 1,
1127 max_lines: None,
1128 },
1129 window,
1130 cx,
1131 )
1132 })
1133 });
1134 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1135
1136 cx.run_until_parked();
1137
1138 let excerpt_id = editor.update(cx, |editor, cx| {
1139 editor
1140 .buffer()
1141 .read(cx)
1142 .excerpt_ids()
1143 .into_iter()
1144 .next()
1145 .unwrap()
1146 });
1147 let completions = editor.update_in(cx, |editor, window, cx| {
1148 editor.set_text("Hello @file ", window, cx);
1149 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1150 let completion_provider = editor.completion_provider().unwrap();
1151 completion_provider.completions(
1152 excerpt_id,
1153 &buffer,
1154 text::Anchor::MAX,
1155 CompletionContext {
1156 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1157 trigger_character: Some("@".into()),
1158 },
1159 window,
1160 cx,
1161 )
1162 });
1163 let [_, completion]: [_; 2] = completions
1164 .await
1165 .unwrap()
1166 .into_iter()
1167 .flat_map(|response| response.completions)
1168 .collect::<Vec<_>>()
1169 .try_into()
1170 .unwrap();
1171
1172 editor.update_in(cx, |editor, window, cx| {
1173 let snapshot = editor.buffer().read(cx).snapshot(cx);
1174 let range = snapshot
1175 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1176 .unwrap();
1177 editor.edit([(range, completion.new_text)], cx);
1178 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1179 });
1180
1181 cx.run_until_parked();
1182
1183 // Backspace over the inserted crease (and the following space).
1184 editor.update_in(cx, |editor, window, cx| {
1185 editor.backspace(&Default::default(), window, cx);
1186 editor.backspace(&Default::default(), window, cx);
1187 });
1188
1189 let (content, _) = message_editor
1190 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1191 .await
1192 .unwrap();
1193
1194 // We don't send a resource link for the deleted crease.
1195 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1196 }
1197
1198 #[gpui::test]
1199 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1200 init_test(cx);
1201 let fs = FakeFs::new(cx.executor());
1202 fs.insert_tree(
1203 "/test",
1204 json!({
1205 ".zed": {
1206 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1207 },
1208 "src": {
1209 "main.rs": "fn main() {}",
1210 },
1211 }),
1212 )
1213 .await;
1214
1215 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1216 let thread_store = None;
1217 let session_list = Rc::new(RefCell::new(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 workspace_handle = workspace.downgrade();
1225 let message_editor = workspace.update_in(cx, |_, window, cx| {
1226 cx.new(|cx| {
1227 MessageEditor::new(
1228 workspace_handle.clone(),
1229 project.downgrade(),
1230 thread_store.clone(),
1231 session_list.clone(),
1232 None,
1233 prompt_capabilities.clone(),
1234 available_commands.clone(),
1235 "Claude Code".into(),
1236 "Test",
1237 EditorMode::AutoHeight {
1238 min_lines: 1,
1239 max_lines: None,
1240 },
1241 window,
1242 cx,
1243 )
1244 })
1245 });
1246 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1247
1248 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1249 editor.update_in(cx, |editor, window, cx| {
1250 editor.set_text("/file test.txt", window, cx);
1251 });
1252
1253 let contents_result = message_editor
1254 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1255 .await;
1256
1257 // Should fail because available_commands is empty (no commands supported)
1258 assert!(contents_result.is_err());
1259 let error_message = contents_result.unwrap_err().to_string();
1260 assert!(error_message.contains("not supported by Claude Code"));
1261 assert!(error_message.contains("Available commands: none"));
1262
1263 // Now simulate Claude providing its list of available commands (which doesn't include file)
1264 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1265
1266 // Test that unsupported slash commands trigger an error when we have a list of available commands
1267 editor.update_in(cx, |editor, window, cx| {
1268 editor.set_text("/file test.txt", window, cx);
1269 });
1270
1271 let contents_result = message_editor
1272 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1273 .await;
1274
1275 assert!(contents_result.is_err());
1276 let error_message = contents_result.unwrap_err().to_string();
1277 assert!(error_message.contains("not supported by Claude Code"));
1278 assert!(error_message.contains("/file"));
1279 assert!(error_message.contains("Available commands: /help"));
1280
1281 // Test that supported commands work fine
1282 editor.update_in(cx, |editor, window, cx| {
1283 editor.set_text("/help", window, cx);
1284 });
1285
1286 let contents_result = message_editor
1287 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1288 .await;
1289
1290 // Should succeed because /help is in available_commands
1291 assert!(contents_result.is_ok());
1292
1293 // Test that regular text works fine
1294 editor.update_in(cx, |editor, window, cx| {
1295 editor.set_text("Hello Claude!", window, cx);
1296 });
1297
1298 let (content, _) = message_editor
1299 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1300 .await
1301 .unwrap();
1302
1303 assert_eq!(content.len(), 1);
1304 if let acp::ContentBlock::Text(text) = &content[0] {
1305 assert_eq!(text.text, "Hello Claude!");
1306 } else {
1307 panic!("Expected ContentBlock::Text");
1308 }
1309
1310 // Test that @ mentions still work
1311 editor.update_in(cx, |editor, window, cx| {
1312 editor.set_text("Check this @", window, cx);
1313 });
1314
1315 // The @ mention functionality should not be affected
1316 let (content, _) = message_editor
1317 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1318 .await
1319 .unwrap();
1320
1321 assert_eq!(content.len(), 1);
1322 if let acp::ContentBlock::Text(text) = &content[0] {
1323 assert_eq!(text.text, "Check this @");
1324 } else {
1325 panic!("Expected ContentBlock::Text");
1326 }
1327 }
1328
1329 struct MessageEditorItem(Entity<MessageEditor>);
1330
1331 impl Item for MessageEditorItem {
1332 type Event = ();
1333
1334 fn include_in_nav_history() -> bool {
1335 false
1336 }
1337
1338 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1339 "Test".into()
1340 }
1341 }
1342
1343 impl EventEmitter<()> for MessageEditorItem {}
1344
1345 impl Focusable for MessageEditorItem {
1346 fn focus_handle(&self, cx: &App) -> FocusHandle {
1347 self.0.read(cx).focus_handle(cx)
1348 }
1349 }
1350
1351 impl Render for MessageEditorItem {
1352 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1353 self.0.clone().into_any_element()
1354 }
1355 }
1356
1357 #[gpui::test]
1358 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1359 init_test(cx);
1360
1361 let app_state = cx.update(AppState::test);
1362
1363 cx.update(|cx| {
1364 editor::init(cx);
1365 workspace::init(app_state.clone(), cx);
1366 });
1367
1368 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1369 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1370 let workspace = window.root(cx).unwrap();
1371
1372 let mut cx = VisualTestContext::from_window(*window, cx);
1373
1374 let thread_store = None;
1375 let session_list = Rc::new(RefCell::new(None));
1376 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1377 let available_commands = Rc::new(RefCell::new(vec![
1378 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1379 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1380 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1381 "<name>",
1382 )),
1383 ),
1384 ]));
1385
1386 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1387 let workspace_handle = cx.weak_entity();
1388 let message_editor = cx.new(|cx| {
1389 MessageEditor::new(
1390 workspace_handle,
1391 project.downgrade(),
1392 thread_store.clone(),
1393 session_list.clone(),
1394 None,
1395 prompt_capabilities.clone(),
1396 available_commands.clone(),
1397 "Test Agent".into(),
1398 "Test",
1399 EditorMode::AutoHeight {
1400 max_lines: None,
1401 min_lines: 1,
1402 },
1403 window,
1404 cx,
1405 )
1406 });
1407 workspace.active_pane().update(cx, |pane, cx| {
1408 pane.add_item(
1409 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1410 true,
1411 true,
1412 None,
1413 window,
1414 cx,
1415 );
1416 });
1417 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1418 message_editor.read(cx).editor().clone()
1419 });
1420
1421 cx.simulate_input("/");
1422
1423 editor.update_in(&mut cx, |editor, window, cx| {
1424 assert_eq!(editor.text(cx), "/");
1425 assert!(editor.has_visible_completions_menu());
1426
1427 assert_eq!(
1428 current_completion_labels_with_documentation(editor),
1429 &[
1430 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1431 ("say-hello".into(), "Say hello to whoever you want".into())
1432 ]
1433 );
1434 editor.set_text("", window, cx);
1435 });
1436
1437 cx.simulate_input("/qui");
1438
1439 editor.update_in(&mut cx, |editor, window, cx| {
1440 assert_eq!(editor.text(cx), "/qui");
1441 assert!(editor.has_visible_completions_menu());
1442
1443 assert_eq!(
1444 current_completion_labels_with_documentation(editor),
1445 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1446 );
1447 editor.set_text("", window, cx);
1448 });
1449
1450 editor.update_in(&mut cx, |editor, window, cx| {
1451 assert!(editor.has_visible_completions_menu());
1452 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1453 });
1454
1455 cx.run_until_parked();
1456
1457 editor.update_in(&mut cx, |editor, window, cx| {
1458 assert_eq!(editor.display_text(cx), "/quick-math ");
1459 assert!(!editor.has_visible_completions_menu());
1460 editor.set_text("", window, cx);
1461 });
1462
1463 cx.simulate_input("/say");
1464
1465 editor.update_in(&mut cx, |editor, _window, cx| {
1466 assert_eq!(editor.display_text(cx), "/say");
1467 assert!(editor.has_visible_completions_menu());
1468
1469 assert_eq!(
1470 current_completion_labels_with_documentation(editor),
1471 &[("say-hello".into(), "Say hello to whoever you want".into())]
1472 );
1473 });
1474
1475 editor.update_in(&mut cx, |editor, window, cx| {
1476 assert!(editor.has_visible_completions_menu());
1477 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1478 });
1479
1480 cx.run_until_parked();
1481
1482 editor.update_in(&mut cx, |editor, _window, cx| {
1483 assert_eq!(editor.text(cx), "/say-hello ");
1484 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1485 assert!(!editor.has_visible_completions_menu());
1486 });
1487
1488 cx.simulate_input("GPT5");
1489
1490 cx.run_until_parked();
1491
1492 editor.update_in(&mut cx, |editor, window, cx| {
1493 assert_eq!(editor.text(cx), "/say-hello GPT5");
1494 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1495 assert!(!editor.has_visible_completions_menu());
1496
1497 // Delete argument
1498 for _ in 0..5 {
1499 editor.backspace(&editor::actions::Backspace, window, cx);
1500 }
1501 });
1502
1503 cx.run_until_parked();
1504
1505 editor.update_in(&mut cx, |editor, window, cx| {
1506 assert_eq!(editor.text(cx), "/say-hello");
1507 // Hint is visible because argument was deleted
1508 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1509
1510 // Delete last command letter
1511 editor.backspace(&editor::actions::Backspace, window, cx);
1512 });
1513
1514 cx.run_until_parked();
1515
1516 editor.update_in(&mut cx, |editor, _window, cx| {
1517 // Hint goes away once command no longer matches an available one
1518 assert_eq!(editor.text(cx), "/say-hell");
1519 assert_eq!(editor.display_text(cx), "/say-hell");
1520 assert!(!editor.has_visible_completions_menu());
1521 });
1522 }
1523
1524 #[gpui::test]
1525 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1526 init_test(cx);
1527
1528 let app_state = cx.update(AppState::test);
1529
1530 cx.update(|cx| {
1531 editor::init(cx);
1532 workspace::init(app_state.clone(), cx);
1533 });
1534
1535 app_state
1536 .fs
1537 .as_fake()
1538 .insert_tree(
1539 path!("/dir"),
1540 json!({
1541 "editor": "",
1542 "a": {
1543 "one.txt": "1",
1544 "two.txt": "2",
1545 "three.txt": "3",
1546 "four.txt": "4"
1547 },
1548 "b": {
1549 "five.txt": "5",
1550 "six.txt": "6",
1551 "seven.txt": "7",
1552 "eight.txt": "8",
1553 },
1554 "x.png": "",
1555 }),
1556 )
1557 .await;
1558
1559 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1560 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1561 let workspace = window.root(cx).unwrap();
1562
1563 let worktree = project.update(cx, |project, cx| {
1564 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1565 assert_eq!(worktrees.len(), 1);
1566 worktrees.pop().unwrap()
1567 });
1568 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1569
1570 let mut cx = VisualTestContext::from_window(*window, cx);
1571
1572 let paths = vec![
1573 rel_path("a/one.txt"),
1574 rel_path("a/two.txt"),
1575 rel_path("a/three.txt"),
1576 rel_path("a/four.txt"),
1577 rel_path("b/five.txt"),
1578 rel_path("b/six.txt"),
1579 rel_path("b/seven.txt"),
1580 rel_path("b/eight.txt"),
1581 ];
1582
1583 let slash = PathStyle::local().primary_separator();
1584
1585 let mut opened_editors = Vec::new();
1586 for path in paths {
1587 let buffer = workspace
1588 .update_in(&mut cx, |workspace, window, cx| {
1589 workspace.open_path(
1590 ProjectPath {
1591 worktree_id,
1592 path: path.into(),
1593 },
1594 None,
1595 false,
1596 window,
1597 cx,
1598 )
1599 })
1600 .await
1601 .unwrap();
1602 opened_editors.push(buffer);
1603 }
1604
1605 let thread_store = cx.new(|cx| ThreadStore::new(cx));
1606 let session_list = Rc::new(RefCell::new(None));
1607 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1608
1609 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1610 let workspace_handle = cx.weak_entity();
1611 let message_editor = cx.new(|cx| {
1612 MessageEditor::new(
1613 workspace_handle,
1614 project.downgrade(),
1615 Some(thread_store),
1616 session_list.clone(),
1617 None,
1618 prompt_capabilities.clone(),
1619 Default::default(),
1620 "Test Agent".into(),
1621 "Test",
1622 EditorMode::AutoHeight {
1623 max_lines: None,
1624 min_lines: 1,
1625 },
1626 window,
1627 cx,
1628 )
1629 });
1630 workspace.active_pane().update(cx, |pane, cx| {
1631 pane.add_item(
1632 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1633 true,
1634 true,
1635 None,
1636 window,
1637 cx,
1638 );
1639 });
1640 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1641 let editor = message_editor.read(cx).editor().clone();
1642 (message_editor, editor)
1643 });
1644
1645 cx.simulate_input("Lorem @");
1646
1647 editor.update_in(&mut cx, |editor, window, cx| {
1648 assert_eq!(editor.text(cx), "Lorem @");
1649 assert!(editor.has_visible_completions_menu());
1650
1651 assert_eq!(
1652 current_completion_labels(editor),
1653 &[
1654 format!("eight.txt b{slash}"),
1655 format!("seven.txt b{slash}"),
1656 format!("six.txt b{slash}"),
1657 format!("five.txt b{slash}"),
1658 "Files & Directories".into(),
1659 "Symbols".into()
1660 ]
1661 );
1662 editor.set_text("", window, cx);
1663 });
1664
1665 prompt_capabilities.replace(
1666 acp::PromptCapabilities::new()
1667 .image(true)
1668 .audio(true)
1669 .embedded_context(true),
1670 );
1671
1672 cx.simulate_input("Lorem ");
1673
1674 editor.update(&mut cx, |editor, cx| {
1675 assert_eq!(editor.text(cx), "Lorem ");
1676 assert!(!editor.has_visible_completions_menu());
1677 });
1678
1679 cx.simulate_input("@");
1680
1681 editor.update(&mut cx, |editor, cx| {
1682 assert_eq!(editor.text(cx), "Lorem @");
1683 assert!(editor.has_visible_completions_menu());
1684 assert_eq!(
1685 current_completion_labels(editor),
1686 &[
1687 format!("eight.txt b{slash}"),
1688 format!("seven.txt b{slash}"),
1689 format!("six.txt b{slash}"),
1690 format!("five.txt b{slash}"),
1691 "Files & Directories".into(),
1692 "Symbols".into(),
1693 "Threads".into(),
1694 "Fetch".into()
1695 ]
1696 );
1697 });
1698
1699 // Select and confirm "File"
1700 editor.update_in(&mut cx, |editor, window, cx| {
1701 assert!(editor.has_visible_completions_menu());
1702 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1703 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1704 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1705 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1706 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1707 });
1708
1709 cx.run_until_parked();
1710
1711 editor.update(&mut cx, |editor, cx| {
1712 assert_eq!(editor.text(cx), "Lorem @file ");
1713 assert!(editor.has_visible_completions_menu());
1714 });
1715
1716 cx.simulate_input("one");
1717
1718 editor.update(&mut cx, |editor, cx| {
1719 assert_eq!(editor.text(cx), "Lorem @file one");
1720 assert!(editor.has_visible_completions_menu());
1721 assert_eq!(
1722 current_completion_labels(editor),
1723 vec![format!("one.txt a{slash}")]
1724 );
1725 });
1726
1727 editor.update_in(&mut cx, |editor, window, cx| {
1728 assert!(editor.has_visible_completions_menu());
1729 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1730 });
1731
1732 let url_one = MentionUri::File {
1733 abs_path: path!("/dir/a/one.txt").into(),
1734 }
1735 .to_uri()
1736 .to_string();
1737 editor.update(&mut cx, |editor, cx| {
1738 let text = editor.text(cx);
1739 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1740 assert!(!editor.has_visible_completions_menu());
1741 assert_eq!(fold_ranges(editor, cx).len(), 1);
1742 });
1743
1744 let contents = message_editor
1745 .update(&mut cx, |message_editor, cx| {
1746 message_editor
1747 .mention_set()
1748 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1749 })
1750 .await
1751 .unwrap()
1752 .into_values()
1753 .collect::<Vec<_>>();
1754
1755 {
1756 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1757 panic!("Unexpected mentions");
1758 };
1759 pretty_assertions::assert_eq!(content, "1");
1760 pretty_assertions::assert_eq!(
1761 uri,
1762 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1763 );
1764 }
1765
1766 cx.simulate_input(" ");
1767
1768 editor.update(&mut cx, |editor, cx| {
1769 let text = editor.text(cx);
1770 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1771 assert!(!editor.has_visible_completions_menu());
1772 assert_eq!(fold_ranges(editor, cx).len(), 1);
1773 });
1774
1775 cx.simulate_input("Ipsum ");
1776
1777 editor.update(&mut cx, |editor, cx| {
1778 let text = editor.text(cx);
1779 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
1780 assert!(!editor.has_visible_completions_menu());
1781 assert_eq!(fold_ranges(editor, cx).len(), 1);
1782 });
1783
1784 cx.simulate_input("@file ");
1785
1786 editor.update(&mut cx, |editor, cx| {
1787 let text = editor.text(cx);
1788 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
1789 assert!(editor.has_visible_completions_menu());
1790 assert_eq!(fold_ranges(editor, cx).len(), 1);
1791 });
1792
1793 editor.update_in(&mut cx, |editor, window, cx| {
1794 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1795 });
1796
1797 cx.run_until_parked();
1798
1799 let contents = message_editor
1800 .update(&mut cx, |message_editor, cx| {
1801 message_editor
1802 .mention_set()
1803 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1804 })
1805 .await
1806 .unwrap()
1807 .into_values()
1808 .collect::<Vec<_>>();
1809
1810 let url_eight = MentionUri::File {
1811 abs_path: path!("/dir/b/eight.txt").into(),
1812 }
1813 .to_uri()
1814 .to_string();
1815
1816 {
1817 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1818 panic!("Unexpected mentions");
1819 };
1820 pretty_assertions::assert_eq!(content, "8");
1821 pretty_assertions::assert_eq!(
1822 uri,
1823 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1824 );
1825 }
1826
1827 editor.update(&mut cx, |editor, cx| {
1828 assert_eq!(
1829 editor.text(cx),
1830 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
1831 );
1832 assert!(!editor.has_visible_completions_menu());
1833 assert_eq!(fold_ranges(editor, cx).len(), 2);
1834 });
1835
1836 let plain_text_language = Arc::new(language::Language::new(
1837 language::LanguageConfig {
1838 name: "Plain Text".into(),
1839 matcher: language::LanguageMatcher {
1840 path_suffixes: vec!["txt".to_string()],
1841 ..Default::default()
1842 },
1843 ..Default::default()
1844 },
1845 None,
1846 ));
1847
1848 // Register the language and fake LSP
1849 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1850 language_registry.add(plain_text_language);
1851
1852 let mut fake_language_servers = language_registry.register_fake_lsp(
1853 "Plain Text",
1854 language::FakeLspAdapter {
1855 capabilities: lsp::ServerCapabilities {
1856 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1857 ..Default::default()
1858 },
1859 ..Default::default()
1860 },
1861 );
1862
1863 // Open the buffer to trigger LSP initialization
1864 let buffer = project
1865 .update(&mut cx, |project, cx| {
1866 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1867 })
1868 .await
1869 .unwrap();
1870
1871 // Register the buffer with language servers
1872 let _handle = project.update(&mut cx, |project, cx| {
1873 project.register_buffer_with_language_servers(&buffer, cx)
1874 });
1875
1876 cx.run_until_parked();
1877
1878 let fake_language_server = fake_language_servers.next().await.unwrap();
1879 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1880 move |_, _| async move {
1881 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1882 #[allow(deprecated)]
1883 lsp::SymbolInformation {
1884 name: "MySymbol".into(),
1885 location: lsp::Location {
1886 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1887 range: lsp::Range::new(
1888 lsp::Position::new(0, 0),
1889 lsp::Position::new(0, 1),
1890 ),
1891 },
1892 kind: lsp::SymbolKind::CONSTANT,
1893 tags: None,
1894 container_name: None,
1895 deprecated: None,
1896 },
1897 ])))
1898 },
1899 );
1900
1901 cx.simulate_input("@symbol ");
1902
1903 editor.update(&mut cx, |editor, cx| {
1904 assert_eq!(
1905 editor.text(cx),
1906 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
1907 );
1908 assert!(editor.has_visible_completions_menu());
1909 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1910 });
1911
1912 editor.update_in(&mut cx, |editor, window, cx| {
1913 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1914 });
1915
1916 let symbol = MentionUri::Symbol {
1917 abs_path: path!("/dir/a/one.txt").into(),
1918 name: "MySymbol".into(),
1919 line_range: 0..=0,
1920 };
1921
1922 let contents = message_editor
1923 .update(&mut cx, |message_editor, cx| {
1924 message_editor
1925 .mention_set()
1926 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1927 })
1928 .await
1929 .unwrap()
1930 .into_values()
1931 .collect::<Vec<_>>();
1932
1933 {
1934 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1935 panic!("Unexpected mentions");
1936 };
1937 pretty_assertions::assert_eq!(content, "1");
1938 pretty_assertions::assert_eq!(uri, &symbol);
1939 }
1940
1941 cx.run_until_parked();
1942
1943 editor.read_with(&cx, |editor, cx| {
1944 assert_eq!(
1945 editor.text(cx),
1946 format!(
1947 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1948 symbol.to_uri(),
1949 )
1950 );
1951 });
1952
1953 // Try to mention an "image" file that will fail to load
1954 cx.simulate_input("@file x.png");
1955
1956 editor.update(&mut cx, |editor, cx| {
1957 assert_eq!(
1958 editor.text(cx),
1959 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1960 );
1961 assert!(editor.has_visible_completions_menu());
1962 assert_eq!(current_completion_labels(editor), &["x.png "]);
1963 });
1964
1965 editor.update_in(&mut cx, |editor, window, cx| {
1966 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1967 });
1968
1969 // Getting the message contents fails
1970 message_editor
1971 .update(&mut cx, |message_editor, cx| {
1972 message_editor
1973 .mention_set()
1974 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1975 })
1976 .await
1977 .expect_err("Should fail to load x.png");
1978
1979 cx.run_until_parked();
1980
1981 // Mention was removed
1982 editor.read_with(&cx, |editor, cx| {
1983 assert_eq!(
1984 editor.text(cx),
1985 format!(
1986 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1987 symbol.to_uri()
1988 )
1989 );
1990 });
1991
1992 // Once more
1993 cx.simulate_input("@file x.png");
1994
1995 editor.update(&mut cx, |editor, cx| {
1996 assert_eq!(
1997 editor.text(cx),
1998 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1999 );
2000 assert!(editor.has_visible_completions_menu());
2001 assert_eq!(current_completion_labels(editor), &["x.png "]);
2002 });
2003
2004 editor.update_in(&mut cx, |editor, window, cx| {
2005 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2006 });
2007
2008 // This time don't immediately get the contents, just let the confirmed completion settle
2009 cx.run_until_parked();
2010
2011 // Mention was removed
2012 editor.read_with(&cx, |editor, cx| {
2013 assert_eq!(
2014 editor.text(cx),
2015 format!(
2016 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2017 symbol.to_uri()
2018 )
2019 );
2020 });
2021
2022 // Now getting the contents succeeds, because the invalid mention was removed
2023 let contents = message_editor
2024 .update(&mut cx, |message_editor, cx| {
2025 message_editor
2026 .mention_set()
2027 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2028 })
2029 .await
2030 .unwrap();
2031 assert_eq!(contents.len(), 3);
2032 }
2033
2034 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2035 let snapshot = editor.buffer().read(cx).snapshot(cx);
2036 editor.display_map.update(cx, |display_map, cx| {
2037 display_map
2038 .snapshot(cx)
2039 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2040 .map(|fold| fold.range.to_point(&snapshot))
2041 .collect()
2042 })
2043 }
2044
2045 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2046 let completions = editor.current_completions().expect("Missing completions");
2047 completions
2048 .into_iter()
2049 .map(|completion| completion.label.text)
2050 .collect::<Vec<_>>()
2051 }
2052
2053 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2054 let completions = editor.current_completions().expect("Missing completions");
2055 completions
2056 .into_iter()
2057 .map(|completion| {
2058 (
2059 completion.label.text,
2060 completion
2061 .documentation
2062 .map(|d| d.text().to_string())
2063 .unwrap_or_default(),
2064 )
2065 })
2066 .collect::<Vec<_>>()
2067 }
2068
2069 #[gpui::test]
2070 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2071 init_test(cx);
2072
2073 let fs = FakeFs::new(cx.executor());
2074
2075 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2076 // Using plain text without a configured language, so no outline is available
2077 const LINE: &str = "This is a line of text in the file\n";
2078 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2079 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2080
2081 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2082 let small_content = "fn small_function() { /* small */ }\n";
2083 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2084
2085 fs.insert_tree(
2086 "/project",
2087 json!({
2088 "large_file.txt": large_content.clone(),
2089 "small_file.txt": small_content,
2090 }),
2091 )
2092 .await;
2093
2094 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2095
2096 let (workspace, cx) =
2097 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2098
2099 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2100 let session_list = Rc::new(RefCell::new(None));
2101
2102 let message_editor = cx.update(|window, cx| {
2103 cx.new(|cx| {
2104 let editor = MessageEditor::new(
2105 workspace.downgrade(),
2106 project.downgrade(),
2107 thread_store.clone(),
2108 session_list.clone(),
2109 None,
2110 Default::default(),
2111 Default::default(),
2112 "Test Agent".into(),
2113 "Test",
2114 EditorMode::AutoHeight {
2115 min_lines: 1,
2116 max_lines: None,
2117 },
2118 window,
2119 cx,
2120 );
2121 // Enable embedded context so files are actually included
2122 editor
2123 .prompt_capabilities
2124 .replace(acp::PromptCapabilities::new().embedded_context(true));
2125 editor
2126 })
2127 });
2128
2129 // Test large file mention
2130 // Get the absolute path using the project's worktree
2131 let large_file_abs_path = project.read_with(cx, |project, cx| {
2132 let worktree = project.worktrees(cx).next().unwrap();
2133 let worktree_root = worktree.read(cx).abs_path();
2134 worktree_root.join("large_file.txt")
2135 });
2136 let large_file_task = message_editor.update(cx, |editor, cx| {
2137 editor.mention_set().update(cx, |set, cx| {
2138 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2139 })
2140 });
2141
2142 let large_file_mention = large_file_task.await.unwrap();
2143 match large_file_mention {
2144 Mention::Text { content, .. } => {
2145 // Should contain some of the content but not all of it
2146 assert!(
2147 content.contains(LINE),
2148 "Should contain some of the file content"
2149 );
2150 assert!(
2151 !content.contains(&LINE.repeat(100)),
2152 "Should not contain the full file"
2153 );
2154 // Should be much smaller than original
2155 assert!(
2156 content.len() < large_content.len() / 10,
2157 "Should be significantly truncated"
2158 );
2159 }
2160 _ => panic!("Expected Text mention for large file"),
2161 }
2162
2163 // Test small file mention
2164 // Get the absolute path using the project's worktree
2165 let small_file_abs_path = project.read_with(cx, |project, cx| {
2166 let worktree = project.worktrees(cx).next().unwrap();
2167 let worktree_root = worktree.read(cx).abs_path();
2168 worktree_root.join("small_file.txt")
2169 });
2170 let small_file_task = message_editor.update(cx, |editor, cx| {
2171 editor.mention_set().update(cx, |set, cx| {
2172 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2173 })
2174 });
2175
2176 let small_file_mention = small_file_task.await.unwrap();
2177 match small_file_mention {
2178 Mention::Text { content, .. } => {
2179 // Should contain the full actual content
2180 assert_eq!(content, small_content);
2181 }
2182 _ => panic!("Expected Text mention for small file"),
2183 }
2184 }
2185
2186 #[gpui::test]
2187 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2188 init_test(cx);
2189 cx.update(LanguageModelRegistry::test);
2190
2191 let fs = FakeFs::new(cx.executor());
2192 fs.insert_tree("/project", json!({"file": ""})).await;
2193 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2194
2195 let (workspace, cx) =
2196 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2197
2198 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2199 let session_list = Rc::new(RefCell::new(None));
2200
2201 // Create a thread metadata to insert as summary
2202 let thread_metadata = AgentSessionInfo {
2203 session_id: acp::SessionId::new("thread-123"),
2204 cwd: None,
2205 title: Some("Previous Conversation".into()),
2206 updated_at: Some(chrono::Utc::now()),
2207 meta: None,
2208 };
2209
2210 let message_editor = cx.update(|window, cx| {
2211 cx.new(|cx| {
2212 let mut editor = MessageEditor::new(
2213 workspace.downgrade(),
2214 project.downgrade(),
2215 thread_store.clone(),
2216 session_list.clone(),
2217 None,
2218 Default::default(),
2219 Default::default(),
2220 "Test Agent".into(),
2221 "Test",
2222 EditorMode::AutoHeight {
2223 min_lines: 1,
2224 max_lines: None,
2225 },
2226 window,
2227 cx,
2228 );
2229 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2230 editor
2231 })
2232 });
2233
2234 // Construct expected values for verification
2235 let expected_uri = MentionUri::Thread {
2236 id: thread_metadata.session_id.clone(),
2237 name: thread_metadata.title.as_ref().unwrap().to_string(),
2238 };
2239 let expected_title = thread_metadata.title.as_ref().unwrap();
2240 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2241
2242 message_editor.read_with(cx, |editor, cx| {
2243 let text = editor.text(cx);
2244
2245 assert!(
2246 text.contains(&expected_link),
2247 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2248 expected_link,
2249 text
2250 );
2251
2252 let mentions = editor.mention_set().read(cx).mentions();
2253 assert_eq!(
2254 mentions.len(),
2255 1,
2256 "Expected exactly one mention after inserting thread summary"
2257 );
2258
2259 assert!(
2260 mentions.contains(&expected_uri),
2261 "Expected mentions to contain the thread URI"
2262 );
2263 });
2264 }
2265
2266 #[gpui::test]
2267 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2268 init_test(cx);
2269 cx.update(LanguageModelRegistry::test);
2270
2271 let fs = FakeFs::new(cx.executor());
2272 fs.insert_tree("/project", json!({"file": ""})).await;
2273 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2274
2275 let (workspace, cx) =
2276 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2277
2278 let thread_store = None;
2279 let session_list = Rc::new(RefCell::new(None));
2280
2281 let thread_metadata = AgentSessionInfo {
2282 session_id: acp::SessionId::new("thread-123"),
2283 cwd: None,
2284 title: Some("Previous Conversation".into()),
2285 updated_at: Some(chrono::Utc::now()),
2286 meta: None,
2287 };
2288
2289 let message_editor = cx.update(|window, cx| {
2290 cx.new(|cx| {
2291 let mut editor = MessageEditor::new(
2292 workspace.downgrade(),
2293 project.downgrade(),
2294 thread_store.clone(),
2295 session_list.clone(),
2296 None,
2297 Default::default(),
2298 Default::default(),
2299 "Test Agent".into(),
2300 "Test",
2301 EditorMode::AutoHeight {
2302 min_lines: 1,
2303 max_lines: None,
2304 },
2305 window,
2306 cx,
2307 );
2308 editor.insert_thread_summary(thread_metadata, window, cx);
2309 editor
2310 })
2311 });
2312
2313 message_editor.read_with(cx, |editor, cx| {
2314 assert!(
2315 editor.text(cx).is_empty(),
2316 "Expected thread summary to be skipped for external agents"
2317 );
2318 assert!(
2319 editor.mention_set().read(cx).mentions().is_empty(),
2320 "Expected no mentions when thread summary is skipped"
2321 );
2322 });
2323 }
2324
2325 #[gpui::test]
2326 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2327 init_test(cx);
2328
2329 let fs = FakeFs::new(cx.executor());
2330 fs.insert_tree("/project", json!({"file": ""})).await;
2331 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2332
2333 let (workspace, cx) =
2334 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2335
2336 let thread_store = None;
2337 let session_list = Rc::new(RefCell::new(None));
2338
2339 let message_editor = cx.update(|window, cx| {
2340 cx.new(|cx| {
2341 MessageEditor::new(
2342 workspace.downgrade(),
2343 project.downgrade(),
2344 thread_store.clone(),
2345 session_list.clone(),
2346 None,
2347 Default::default(),
2348 Default::default(),
2349 "Test Agent".into(),
2350 "Test",
2351 EditorMode::AutoHeight {
2352 min_lines: 1,
2353 max_lines: None,
2354 },
2355 window,
2356 cx,
2357 )
2358 })
2359 });
2360
2361 message_editor.update(cx, |editor, _cx| {
2362 editor
2363 .prompt_capabilities
2364 .replace(acp::PromptCapabilities::new().embedded_context(true));
2365 });
2366
2367 let supported_modes = {
2368 let app = cx.app.borrow();
2369 message_editor.supported_modes(&app)
2370 };
2371
2372 assert!(
2373 !supported_modes.contains(&PromptContextType::Thread),
2374 "Expected thread mode to be hidden when thread mentions are disabled"
2375 );
2376 }
2377
2378 #[gpui::test]
2379 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2380 init_test(cx);
2381
2382 let fs = FakeFs::new(cx.executor());
2383 fs.insert_tree("/project", json!({"file": ""})).await;
2384 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2385
2386 let (workspace, cx) =
2387 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2388
2389 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2390 let session_list = Rc::new(RefCell::new(None));
2391
2392 let message_editor = cx.update(|window, cx| {
2393 cx.new(|cx| {
2394 MessageEditor::new(
2395 workspace.downgrade(),
2396 project.downgrade(),
2397 thread_store.clone(),
2398 session_list.clone(),
2399 None,
2400 Default::default(),
2401 Default::default(),
2402 "Test Agent".into(),
2403 "Test",
2404 EditorMode::AutoHeight {
2405 min_lines: 1,
2406 max_lines: None,
2407 },
2408 window,
2409 cx,
2410 )
2411 })
2412 });
2413
2414 message_editor.update(cx, |editor, _cx| {
2415 editor
2416 .prompt_capabilities
2417 .replace(acp::PromptCapabilities::new().embedded_context(true));
2418 });
2419
2420 let supported_modes = {
2421 let app = cx.app.borrow();
2422 message_editor.supported_modes(&app)
2423 };
2424
2425 assert!(
2426 supported_modes.contains(&PromptContextType::Thread),
2427 "Expected thread mode to be visible when enabled"
2428 );
2429 }
2430
2431 #[gpui::test]
2432 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2433 init_test(cx);
2434
2435 let fs = FakeFs::new(cx.executor());
2436 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2437 .await;
2438 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2439
2440 let (workspace, cx) =
2441 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2442
2443 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2444 let session_list = Rc::new(RefCell::new(None));
2445
2446 let message_editor = cx.update(|window, cx| {
2447 cx.new(|cx| {
2448 MessageEditor::new(
2449 workspace.downgrade(),
2450 project.downgrade(),
2451 thread_store.clone(),
2452 session_list.clone(),
2453 None,
2454 Default::default(),
2455 Default::default(),
2456 "Test Agent".into(),
2457 "Test",
2458 EditorMode::AutoHeight {
2459 min_lines: 1,
2460 max_lines: None,
2461 },
2462 window,
2463 cx,
2464 )
2465 })
2466 });
2467 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2468
2469 cx.run_until_parked();
2470
2471 editor.update_in(cx, |editor, window, cx| {
2472 editor.set_text(" \u{A0}してhello world ", window, cx);
2473 });
2474
2475 let (content, _) = message_editor
2476 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2477 .await
2478 .unwrap();
2479
2480 assert_eq!(content, vec!["してhello world".into()]);
2481 }
2482
2483 #[gpui::test]
2484 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2485 init_test(cx);
2486
2487 let fs = FakeFs::new(cx.executor());
2488
2489 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2490
2491 fs.insert_tree(
2492 "/project",
2493 json!({
2494 "src": {
2495 "main.rs": file_content,
2496 }
2497 }),
2498 )
2499 .await;
2500
2501 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2502
2503 let (workspace, cx) =
2504 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2505
2506 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2507 let session_list = Rc::new(RefCell::new(None));
2508
2509 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2510 let workspace_handle = cx.weak_entity();
2511 let message_editor = cx.new(|cx| {
2512 MessageEditor::new(
2513 workspace_handle,
2514 project.downgrade(),
2515 thread_store.clone(),
2516 session_list.clone(),
2517 None,
2518 Default::default(),
2519 Default::default(),
2520 "Test Agent".into(),
2521 "Test",
2522 EditorMode::AutoHeight {
2523 max_lines: None,
2524 min_lines: 1,
2525 },
2526 window,
2527 cx,
2528 )
2529 });
2530 workspace.active_pane().update(cx, |pane, cx| {
2531 pane.add_item(
2532 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2533 true,
2534 true,
2535 None,
2536 window,
2537 cx,
2538 );
2539 });
2540 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2541 let editor = message_editor.read(cx).editor().clone();
2542 (message_editor, editor)
2543 });
2544
2545 cx.simulate_input("What is in @file main");
2546
2547 editor.update_in(cx, |editor, window, cx| {
2548 assert!(editor.has_visible_completions_menu());
2549 assert_eq!(editor.text(cx), "What is in @file main");
2550 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2551 });
2552
2553 let content = message_editor
2554 .update(cx, |editor, cx| editor.contents(false, cx))
2555 .await
2556 .unwrap()
2557 .0;
2558
2559 let main_rs_uri = if cfg!(windows) {
2560 "file:///C:/project/src/main.rs"
2561 } else {
2562 "file:///project/src/main.rs"
2563 };
2564
2565 // When embedded context is `false` we should get a resource link
2566 pretty_assertions::assert_eq!(
2567 content,
2568 vec![
2569 "What is in ".into(),
2570 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2571 ]
2572 );
2573
2574 message_editor.update(cx, |editor, _cx| {
2575 editor
2576 .prompt_capabilities
2577 .replace(acp::PromptCapabilities::new().embedded_context(true))
2578 });
2579
2580 let content = message_editor
2581 .update(cx, |editor, cx| editor.contents(false, cx))
2582 .await
2583 .unwrap()
2584 .0;
2585
2586 // When embedded context is `true` we should get a resource
2587 pretty_assertions::assert_eq!(
2588 content,
2589 vec![
2590 "What is in ".into(),
2591 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2592 acp::EmbeddedResourceResource::TextResourceContents(
2593 acp::TextResourceContents::new(file_content, main_rs_uri)
2594 )
2595 ))
2596 ]
2597 );
2598 }
2599
2600 #[gpui::test]
2601 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2602 init_test(cx);
2603
2604 let app_state = cx.update(AppState::test);
2605
2606 cx.update(|cx| {
2607 editor::init(cx);
2608 workspace::init(app_state.clone(), cx);
2609 });
2610
2611 app_state
2612 .fs
2613 .as_fake()
2614 .insert_tree(
2615 path!("/dir"),
2616 json!({
2617 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2618 }),
2619 )
2620 .await;
2621
2622 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2623 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2624 let workspace = window.root(cx).unwrap();
2625
2626 let worktree = project.update(cx, |project, cx| {
2627 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2628 assert_eq!(worktrees.len(), 1);
2629 worktrees.pop().unwrap()
2630 });
2631 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2632
2633 let mut cx = VisualTestContext::from_window(*window, cx);
2634
2635 // Open a regular editor with the created file, and select a portion of
2636 // the text that will be used for the selections that are meant to be
2637 // inserted in the agent panel.
2638 let editor = workspace
2639 .update_in(&mut cx, |workspace, window, cx| {
2640 workspace.open_path(
2641 ProjectPath {
2642 worktree_id,
2643 path: rel_path("test.txt").into(),
2644 },
2645 None,
2646 false,
2647 window,
2648 cx,
2649 )
2650 })
2651 .await
2652 .unwrap()
2653 .downcast::<Editor>()
2654 .unwrap();
2655
2656 editor.update_in(&mut cx, |editor, window, cx| {
2657 editor.change_selections(Default::default(), window, cx, |selections| {
2658 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2659 });
2660 });
2661
2662 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2663 let session_list = Rc::new(RefCell::new(None));
2664
2665 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2666 // to ensure we have a fixed viewport, so we can eventually actually
2667 // place the cursor outside of the visible area.
2668 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2669 let workspace_handle = cx.weak_entity();
2670 let message_editor = cx.new(|cx| {
2671 MessageEditor::new(
2672 workspace_handle,
2673 project.downgrade(),
2674 thread_store.clone(),
2675 session_list.clone(),
2676 None,
2677 Default::default(),
2678 Default::default(),
2679 "Test Agent".into(),
2680 "Test",
2681 EditorMode::full(),
2682 window,
2683 cx,
2684 )
2685 });
2686 workspace.active_pane().update(cx, |pane, cx| {
2687 pane.add_item(
2688 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2689 true,
2690 true,
2691 None,
2692 window,
2693 cx,
2694 );
2695 });
2696
2697 message_editor
2698 });
2699
2700 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2701 message_editor.editor.update(cx, |editor, cx| {
2702 // Update the Agent Panel's Message Editor text to have 100
2703 // lines, ensuring that the cursor is set at line 90 and that we
2704 // then scroll all the way to the top, so the cursor's position
2705 // remains off screen.
2706 let mut lines = String::new();
2707 for _ in 1..=100 {
2708 lines.push_str(&"Another line in the agent panel's message editor\n");
2709 }
2710 editor.set_text(lines.as_str(), window, cx);
2711 editor.change_selections(Default::default(), window, cx, |selections| {
2712 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2713 });
2714 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2715 });
2716 });
2717
2718 cx.run_until_parked();
2719
2720 // Before proceeding, let's assert that the cursor is indeed off screen,
2721 // otherwise the rest of the test doesn't make sense.
2722 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2723 message_editor.editor.update(cx, |editor, cx| {
2724 let snapshot = editor.snapshot(window, cx);
2725 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2726 let scroll_top = snapshot.scroll_position().y as u32;
2727 let visible_lines = editor.visible_line_count().unwrap() as u32;
2728 let visible_range = scroll_top..(scroll_top + visible_lines);
2729
2730 assert!(!visible_range.contains(&cursor_row));
2731 })
2732 });
2733
2734 // Now let's insert the selection in the Agent Panel's editor and
2735 // confirm that, after the insertion, the cursor is now in the visible
2736 // range.
2737 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2738 message_editor.insert_selections(window, cx);
2739 });
2740
2741 cx.run_until_parked();
2742
2743 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2744 message_editor.editor.update(cx, |editor, cx| {
2745 let snapshot = editor.snapshot(window, cx);
2746 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2747 let scroll_top = snapshot.scroll_position().y as u32;
2748 let visible_lines = editor.visible_line_count().unwrap() as u32;
2749 let visible_range = scroll_top..(scroll_top + visible_lines);
2750
2751 assert!(visible_range.contains(&cursor_row));
2752 })
2753 });
2754 }
2755}