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