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