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