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