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