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