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