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