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 = 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 //todo(): Custom slash command ContentBlock?
718 // let chunk = if prevent_slash_commands
719 // && ix == 0
720 // && parse_slash_command(&text[ix..]).is_some()
721 // {
722 // format!(" {}", &text[ix..crease_range.start]).into()
723 // } else {
724 // text[ix..crease_range.start].into()
725 // };
726 let chunk = text[ix..crease_range.start].into();
727 chunks.push(chunk);
728 }
729 let chunk = match mention {
730 Mention::Text {
731 content,
732 tracked_buffers,
733 } => {
734 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
735 acp::ContentBlock::Resource(acp::EmbeddedResource {
736 annotations: None,
737 resource: acp::EmbeddedResourceResource::TextResourceContents(
738 acp::TextResourceContents {
739 mime_type: None,
740 text: content.clone(),
741 uri: uri.to_uri().to_string(),
742 meta: None,
743 },
744 ),
745 meta: None,
746 })
747 }
748 Mention::Image(mention_image) => {
749 let uri = match uri {
750 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
751 MentionUri::PastedImage => None,
752 other => {
753 debug_panic!(
754 "unexpected mention uri for image: {:?}",
755 other
756 );
757 None
758 }
759 };
760 acp::ContentBlock::Image(acp::ImageContent {
761 annotations: None,
762 data: mention_image.data.to_string(),
763 mime_type: mention_image.format.mime_type().into(),
764 uri,
765 meta: None,
766 })
767 }
768 Mention::UriOnly => {
769 acp::ContentBlock::ResourceLink(acp::ResourceLink {
770 name: uri.name(),
771 uri: uri.to_uri().to_string(),
772 annotations: None,
773 description: None,
774 mime_type: None,
775 size: None,
776 title: None,
777 meta: None,
778 })
779 }
780 };
781 chunks.push(chunk);
782 ix = crease_range.end;
783 }
784
785 if ix < text.len() {
786 //todo(): Custom slash command ContentBlock?
787 // let last_chunk = if prevent_slash_commands
788 // && ix == 0
789 // && parse_slash_command(&text[ix..]).is_some()
790 // {
791 // format!(" {}", text[ix..].trim_end())
792 // } else {
793 // text[ix..].trim_end().to_owned()
794 // };
795 let last_chunk = text[ix..].trim_end().to_owned();
796 if !last_chunk.is_empty() {
797 chunks.push(last_chunk.into());
798 }
799 }
800 });
801 Ok((chunks, all_tracked_buffers))
802 })?;
803 result
804 })
805 }
806
807 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
808 self.editor.update(cx, |editor, cx| {
809 editor.clear(window, cx);
810 editor.remove_creases(
811 self.mention_set
812 .mentions
813 .drain()
814 .map(|(crease_id, _)| crease_id),
815 cx,
816 )
817 });
818 }
819
820 pub fn send(&mut self, cx: &mut Context<Self>) {
821 if self.is_empty(cx) {
822 return;
823 }
824 self.editor.update(cx, |editor, cx| {
825 editor.clear_inlay_hints(cx);
826 });
827 cx.emit(MessageEditorEvent::Send)
828 }
829
830 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
831 self.send(cx);
832 }
833
834 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
835 cx.emit(MessageEditorEvent::Cancel)
836 }
837
838 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
839 if !self.prompt_capabilities.borrow().image {
840 return;
841 }
842
843 let images = cx
844 .read_from_clipboard()
845 .map(|item| {
846 item.into_entries()
847 .filter_map(|entry| {
848 if let ClipboardEntry::Image(image) = entry {
849 Some(image)
850 } else {
851 None
852 }
853 })
854 .collect::<Vec<_>>()
855 })
856 .unwrap_or_default();
857
858 if images.is_empty() {
859 return;
860 }
861 cx.stop_propagation();
862
863 let replacement_text = MentionUri::PastedImage.as_link().to_string();
864 for image in images {
865 let (excerpt_id, text_anchor, multibuffer_anchor) =
866 self.editor.update(cx, |message_editor, cx| {
867 let snapshot = message_editor.snapshot(window, cx);
868 let (excerpt_id, _, buffer_snapshot) =
869 snapshot.buffer_snapshot().as_singleton().unwrap();
870
871 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
872 let multibuffer_anchor = snapshot
873 .buffer_snapshot()
874 .anchor_in_excerpt(*excerpt_id, text_anchor);
875 message_editor.edit(
876 [(
877 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
878 format!("{replacement_text} "),
879 )],
880 cx,
881 );
882 (*excerpt_id, text_anchor, multibuffer_anchor)
883 });
884
885 let content_len = replacement_text.len();
886 let Some(start_anchor) = multibuffer_anchor else {
887 continue;
888 };
889 let end_anchor = self.editor.update(cx, |editor, cx| {
890 let snapshot = editor.buffer().read(cx).snapshot(cx);
891 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
892 });
893 let image = Arc::new(image);
894 let Some((crease_id, tx)) = insert_crease_for_mention(
895 excerpt_id,
896 text_anchor,
897 content_len,
898 MentionUri::PastedImage.name().into(),
899 IconName::Image.path().into(),
900 Some(Task::ready(Ok(image.clone())).shared()),
901 self.editor.clone(),
902 window,
903 cx,
904 ) else {
905 continue;
906 };
907 let task = cx
908 .spawn_in(window, {
909 async move |_, cx| {
910 let format = image.format;
911 let image = cx
912 .update(|_, cx| LanguageModelImage::from_image(image, cx))
913 .map_err(|e| e.to_string())?
914 .await;
915 drop(tx);
916 if let Some(image) = image {
917 Ok(Mention::Image(MentionImage {
918 data: image.source,
919 format,
920 }))
921 } else {
922 Err("Failed to convert image".into())
923 }
924 }
925 })
926 .shared();
927
928 self.mention_set
929 .mentions
930 .insert(crease_id, (MentionUri::PastedImage, task.clone()));
931
932 cx.spawn_in(window, async move |this, cx| {
933 if task.await.notify_async_err(cx).is_none() {
934 this.update(cx, |this, cx| {
935 this.editor.update(cx, |editor, cx| {
936 editor.edit([(start_anchor..end_anchor, "")], cx);
937 });
938 this.mention_set.mentions.remove(&crease_id);
939 })
940 .ok();
941 }
942 })
943 .detach();
944 }
945 }
946
947 pub fn insert_dragged_files(
948 &mut self,
949 paths: Vec<project::ProjectPath>,
950 added_worktrees: Vec<Entity<Worktree>>,
951 window: &mut Window,
952 cx: &mut Context<Self>,
953 ) {
954 let path_style = self.project.read(cx).path_style(cx);
955 let buffer = self.editor.read(cx).buffer().clone();
956 let Some(buffer) = buffer.read(cx).as_singleton() else {
957 return;
958 };
959 let mut tasks = Vec::new();
960 for path in paths {
961 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
962 continue;
963 };
964 let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else {
965 continue;
966 };
967 let abs_path = worktree.read(cx).absolutize(&path.path);
968 let (file_name, _) =
969 crate::context_picker::file_context_picker::extract_file_name_and_directory(
970 &path.path,
971 worktree.read(cx).root_name(),
972 path_style,
973 );
974
975 let uri = if entry.is_dir() {
976 MentionUri::Directory { abs_path }
977 } else {
978 MentionUri::File { abs_path }
979 };
980
981 let new_text = format!("{} ", uri.as_link());
982 let content_len = new_text.len() - 1;
983
984 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
985
986 self.editor.update(cx, |message_editor, cx| {
987 message_editor.edit(
988 [(
989 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
990 new_text,
991 )],
992 cx,
993 );
994 });
995 tasks.push(self.confirm_mention_completion(
996 file_name,
997 anchor,
998 content_len,
999 uri,
1000 window,
1001 cx,
1002 ));
1003 }
1004 cx.spawn(async move |_, _| {
1005 join_all(tasks).await;
1006 drop(added_worktrees);
1007 })
1008 .detach();
1009 }
1010
1011 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1012 let editor = self.editor.read(cx);
1013 let editor_buffer = editor.buffer().read(cx);
1014 let Some(buffer) = editor_buffer.as_singleton() else {
1015 return;
1016 };
1017 let cursor_anchor = editor.selections.newest_anchor().head();
1018 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1019 let anchor = buffer.update(cx, |buffer, _cx| {
1020 buffer.anchor_before(cursor_offset.min(buffer.len()))
1021 });
1022 let Some(workspace) = self.workspace.upgrade() else {
1023 return;
1024 };
1025 let Some(completion) = ContextPickerCompletionProvider::completion_for_action(
1026 ContextPickerAction::AddSelections,
1027 anchor..anchor,
1028 cx.weak_entity(),
1029 &workspace,
1030 cx,
1031 ) else {
1032 return;
1033 };
1034
1035 self.editor.update(cx, |message_editor, cx| {
1036 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1037 });
1038 if let Some(confirm) = completion.confirm {
1039 confirm(CompletionIntent::Complete, window, cx);
1040 }
1041 }
1042
1043 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1044 self.editor.update(cx, |message_editor, cx| {
1045 message_editor.set_read_only(read_only);
1046 cx.notify()
1047 })
1048 }
1049
1050 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1051 self.editor.update(cx, |editor, cx| {
1052 editor.set_mode(mode);
1053 cx.notify()
1054 });
1055 }
1056
1057 pub fn set_message(
1058 &mut self,
1059 message: Vec<acp::ContentBlock>,
1060 window: &mut Window,
1061 cx: &mut Context<Self>,
1062 ) {
1063 self.clear(window, cx);
1064
1065 let path_style = self.project.read(cx).path_style(cx);
1066 let mut text = String::new();
1067 let mut mentions = Vec::new();
1068
1069 for chunk in message {
1070 match chunk {
1071 acp::ContentBlock::Text(text_content) => {
1072 text.push_str(&text_content.text);
1073 }
1074 acp::ContentBlock::Resource(acp::EmbeddedResource {
1075 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1076 ..
1077 }) => {
1078 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1079 else {
1080 continue;
1081 };
1082 let start = text.len();
1083 write!(&mut text, "{}", mention_uri.as_link()).ok();
1084 let end = text.len();
1085 mentions.push((
1086 start..end,
1087 mention_uri,
1088 Mention::Text {
1089 content: resource.text,
1090 tracked_buffers: Vec::new(),
1091 },
1092 ));
1093 }
1094 acp::ContentBlock::ResourceLink(resource) => {
1095 if let Some(mention_uri) =
1096 MentionUri::parse(&resource.uri, path_style).log_err()
1097 {
1098 let start = text.len();
1099 write!(&mut text, "{}", mention_uri.as_link()).ok();
1100 let end = text.len();
1101 mentions.push((start..end, mention_uri, Mention::UriOnly));
1102 }
1103 }
1104 acp::ContentBlock::Image(acp::ImageContent {
1105 uri,
1106 data,
1107 mime_type,
1108 annotations: _,
1109 meta: _,
1110 }) => {
1111 let mention_uri = if let Some(uri) = uri {
1112 MentionUri::parse(&uri, path_style)
1113 } else {
1114 Ok(MentionUri::PastedImage)
1115 };
1116 let Some(mention_uri) = mention_uri.log_err() else {
1117 continue;
1118 };
1119 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1120 log::error!("failed to parse MIME type for image: {mime_type:?}");
1121 continue;
1122 };
1123 let start = text.len();
1124 write!(&mut text, "{}", mention_uri.as_link()).ok();
1125 let end = text.len();
1126 mentions.push((
1127 start..end,
1128 mention_uri,
1129 Mention::Image(MentionImage {
1130 data: data.into(),
1131 format,
1132 }),
1133 ));
1134 }
1135 acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {}
1136 }
1137 }
1138
1139 let snapshot = self.editor.update(cx, |editor, cx| {
1140 editor.set_text(text, window, cx);
1141 editor.buffer().read(cx).snapshot(cx)
1142 });
1143
1144 for (range, mention_uri, mention) in mentions {
1145 let anchor = snapshot.anchor_before(range.start);
1146 let Some((crease_id, tx)) = insert_crease_for_mention(
1147 anchor.excerpt_id,
1148 anchor.text_anchor,
1149 range.end - range.start,
1150 mention_uri.name().into(),
1151 mention_uri.icon_path(cx),
1152 None,
1153 self.editor.clone(),
1154 window,
1155 cx,
1156 ) else {
1157 continue;
1158 };
1159 drop(tx);
1160
1161 self.mention_set.mentions.insert(
1162 crease_id,
1163 (mention_uri.clone(), Task::ready(Ok(mention)).shared()),
1164 );
1165 }
1166 cx.notify();
1167 }
1168
1169 pub fn text(&self, cx: &App) -> String {
1170 self.editor.read(cx).text(cx)
1171 }
1172
1173 #[cfg(test)]
1174 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1175 self.editor.update(cx, |editor, cx| {
1176 editor.set_text(text, window, cx);
1177 });
1178 }
1179}
1180
1181fn full_mention_for_directory(
1182 project: &Entity<Project>,
1183 abs_path: &Path,
1184 cx: &mut App,
1185) -> Task<Result<Mention>> {
1186 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1187 let mut files = Vec::new();
1188
1189 for entry in worktree.child_entries(path) {
1190 if entry.is_dir() {
1191 files.extend(collect_files_in_path(worktree, &entry.path));
1192 } else if entry.is_file() {
1193 files.push((
1194 entry.path.clone(),
1195 worktree
1196 .full_path(&entry.path)
1197 .to_string_lossy()
1198 .to_string(),
1199 ));
1200 }
1201 }
1202
1203 files
1204 }
1205
1206 let Some(project_path) = project
1207 .read(cx)
1208 .project_path_for_absolute_path(&abs_path, cx)
1209 else {
1210 return Task::ready(Err(anyhow!("project path not found")));
1211 };
1212 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1213 return Task::ready(Err(anyhow!("project entry not found")));
1214 };
1215 let directory_path = entry.path.clone();
1216 let worktree_id = project_path.worktree_id;
1217 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1218 return Task::ready(Err(anyhow!("worktree not found")));
1219 };
1220 let project = project.clone();
1221 cx.spawn(async move |cx| {
1222 let file_paths = worktree.read_with(cx, |worktree, _cx| {
1223 collect_files_in_path(worktree, &directory_path)
1224 })?;
1225 let descendants_future = cx.update(|cx| {
1226 join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
1227 let rel_path = worktree_path
1228 .strip_prefix(&directory_path)
1229 .log_err()
1230 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1231
1232 let open_task = project.update(cx, |project, cx| {
1233 project.buffer_store().update(cx, |buffer_store, cx| {
1234 let project_path = ProjectPath {
1235 worktree_id,
1236 path: worktree_path,
1237 };
1238 buffer_store.open_buffer(project_path, cx)
1239 })
1240 });
1241
1242 cx.spawn(async move |cx| {
1243 let buffer = open_task.await.log_err()?;
1244 let buffer_content = outline::get_buffer_content_or_outline(
1245 buffer.clone(),
1246 Some(&full_path),
1247 &cx,
1248 )
1249 .await
1250 .ok()?;
1251
1252 Some((rel_path, full_path, buffer_content.text, buffer))
1253 })
1254 }))
1255 })?;
1256
1257 let contents = cx
1258 .background_spawn(async move {
1259 let (contents, tracked_buffers) = descendants_future
1260 .await
1261 .into_iter()
1262 .flatten()
1263 .map(|(rel_path, full_path, rope, buffer)| {
1264 ((rel_path, full_path, rope), buffer)
1265 })
1266 .unzip();
1267 Mention::Text {
1268 content: render_directory_contents(contents),
1269 tracked_buffers,
1270 }
1271 })
1272 .await;
1273 anyhow::Ok(contents)
1274 })
1275}
1276
1277fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1278 let mut output = String::new();
1279 for (_relative_path, full_path, content) in entries {
1280 let fence = codeblock_fence_for_path(Some(&full_path), None);
1281 write!(output, "\n{fence}\n{content}\n```").unwrap();
1282 }
1283 output
1284}
1285
1286impl Focusable for MessageEditor {
1287 fn focus_handle(&self, cx: &App) -> FocusHandle {
1288 self.editor.focus_handle(cx)
1289 }
1290}
1291
1292impl Render for MessageEditor {
1293 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1294 div()
1295 .key_context("MessageEditor")
1296 .on_action(cx.listener(Self::chat))
1297 .on_action(cx.listener(Self::cancel))
1298 .capture_action(cx.listener(Self::paste))
1299 .flex_1()
1300 .child({
1301 let settings = ThemeSettings::get_global(cx);
1302
1303 let text_style = TextStyle {
1304 color: cx.theme().colors().text,
1305 font_family: settings.buffer_font.family.clone(),
1306 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1307 font_features: settings.buffer_font.features.clone(),
1308 font_size: settings.agent_buffer_font_size(cx).into(),
1309 line_height: relative(settings.buffer_line_height.value()),
1310 ..Default::default()
1311 };
1312
1313 EditorElement::new(
1314 &self.editor,
1315 EditorStyle {
1316 background: cx.theme().colors().editor_background,
1317 local_player: cx.theme().players().local(),
1318 text: text_style,
1319 syntax: cx.theme().syntax().clone(),
1320 inlay_hints_style: editor::make_inlay_hints_style(cx),
1321 ..Default::default()
1322 },
1323 )
1324 })
1325 }
1326}
1327
1328pub(crate) fn insert_crease_for_mention(
1329 excerpt_id: ExcerptId,
1330 anchor: text::Anchor,
1331 content_len: usize,
1332 crease_label: SharedString,
1333 crease_icon: SharedString,
1334 // abs_path: Option<Arc<Path>>,
1335 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1336 editor: Entity<Editor>,
1337 window: &mut Window,
1338 cx: &mut App,
1339) -> Option<(CreaseId, postage::barrier::Sender)> {
1340 let (tx, rx) = postage::barrier::channel();
1341
1342 let crease_id = editor.update(cx, |editor, cx| {
1343 let snapshot = editor.buffer().read(cx).snapshot(cx);
1344
1345 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
1346
1347 let start = start.bias_right(&snapshot);
1348 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
1349
1350 let placeholder = FoldPlaceholder {
1351 render: render_mention_fold_button(
1352 crease_label,
1353 crease_icon,
1354 start..end,
1355 rx,
1356 image,
1357 cx.weak_entity(),
1358 cx,
1359 ),
1360 merge_adjacent: false,
1361 ..Default::default()
1362 };
1363
1364 let crease = Crease::Inline {
1365 range: start..end,
1366 placeholder,
1367 render_toggle: None,
1368 render_trailer: None,
1369 metadata: None,
1370 };
1371
1372 let ids = editor.insert_creases(vec![crease.clone()], cx);
1373 editor.fold_creases(vec![crease], false, window, cx);
1374
1375 Some(ids[0])
1376 })?;
1377
1378 Some((crease_id, tx))
1379}
1380
1381fn render_mention_fold_button(
1382 label: SharedString,
1383 icon: SharedString,
1384 range: Range<Anchor>,
1385 mut loading_finished: postage::barrier::Receiver,
1386 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1387 editor: WeakEntity<Editor>,
1388 cx: &mut App,
1389) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1390 let loading = cx.new(|cx| {
1391 let loading = cx.spawn(async move |this, cx| {
1392 loading_finished.recv().await;
1393 this.update(cx, |this: &mut LoadingContext, cx| {
1394 this.loading = None;
1395 cx.notify();
1396 })
1397 .ok();
1398 });
1399 LoadingContext {
1400 id: cx.entity_id(),
1401 label,
1402 icon,
1403 range,
1404 editor,
1405 loading: Some(loading),
1406 image: image_task.clone(),
1407 }
1408 });
1409 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1410}
1411
1412struct LoadingContext {
1413 id: EntityId,
1414 label: SharedString,
1415 icon: SharedString,
1416 range: Range<Anchor>,
1417 editor: WeakEntity<Editor>,
1418 loading: Option<Task<()>>,
1419 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1420}
1421
1422impl Render for LoadingContext {
1423 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1424 let is_in_text_selection = self
1425 .editor
1426 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1427 .unwrap_or_default();
1428 ButtonLike::new(("loading-context", self.id))
1429 .style(ButtonStyle::Filled)
1430 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1431 .toggle_state(is_in_text_selection)
1432 .when_some(self.image.clone(), |el, image_task| {
1433 el.hoverable_tooltip(move |_, cx| {
1434 let image = image_task.peek().cloned().transpose().ok().flatten();
1435 let image_task = image_task.clone();
1436 cx.new::<ImageHover>(|cx| ImageHover {
1437 image,
1438 _task: cx.spawn(async move |this, cx| {
1439 if let Ok(image) = image_task.clone().await {
1440 this.update(cx, |this, cx| {
1441 if this.image.replace(image).is_none() {
1442 cx.notify();
1443 }
1444 })
1445 .ok();
1446 }
1447 }),
1448 })
1449 .into()
1450 })
1451 })
1452 .child(
1453 h_flex()
1454 .gap_1()
1455 .child(
1456 Icon::from_path(self.icon.clone())
1457 .size(IconSize::XSmall)
1458 .color(Color::Muted),
1459 )
1460 .child(
1461 Label::new(self.label.clone())
1462 .size(LabelSize::Small)
1463 .buffer_font(cx)
1464 .single_line(),
1465 )
1466 .map(|el| {
1467 if self.loading.is_some() {
1468 el.with_animation(
1469 "loading-context-crease",
1470 Animation::new(Duration::from_secs(2))
1471 .repeat()
1472 .with_easing(pulsating_between(0.4, 0.8)),
1473 |label, delta| label.opacity(delta),
1474 )
1475 .into_any()
1476 } else {
1477 el.into_any()
1478 }
1479 }),
1480 )
1481 }
1482}
1483
1484struct ImageHover {
1485 image: Option<Arc<Image>>,
1486 _task: Task<()>,
1487}
1488
1489impl Render for ImageHover {
1490 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1491 if let Some(image) = self.image.clone() {
1492 gpui::img(image).max_w_96().max_h_96().into_any_element()
1493 } else {
1494 gpui::Empty.into_any_element()
1495 }
1496 }
1497}
1498
1499#[derive(Debug, Clone, Eq, PartialEq)]
1500pub enum Mention {
1501 Text {
1502 content: String,
1503 tracked_buffers: Vec<Entity<Buffer>>,
1504 },
1505 Image(MentionImage),
1506 UriOnly,
1507}
1508
1509#[derive(Clone, Debug, Eq, PartialEq)]
1510pub struct MentionImage {
1511 pub data: SharedString,
1512 pub format: ImageFormat,
1513}
1514
1515#[derive(Default)]
1516pub struct MentionSet {
1517 mentions: HashMap<CreaseId, (MentionUri, Shared<Task<Result<Mention, String>>>)>,
1518}
1519
1520impl MentionSet {
1521 fn contents(
1522 &self,
1523 prompt_capabilities: &acp::PromptCapabilities,
1524 full_mention_content: bool,
1525 project: Entity<Project>,
1526 cx: &mut App,
1527 ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
1528 if !prompt_capabilities.embedded_context {
1529 let mentions = self
1530 .mentions
1531 .iter()
1532 .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly)))
1533 .collect();
1534
1535 return Task::ready(Ok(mentions));
1536 }
1537
1538 let mentions = self.mentions.clone();
1539 cx.spawn(async move |cx| {
1540 let mut contents = HashMap::default();
1541 for (crease_id, (mention_uri, task)) in mentions {
1542 let content = if full_mention_content
1543 && let MentionUri::Directory { abs_path } = &mention_uri
1544 {
1545 cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))?
1546 .await?
1547 } else {
1548 task.await.map_err(|e| anyhow!("{e}"))?
1549 };
1550
1551 contents.insert(crease_id, (mention_uri, content));
1552 }
1553 Ok(contents)
1554 })
1555 }
1556
1557 fn remove_invalid(&mut self, snapshot: EditorSnapshot) {
1558 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
1559 if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) {
1560 self.mentions.remove(&crease_id);
1561 }
1562 }
1563 }
1564}
1565
1566pub struct MessageEditorAddon {}
1567
1568impl MessageEditorAddon {
1569 pub fn new() -> Self {
1570 Self {}
1571 }
1572}
1573
1574impl Addon for MessageEditorAddon {
1575 fn to_any(&self) -> &dyn std::any::Any {
1576 self
1577 }
1578
1579 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1580 Some(self)
1581 }
1582
1583 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1584 let settings = agent_settings::AgentSettings::get_global(cx);
1585 if settings.use_modifier_to_send {
1586 key_context.add("use_modifier_to_send");
1587 }
1588 }
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1594
1595 use acp_thread::MentionUri;
1596 use agent::{HistoryStore, outline};
1597 use agent_client_protocol as acp;
1598 use assistant_text_thread::TextThreadStore;
1599 use editor::{AnchorRangeExt as _, Editor, EditorMode};
1600 use fs::FakeFs;
1601 use futures::StreamExt as _;
1602 use gpui::{
1603 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1604 };
1605 use lsp::{CompletionContext, CompletionTriggerKind};
1606 use project::{CompletionIntent, Project, ProjectPath};
1607 use serde_json::json;
1608 use text::Point;
1609 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1610 use util::{path, paths::PathStyle, rel_path::rel_path};
1611 use workspace::{AppState, Item, Workspace};
1612
1613 use crate::acp::{
1614 message_editor::{Mention, MessageEditor},
1615 thread_view::tests::init_test,
1616 };
1617
1618 #[gpui::test]
1619 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1620 init_test(cx);
1621
1622 let fs = FakeFs::new(cx.executor());
1623 fs.insert_tree("/project", json!({"file": ""})).await;
1624 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1625
1626 let (workspace, cx) =
1627 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1628
1629 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1630 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1631
1632 let message_editor = cx.update(|window, cx| {
1633 cx.new(|cx| {
1634 MessageEditor::new(
1635 workspace.downgrade(),
1636 project.clone(),
1637 history_store.clone(),
1638 None,
1639 Default::default(),
1640 Default::default(),
1641 "Test Agent".into(),
1642 "Test",
1643 EditorMode::AutoHeight {
1644 min_lines: 1,
1645 max_lines: None,
1646 },
1647 window,
1648 cx,
1649 )
1650 })
1651 });
1652 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1653
1654 cx.run_until_parked();
1655
1656 let excerpt_id = editor.update(cx, |editor, cx| {
1657 editor
1658 .buffer()
1659 .read(cx)
1660 .excerpt_ids()
1661 .into_iter()
1662 .next()
1663 .unwrap()
1664 });
1665 let completions = editor.update_in(cx, |editor, window, cx| {
1666 editor.set_text("Hello @file ", window, cx);
1667 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1668 let completion_provider = editor.completion_provider().unwrap();
1669 completion_provider.completions(
1670 excerpt_id,
1671 &buffer,
1672 text::Anchor::MAX,
1673 CompletionContext {
1674 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1675 trigger_character: Some("@".into()),
1676 },
1677 window,
1678 cx,
1679 )
1680 });
1681 let [_, completion]: [_; 2] = completions
1682 .await
1683 .unwrap()
1684 .into_iter()
1685 .flat_map(|response| response.completions)
1686 .collect::<Vec<_>>()
1687 .try_into()
1688 .unwrap();
1689
1690 editor.update_in(cx, |editor, window, cx| {
1691 let snapshot = editor.buffer().read(cx).snapshot(cx);
1692 let range = snapshot
1693 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1694 .unwrap();
1695 editor.edit([(range, completion.new_text)], cx);
1696 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1697 });
1698
1699 cx.run_until_parked();
1700
1701 // Backspace over the inserted crease (and the following space).
1702 editor.update_in(cx, |editor, window, cx| {
1703 editor.backspace(&Default::default(), window, cx);
1704 editor.backspace(&Default::default(), window, cx);
1705 });
1706
1707 let (content, _) = message_editor
1708 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1709 .await
1710 .unwrap();
1711
1712 // We don't send a resource link for the deleted crease.
1713 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1714 }
1715
1716 #[gpui::test]
1717 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1718 init_test(cx);
1719 let fs = FakeFs::new(cx.executor());
1720 fs.insert_tree(
1721 "/test",
1722 json!({
1723 ".zed": {
1724 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1725 },
1726 "src": {
1727 "main.rs": "fn main() {}",
1728 },
1729 }),
1730 )
1731 .await;
1732
1733 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1734 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1735 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1736 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1737 // Start with no available commands - simulating Claude which doesn't support slash commands
1738 let available_commands = Rc::new(RefCell::new(vec![]));
1739
1740 let (workspace, cx) =
1741 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1742 let workspace_handle = workspace.downgrade();
1743 let message_editor = workspace.update_in(cx, |_, window, cx| {
1744 cx.new(|cx| {
1745 MessageEditor::new(
1746 workspace_handle.clone(),
1747 project.clone(),
1748 history_store.clone(),
1749 None,
1750 prompt_capabilities.clone(),
1751 available_commands.clone(),
1752 "Claude Code".into(),
1753 "Test",
1754 EditorMode::AutoHeight {
1755 min_lines: 1,
1756 max_lines: None,
1757 },
1758 window,
1759 cx,
1760 )
1761 })
1762 });
1763 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1764
1765 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1766 editor.update_in(cx, |editor, window, cx| {
1767 editor.set_text("/file test.txt", window, cx);
1768 });
1769
1770 let contents_result = message_editor
1771 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1772 .await;
1773
1774 // Should fail because available_commands is empty (no commands supported)
1775 assert!(contents_result.is_err());
1776 let error_message = contents_result.unwrap_err().to_string();
1777 assert!(error_message.contains("not supported by Claude Code"));
1778 assert!(error_message.contains("Available commands: none"));
1779
1780 // Now simulate Claude providing its list of available commands (which doesn't include file)
1781 available_commands.replace(vec![acp::AvailableCommand {
1782 name: "help".to_string(),
1783 description: "Get help".to_string(),
1784 input: None,
1785 meta: None,
1786 }]);
1787
1788 // Test that unsupported slash commands trigger an error when we have a list of available commands
1789 editor.update_in(cx, |editor, window, cx| {
1790 editor.set_text("/file test.txt", window, cx);
1791 });
1792
1793 let contents_result = message_editor
1794 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1795 .await;
1796
1797 assert!(contents_result.is_err());
1798 let error_message = contents_result.unwrap_err().to_string();
1799 assert!(error_message.contains("not supported by Claude Code"));
1800 assert!(error_message.contains("/file"));
1801 assert!(error_message.contains("Available commands: /help"));
1802
1803 // Test that supported commands work fine
1804 editor.update_in(cx, |editor, window, cx| {
1805 editor.set_text("/help", window, cx);
1806 });
1807
1808 let contents_result = message_editor
1809 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1810 .await;
1811
1812 // Should succeed because /help is in available_commands
1813 assert!(contents_result.is_ok());
1814
1815 // Test that regular text works fine
1816 editor.update_in(cx, |editor, window, cx| {
1817 editor.set_text("Hello Claude!", window, cx);
1818 });
1819
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, "Hello Claude!");
1828 } else {
1829 panic!("Expected ContentBlock::Text");
1830 }
1831
1832 // Test that @ mentions still work
1833 editor.update_in(cx, |editor, window, cx| {
1834 editor.set_text("Check this @", window, cx);
1835 });
1836
1837 // The @ mention functionality should not be affected
1838 let (content, _) = message_editor
1839 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1840 .await
1841 .unwrap();
1842
1843 assert_eq!(content.len(), 1);
1844 if let acp::ContentBlock::Text(text) = &content[0] {
1845 assert_eq!(text.text, "Check this @");
1846 } else {
1847 panic!("Expected ContentBlock::Text");
1848 }
1849 }
1850
1851 struct MessageEditorItem(Entity<MessageEditor>);
1852
1853 impl Item for MessageEditorItem {
1854 type Event = ();
1855
1856 fn include_in_nav_history() -> bool {
1857 false
1858 }
1859
1860 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1861 "Test".into()
1862 }
1863 }
1864
1865 impl EventEmitter<()> for MessageEditorItem {}
1866
1867 impl Focusable for MessageEditorItem {
1868 fn focus_handle(&self, cx: &App) -> FocusHandle {
1869 self.0.read(cx).focus_handle(cx)
1870 }
1871 }
1872
1873 impl Render for MessageEditorItem {
1874 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1875 self.0.clone().into_any_element()
1876 }
1877 }
1878
1879 #[gpui::test]
1880 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1881 init_test(cx);
1882
1883 let app_state = cx.update(AppState::test);
1884
1885 cx.update(|cx| {
1886 language::init(cx);
1887 editor::init(cx);
1888 workspace::init(app_state.clone(), cx);
1889 Project::init_settings(cx);
1890 });
1891
1892 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1893 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1894 let workspace = window.root(cx).unwrap();
1895
1896 let mut cx = VisualTestContext::from_window(*window, cx);
1897
1898 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1899 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1900 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1901 let available_commands = Rc::new(RefCell::new(vec![
1902 acp::AvailableCommand {
1903 name: "quick-math".to_string(),
1904 description: "2 + 2 = 4 - 1 = 3".to_string(),
1905 input: None,
1906 meta: None,
1907 },
1908 acp::AvailableCommand {
1909 name: "say-hello".to_string(),
1910 description: "Say hello to whoever you want".to_string(),
1911 input: Some(acp::AvailableCommandInput::Unstructured {
1912 hint: "<name>".to_string(),
1913 }),
1914 meta: None,
1915 },
1916 ]));
1917
1918 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1919 let workspace_handle = cx.weak_entity();
1920 let message_editor = cx.new(|cx| {
1921 MessageEditor::new(
1922 workspace_handle,
1923 project.clone(),
1924 history_store.clone(),
1925 None,
1926 prompt_capabilities.clone(),
1927 available_commands.clone(),
1928 "Test Agent".into(),
1929 "Test",
1930 EditorMode::AutoHeight {
1931 max_lines: None,
1932 min_lines: 1,
1933 },
1934 window,
1935 cx,
1936 )
1937 });
1938 workspace.active_pane().update(cx, |pane, cx| {
1939 pane.add_item(
1940 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1941 true,
1942 true,
1943 None,
1944 window,
1945 cx,
1946 );
1947 });
1948 message_editor.read(cx).focus_handle(cx).focus(window);
1949 message_editor.read(cx).editor().clone()
1950 });
1951
1952 cx.simulate_input("/");
1953
1954 editor.update_in(&mut cx, |editor, window, cx| {
1955 assert_eq!(editor.text(cx), "/");
1956 assert!(editor.has_visible_completions_menu());
1957
1958 assert_eq!(
1959 current_completion_labels_with_documentation(editor),
1960 &[
1961 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1962 ("say-hello".into(), "Say hello to whoever you want".into())
1963 ]
1964 );
1965 editor.set_text("", window, cx);
1966 });
1967
1968 cx.simulate_input("/qui");
1969
1970 editor.update_in(&mut cx, |editor, window, cx| {
1971 assert_eq!(editor.text(cx), "/qui");
1972 assert!(editor.has_visible_completions_menu());
1973
1974 assert_eq!(
1975 current_completion_labels_with_documentation(editor),
1976 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1977 );
1978 editor.set_text("", window, cx);
1979 });
1980
1981 editor.update_in(&mut cx, |editor, window, cx| {
1982 assert!(editor.has_visible_completions_menu());
1983 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1984 });
1985
1986 cx.run_until_parked();
1987
1988 editor.update_in(&mut cx, |editor, window, cx| {
1989 assert_eq!(editor.display_text(cx), "/quick-math ");
1990 assert!(!editor.has_visible_completions_menu());
1991 editor.set_text("", window, cx);
1992 });
1993
1994 cx.simulate_input("/say");
1995
1996 editor.update_in(&mut cx, |editor, _window, cx| {
1997 assert_eq!(editor.display_text(cx), "/say");
1998 assert!(editor.has_visible_completions_menu());
1999
2000 assert_eq!(
2001 current_completion_labels_with_documentation(editor),
2002 &[("say-hello".into(), "Say hello to whoever you want".into())]
2003 );
2004 });
2005
2006 editor.update_in(&mut cx, |editor, window, cx| {
2007 assert!(editor.has_visible_completions_menu());
2008 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2009 });
2010
2011 cx.run_until_parked();
2012
2013 editor.update_in(&mut cx, |editor, _window, cx| {
2014 assert_eq!(editor.text(cx), "/say-hello ");
2015 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2016 assert!(!editor.has_visible_completions_menu());
2017 });
2018
2019 cx.simulate_input("GPT5");
2020
2021 cx.run_until_parked();
2022
2023 editor.update_in(&mut cx, |editor, window, cx| {
2024 assert_eq!(editor.text(cx), "/say-hello GPT5");
2025 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2026 assert!(!editor.has_visible_completions_menu());
2027
2028 // Delete argument
2029 for _ in 0..5 {
2030 editor.backspace(&editor::actions::Backspace, window, cx);
2031 }
2032 });
2033
2034 cx.run_until_parked();
2035
2036 editor.update_in(&mut cx, |editor, window, cx| {
2037 assert_eq!(editor.text(cx), "/say-hello");
2038 // Hint is visible because argument was deleted
2039 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2040
2041 // Delete last command letter
2042 editor.backspace(&editor::actions::Backspace, window, cx);
2043 });
2044
2045 cx.run_until_parked();
2046
2047 editor.update_in(&mut cx, |editor, _window, cx| {
2048 // Hint goes away once command no longer matches an available one
2049 assert_eq!(editor.text(cx), "/say-hell");
2050 assert_eq!(editor.display_text(cx), "/say-hell");
2051 assert!(!editor.has_visible_completions_menu());
2052 });
2053 }
2054
2055 #[gpui::test]
2056 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2057 init_test(cx);
2058
2059 let app_state = cx.update(AppState::test);
2060
2061 cx.update(|cx| {
2062 language::init(cx);
2063 editor::init(cx);
2064 workspace::init(app_state.clone(), cx);
2065 Project::init_settings(cx);
2066 });
2067
2068 app_state
2069 .fs
2070 .as_fake()
2071 .insert_tree(
2072 path!("/dir"),
2073 json!({
2074 "editor": "",
2075 "a": {
2076 "one.txt": "1",
2077 "two.txt": "2",
2078 "three.txt": "3",
2079 "four.txt": "4"
2080 },
2081 "b": {
2082 "five.txt": "5",
2083 "six.txt": "6",
2084 "seven.txt": "7",
2085 "eight.txt": "8",
2086 },
2087 "x.png": "",
2088 }),
2089 )
2090 .await;
2091
2092 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2093 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2094 let workspace = window.root(cx).unwrap();
2095
2096 let worktree = project.update(cx, |project, cx| {
2097 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2098 assert_eq!(worktrees.len(), 1);
2099 worktrees.pop().unwrap()
2100 });
2101 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2102
2103 let mut cx = VisualTestContext::from_window(*window, cx);
2104
2105 let paths = vec![
2106 rel_path("a/one.txt"),
2107 rel_path("a/two.txt"),
2108 rel_path("a/three.txt"),
2109 rel_path("a/four.txt"),
2110 rel_path("b/five.txt"),
2111 rel_path("b/six.txt"),
2112 rel_path("b/seven.txt"),
2113 rel_path("b/eight.txt"),
2114 ];
2115
2116 let slash = PathStyle::local().separator();
2117
2118 let mut opened_editors = Vec::new();
2119 for path in paths {
2120 let buffer = workspace
2121 .update_in(&mut cx, |workspace, window, cx| {
2122 workspace.open_path(
2123 ProjectPath {
2124 worktree_id,
2125 path: path.into(),
2126 },
2127 None,
2128 false,
2129 window,
2130 cx,
2131 )
2132 })
2133 .await
2134 .unwrap();
2135 opened_editors.push(buffer);
2136 }
2137
2138 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2139 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2140 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2141
2142 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2143 let workspace_handle = cx.weak_entity();
2144 let message_editor = cx.new(|cx| {
2145 MessageEditor::new(
2146 workspace_handle,
2147 project.clone(),
2148 history_store.clone(),
2149 None,
2150 prompt_capabilities.clone(),
2151 Default::default(),
2152 "Test Agent".into(),
2153 "Test",
2154 EditorMode::AutoHeight {
2155 max_lines: None,
2156 min_lines: 1,
2157 },
2158 window,
2159 cx,
2160 )
2161 });
2162 workspace.active_pane().update(cx, |pane, cx| {
2163 pane.add_item(
2164 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2165 true,
2166 true,
2167 None,
2168 window,
2169 cx,
2170 );
2171 });
2172 message_editor.read(cx).focus_handle(cx).focus(window);
2173 let editor = message_editor.read(cx).editor().clone();
2174 (message_editor, editor)
2175 });
2176
2177 cx.simulate_input("Lorem @");
2178
2179 editor.update_in(&mut cx, |editor, window, cx| {
2180 assert_eq!(editor.text(cx), "Lorem @");
2181 assert!(editor.has_visible_completions_menu());
2182
2183 assert_eq!(
2184 current_completion_labels(editor),
2185 &[
2186 format!("eight.txt dir{slash}b{slash}"),
2187 format!("seven.txt dir{slash}b{slash}"),
2188 format!("six.txt dir{slash}b{slash}"),
2189 format!("five.txt dir{slash}b{slash}"),
2190 ]
2191 );
2192 editor.set_text("", window, cx);
2193 });
2194
2195 prompt_capabilities.replace(acp::PromptCapabilities {
2196 image: true,
2197 audio: true,
2198 embedded_context: true,
2199 meta: None,
2200 });
2201
2202 cx.simulate_input("Lorem ");
2203
2204 editor.update(&mut cx, |editor, cx| {
2205 assert_eq!(editor.text(cx), "Lorem ");
2206 assert!(!editor.has_visible_completions_menu());
2207 });
2208
2209 cx.simulate_input("@");
2210
2211 editor.update(&mut cx, |editor, cx| {
2212 assert_eq!(editor.text(cx), "Lorem @");
2213 assert!(editor.has_visible_completions_menu());
2214 assert_eq!(
2215 current_completion_labels(editor),
2216 &[
2217 format!("eight.txt dir{slash}b{slash}"),
2218 format!("seven.txt dir{slash}b{slash}"),
2219 format!("six.txt dir{slash}b{slash}"),
2220 format!("five.txt dir{slash}b{slash}"),
2221 "Files & Directories".into(),
2222 "Symbols".into(),
2223 "Threads".into(),
2224 "Fetch".into()
2225 ]
2226 );
2227 });
2228
2229 // Select and confirm "File"
2230 editor.update_in(&mut cx, |editor, window, cx| {
2231 assert!(editor.has_visible_completions_menu());
2232 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2233 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2234 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2235 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2236 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2237 });
2238
2239 cx.run_until_parked();
2240
2241 editor.update(&mut cx, |editor, cx| {
2242 assert_eq!(editor.text(cx), "Lorem @file ");
2243 assert!(editor.has_visible_completions_menu());
2244 });
2245
2246 cx.simulate_input("one");
2247
2248 editor.update(&mut cx, |editor, cx| {
2249 assert_eq!(editor.text(cx), "Lorem @file one");
2250 assert!(editor.has_visible_completions_menu());
2251 assert_eq!(
2252 current_completion_labels(editor),
2253 vec![format!("one.txt dir{slash}a{slash}")]
2254 );
2255 });
2256
2257 editor.update_in(&mut cx, |editor, window, cx| {
2258 assert!(editor.has_visible_completions_menu());
2259 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2260 });
2261
2262 let url_one = MentionUri::File {
2263 abs_path: path!("/dir/a/one.txt").into(),
2264 }
2265 .to_uri()
2266 .to_string();
2267 editor.update(&mut cx, |editor, cx| {
2268 let text = editor.text(cx);
2269 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2270 assert!(!editor.has_visible_completions_menu());
2271 assert_eq!(fold_ranges(editor, cx).len(), 1);
2272 });
2273
2274 let all_prompt_capabilities = acp::PromptCapabilities {
2275 image: true,
2276 audio: true,
2277 embedded_context: true,
2278 meta: None,
2279 };
2280
2281 let contents = message_editor
2282 .update(&mut cx, |message_editor, cx| {
2283 message_editor.mention_set().contents(
2284 &all_prompt_capabilities,
2285 false,
2286 project.clone(),
2287 cx,
2288 )
2289 })
2290 .await
2291 .unwrap()
2292 .into_values()
2293 .collect::<Vec<_>>();
2294
2295 {
2296 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2297 panic!("Unexpected mentions");
2298 };
2299 pretty_assertions::assert_eq!(content, "1");
2300 pretty_assertions::assert_eq!(
2301 uri,
2302 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2303 );
2304 }
2305
2306 let contents = message_editor
2307 .update(&mut cx, |message_editor, cx| {
2308 message_editor.mention_set().contents(
2309 &acp::PromptCapabilities::default(),
2310 false,
2311 project.clone(),
2312 cx,
2313 )
2314 })
2315 .await
2316 .unwrap()
2317 .into_values()
2318 .collect::<Vec<_>>();
2319
2320 {
2321 let [(uri, Mention::UriOnly)] = contents.as_slice() else {
2322 panic!("Unexpected mentions");
2323 };
2324 pretty_assertions::assert_eq!(
2325 uri,
2326 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2327 );
2328 }
2329
2330 cx.simulate_input(" ");
2331
2332 editor.update(&mut cx, |editor, cx| {
2333 let text = editor.text(cx);
2334 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2335 assert!(!editor.has_visible_completions_menu());
2336 assert_eq!(fold_ranges(editor, cx).len(), 1);
2337 });
2338
2339 cx.simulate_input("Ipsum ");
2340
2341 editor.update(&mut cx, |editor, cx| {
2342 let text = editor.text(cx);
2343 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2344 assert!(!editor.has_visible_completions_menu());
2345 assert_eq!(fold_ranges(editor, cx).len(), 1);
2346 });
2347
2348 cx.simulate_input("@file ");
2349
2350 editor.update(&mut cx, |editor, cx| {
2351 let text = editor.text(cx);
2352 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2353 assert!(editor.has_visible_completions_menu());
2354 assert_eq!(fold_ranges(editor, cx).len(), 1);
2355 });
2356
2357 editor.update_in(&mut cx, |editor, window, cx| {
2358 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2359 });
2360
2361 cx.run_until_parked();
2362
2363 let contents = message_editor
2364 .update(&mut cx, |message_editor, cx| {
2365 message_editor.mention_set().contents(
2366 &all_prompt_capabilities,
2367 false,
2368 project.clone(),
2369 cx,
2370 )
2371 })
2372 .await
2373 .unwrap()
2374 .into_values()
2375 .collect::<Vec<_>>();
2376
2377 let url_eight = MentionUri::File {
2378 abs_path: path!("/dir/b/eight.txt").into(),
2379 }
2380 .to_uri()
2381 .to_string();
2382
2383 {
2384 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2385 panic!("Unexpected mentions");
2386 };
2387 pretty_assertions::assert_eq!(content, "8");
2388 pretty_assertions::assert_eq!(
2389 uri,
2390 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2391 );
2392 }
2393
2394 editor.update(&mut cx, |editor, cx| {
2395 assert_eq!(
2396 editor.text(cx),
2397 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2398 );
2399 assert!(!editor.has_visible_completions_menu());
2400 assert_eq!(fold_ranges(editor, cx).len(), 2);
2401 });
2402
2403 let plain_text_language = Arc::new(language::Language::new(
2404 language::LanguageConfig {
2405 name: "Plain Text".into(),
2406 matcher: language::LanguageMatcher {
2407 path_suffixes: vec!["txt".to_string()],
2408 ..Default::default()
2409 },
2410 ..Default::default()
2411 },
2412 None,
2413 ));
2414
2415 // Register the language and fake LSP
2416 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2417 language_registry.add(plain_text_language);
2418
2419 let mut fake_language_servers = language_registry.register_fake_lsp(
2420 "Plain Text",
2421 language::FakeLspAdapter {
2422 capabilities: lsp::ServerCapabilities {
2423 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2424 ..Default::default()
2425 },
2426 ..Default::default()
2427 },
2428 );
2429
2430 // Open the buffer to trigger LSP initialization
2431 let buffer = project
2432 .update(&mut cx, |project, cx| {
2433 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2434 })
2435 .await
2436 .unwrap();
2437
2438 // Register the buffer with language servers
2439 let _handle = project.update(&mut cx, |project, cx| {
2440 project.register_buffer_with_language_servers(&buffer, cx)
2441 });
2442
2443 cx.run_until_parked();
2444
2445 let fake_language_server = fake_language_servers.next().await.unwrap();
2446 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2447 move |_, _| async move {
2448 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2449 #[allow(deprecated)]
2450 lsp::SymbolInformation {
2451 name: "MySymbol".into(),
2452 location: lsp::Location {
2453 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2454 range: lsp::Range::new(
2455 lsp::Position::new(0, 0),
2456 lsp::Position::new(0, 1),
2457 ),
2458 },
2459 kind: lsp::SymbolKind::CONSTANT,
2460 tags: None,
2461 container_name: None,
2462 deprecated: None,
2463 },
2464 ])))
2465 },
2466 );
2467
2468 cx.simulate_input("@symbol ");
2469
2470 editor.update(&mut cx, |editor, cx| {
2471 assert_eq!(
2472 editor.text(cx),
2473 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2474 );
2475 assert!(editor.has_visible_completions_menu());
2476 assert_eq!(current_completion_labels(editor), &["MySymbol"]);
2477 });
2478
2479 editor.update_in(&mut cx, |editor, window, cx| {
2480 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2481 });
2482
2483 let symbol = MentionUri::Symbol {
2484 abs_path: path!("/dir/a/one.txt").into(),
2485 name: "MySymbol".into(),
2486 line_range: 0..=0,
2487 };
2488
2489 let contents = message_editor
2490 .update(&mut cx, |message_editor, cx| {
2491 message_editor.mention_set().contents(
2492 &all_prompt_capabilities,
2493 false,
2494 project.clone(),
2495 cx,
2496 )
2497 })
2498 .await
2499 .unwrap()
2500 .into_values()
2501 .collect::<Vec<_>>();
2502
2503 {
2504 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2505 panic!("Unexpected mentions");
2506 };
2507 pretty_assertions::assert_eq!(content, "1");
2508 pretty_assertions::assert_eq!(uri, &symbol);
2509 }
2510
2511 cx.run_until_parked();
2512
2513 editor.read_with(&cx, |editor, cx| {
2514 assert_eq!(
2515 editor.text(cx),
2516 format!(
2517 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2518 symbol.to_uri(),
2519 )
2520 );
2521 });
2522
2523 // Try to mention an "image" file that will fail to load
2524 cx.simulate_input("@file x.png");
2525
2526 editor.update(&mut cx, |editor, cx| {
2527 assert_eq!(
2528 editor.text(cx),
2529 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2530 );
2531 assert!(editor.has_visible_completions_menu());
2532 assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]);
2533 });
2534
2535 editor.update_in(&mut cx, |editor, window, cx| {
2536 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2537 });
2538
2539 // Getting the message contents fails
2540 message_editor
2541 .update(&mut cx, |message_editor, cx| {
2542 message_editor.mention_set().contents(
2543 &all_prompt_capabilities,
2544 false,
2545 project.clone(),
2546 cx,
2547 )
2548 })
2549 .await
2550 .expect_err("Should fail to load x.png");
2551
2552 cx.run_until_parked();
2553
2554 // Mention was removed
2555 editor.read_with(&cx, |editor, cx| {
2556 assert_eq!(
2557 editor.text(cx),
2558 format!(
2559 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2560 symbol.to_uri()
2561 )
2562 );
2563 });
2564
2565 // Once more
2566 cx.simulate_input("@file x.png");
2567
2568 editor.update(&mut cx, |editor, cx| {
2569 assert_eq!(
2570 editor.text(cx),
2571 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2572 );
2573 assert!(editor.has_visible_completions_menu());
2574 assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]);
2575 });
2576
2577 editor.update_in(&mut cx, |editor, window, cx| {
2578 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2579 });
2580
2581 // This time don't immediately get the contents, just let the confirmed completion settle
2582 cx.run_until_parked();
2583
2584 // Mention was removed
2585 editor.read_with(&cx, |editor, cx| {
2586 assert_eq!(
2587 editor.text(cx),
2588 format!(
2589 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2590 symbol.to_uri()
2591 )
2592 );
2593 });
2594
2595 // Now getting the contents succeeds, because the invalid mention was removed
2596 let contents = message_editor
2597 .update(&mut cx, |message_editor, cx| {
2598 message_editor.mention_set().contents(
2599 &all_prompt_capabilities,
2600 false,
2601 project.clone(),
2602 cx,
2603 )
2604 })
2605 .await
2606 .unwrap();
2607 assert_eq!(contents.len(), 3);
2608 }
2609
2610 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2611 let snapshot = editor.buffer().read(cx).snapshot(cx);
2612 editor.display_map.update(cx, |display_map, cx| {
2613 display_map
2614 .snapshot(cx)
2615 .folds_in_range(0..snapshot.len())
2616 .map(|fold| fold.range.to_point(&snapshot))
2617 .collect()
2618 })
2619 }
2620
2621 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2622 let completions = editor.current_completions().expect("Missing completions");
2623 completions
2624 .into_iter()
2625 .map(|completion| completion.label.text)
2626 .collect::<Vec<_>>()
2627 }
2628
2629 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2630 let completions = editor.current_completions().expect("Missing completions");
2631 completions
2632 .into_iter()
2633 .map(|completion| {
2634 (
2635 completion.label.text,
2636 completion
2637 .documentation
2638 .map(|d| d.text().to_string())
2639 .unwrap_or_default(),
2640 )
2641 })
2642 .collect::<Vec<_>>()
2643 }
2644
2645 #[gpui::test]
2646 async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) {
2647 init_test(cx);
2648
2649 let fs = FakeFs::new(cx.executor());
2650
2651 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2652 const LINE: &str = "fn example_function() { /* some code */ }\n";
2653 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2654 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2655
2656 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2657 let small_content = "fn small_function() { /* small */ }\n";
2658 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2659
2660 fs.insert_tree(
2661 "/project",
2662 json!({
2663 "large_file.rs": large_content.clone(),
2664 "small_file.rs": small_content,
2665 }),
2666 )
2667 .await;
2668
2669 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2670
2671 let (workspace, cx) =
2672 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2673
2674 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2675 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2676
2677 let message_editor = cx.update(|window, cx| {
2678 cx.new(|cx| {
2679 let editor = MessageEditor::new(
2680 workspace.downgrade(),
2681 project.clone(),
2682 history_store.clone(),
2683 None,
2684 Default::default(),
2685 Default::default(),
2686 "Test Agent".into(),
2687 "Test",
2688 EditorMode::AutoHeight {
2689 min_lines: 1,
2690 max_lines: None,
2691 },
2692 window,
2693 cx,
2694 );
2695 // Enable embedded context so files are actually included
2696 editor.prompt_capabilities.replace(acp::PromptCapabilities {
2697 embedded_context: true,
2698 meta: None,
2699 ..Default::default()
2700 });
2701 editor
2702 })
2703 });
2704
2705 // Test large file mention
2706 // Get the absolute path using the project's worktree
2707 let large_file_abs_path = project.read_with(cx, |project, cx| {
2708 let worktree = project.worktrees(cx).next().unwrap();
2709 let worktree_root = worktree.read(cx).abs_path();
2710 worktree_root.join("large_file.rs")
2711 });
2712 let large_file_task = message_editor.update(cx, |editor, cx| {
2713 editor.confirm_mention_for_file(large_file_abs_path, cx)
2714 });
2715
2716 let large_file_mention = large_file_task.await.unwrap();
2717 match large_file_mention {
2718 Mention::Text { content, .. } => {
2719 // Should contain outline header for large files
2720 assert!(content.contains("File outline for"));
2721 assert!(content.contains("file too large to show full content"));
2722 // Should not contain the full repeated content
2723 assert!(!content.contains(&LINE.repeat(100)));
2724 }
2725 _ => panic!("Expected Text mention for large file"),
2726 }
2727
2728 // Test small file mention
2729 // Get the absolute path using the project's worktree
2730 let small_file_abs_path = project.read_with(cx, |project, cx| {
2731 let worktree = project.worktrees(cx).next().unwrap();
2732 let worktree_root = worktree.read(cx).abs_path();
2733 worktree_root.join("small_file.rs")
2734 });
2735 let small_file_task = message_editor.update(cx, |editor, cx| {
2736 editor.confirm_mention_for_file(small_file_abs_path, cx)
2737 });
2738
2739 let small_file_mention = small_file_task.await.unwrap();
2740 match small_file_mention {
2741 Mention::Text { content, .. } => {
2742 // Should contain the actual content
2743 assert_eq!(content, small_content);
2744 // Should not contain outline header
2745 assert!(!content.contains("File outline for"));
2746 }
2747 _ => panic!("Expected Text mention for small file"),
2748 }
2749 }
2750}