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