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