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