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.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
718 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
719 let text = editor.text(cx);
720 editor.display_map.update(cx, |map, cx| {
721 let snapshot = map.snapshot(cx);
722 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
723 let Some((uri, mention)) = contents.get(&crease_id) else {
724 continue;
725 };
726
727 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
728 if crease_range.start > ix {
729 let chunk = text[ix..crease_range.start].into();
730 chunks.push(chunk);
731 }
732 let chunk = match mention {
733 Mention::Text {
734 content,
735 tracked_buffers,
736 } => {
737 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
738 if supports_embedded_context {
739 acp::ContentBlock::Resource(acp::EmbeddedResource {
740 annotations: None,
741 resource:
742 acp::EmbeddedResourceResource::TextResourceContents(
743 acp::TextResourceContents {
744 mime_type: None,
745 text: content.clone(),
746 uri: uri.to_uri().to_string(),
747 meta: None,
748 },
749 ),
750 meta: None,
751 })
752 } else {
753 acp::ContentBlock::ResourceLink(acp::ResourceLink {
754 name: uri.name(),
755 uri: uri.to_uri().to_string(),
756 annotations: None,
757 description: None,
758 mime_type: None,
759 size: None,
760 title: None,
761 meta: None,
762 })
763 }
764 }
765 Mention::Image(mention_image) => {
766 let uri = match uri {
767 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
768 MentionUri::PastedImage => None,
769 other => {
770 debug_panic!(
771 "unexpected mention uri for image: {:?}",
772 other
773 );
774 None
775 }
776 };
777 acp::ContentBlock::Image(acp::ImageContent {
778 annotations: None,
779 data: mention_image.data.to_string(),
780 mime_type: mention_image.format.mime_type().into(),
781 uri,
782 meta: None,
783 })
784 }
785 Mention::Link => acp::ContentBlock::ResourceLink(acp::ResourceLink {
786 name: uri.name(),
787 uri: uri.to_uri().to_string(),
788 annotations: None,
789 description: None,
790 mime_type: None,
791 size: None,
792 title: None,
793 meta: None,
794 }),
795 };
796 chunks.push(chunk);
797 ix = crease_range.end;
798 }
799
800 if ix < text.len() {
801 let last_chunk = text[ix..].trim_end().to_owned();
802 if !last_chunk.is_empty() {
803 chunks.push(last_chunk.into());
804 }
805 }
806 });
807 Ok((chunks, all_tracked_buffers))
808 })?;
809 result
810 })
811 }
812
813 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
814 self.editor.update(cx, |editor, cx| {
815 editor.clear(window, cx);
816 editor.remove_creases(
817 self.mention_set
818 .mentions
819 .drain()
820 .map(|(crease_id, _)| crease_id),
821 cx,
822 )
823 });
824 }
825
826 pub fn send(&mut self, cx: &mut Context<Self>) {
827 if self.is_empty(cx) {
828 return;
829 }
830 self.editor.update(cx, |editor, cx| {
831 editor.clear_inlay_hints(cx);
832 });
833 cx.emit(MessageEditorEvent::Send)
834 }
835
836 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
837 self.send(cx);
838 }
839
840 fn chat_with_follow(
841 &mut self,
842 _: &ChatWithFollow,
843 window: &mut Window,
844 cx: &mut Context<Self>,
845 ) {
846 self.workspace
847 .update(cx, |this, cx| {
848 this.follow(CollaboratorId::Agent, window, cx)
849 })
850 .log_err();
851
852 self.send(cx);
853 }
854
855 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
856 cx.emit(MessageEditorEvent::Cancel)
857 }
858
859 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
860 if !self.prompt_capabilities.borrow().image {
861 return;
862 }
863
864 let images = cx
865 .read_from_clipboard()
866 .map(|item| {
867 item.into_entries()
868 .filter_map(|entry| {
869 if let ClipboardEntry::Image(image) = entry {
870 Some(image)
871 } else {
872 None
873 }
874 })
875 .collect::<Vec<_>>()
876 })
877 .unwrap_or_default();
878
879 if images.is_empty() {
880 return;
881 }
882 cx.stop_propagation();
883
884 let replacement_text = MentionUri::PastedImage.as_link().to_string();
885 for image in images {
886 let (excerpt_id, text_anchor, multibuffer_anchor) =
887 self.editor.update(cx, |message_editor, cx| {
888 let snapshot = message_editor.snapshot(window, cx);
889 let (excerpt_id, _, buffer_snapshot) =
890 snapshot.buffer_snapshot().as_singleton().unwrap();
891
892 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
893 let multibuffer_anchor = snapshot
894 .buffer_snapshot()
895 .anchor_in_excerpt(*excerpt_id, text_anchor);
896 message_editor.edit(
897 [(
898 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
899 format!("{replacement_text} "),
900 )],
901 cx,
902 );
903 (*excerpt_id, text_anchor, multibuffer_anchor)
904 });
905
906 let content_len = replacement_text.len();
907 let Some(start_anchor) = multibuffer_anchor else {
908 continue;
909 };
910 let end_anchor = self.editor.update(cx, |editor, cx| {
911 let snapshot = editor.buffer().read(cx).snapshot(cx);
912 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
913 });
914 let image = Arc::new(image);
915 let Some((crease_id, tx)) = insert_crease_for_mention(
916 excerpt_id,
917 text_anchor,
918 content_len,
919 MentionUri::PastedImage.name().into(),
920 IconName::Image.path().into(),
921 Some(Task::ready(Ok(image.clone())).shared()),
922 self.editor.clone(),
923 window,
924 cx,
925 ) else {
926 continue;
927 };
928 let task = cx
929 .spawn_in(window, {
930 async move |_, cx| {
931 let format = image.format;
932 let image = cx
933 .update(|_, cx| LanguageModelImage::from_image(image, cx))
934 .map_err(|e| e.to_string())?
935 .await;
936 drop(tx);
937 if let Some(image) = image {
938 Ok(Mention::Image(MentionImage {
939 data: image.source,
940 format,
941 }))
942 } else {
943 Err("Failed to convert image".into())
944 }
945 }
946 })
947 .shared();
948
949 self.mention_set
950 .mentions
951 .insert(crease_id, (MentionUri::PastedImage, task.clone()));
952
953 cx.spawn_in(window, async move |this, cx| {
954 if task.await.notify_async_err(cx).is_none() {
955 this.update(cx, |this, cx| {
956 this.editor.update(cx, |editor, cx| {
957 editor.edit([(start_anchor..end_anchor, "")], cx);
958 });
959 this.mention_set.mentions.remove(&crease_id);
960 })
961 .ok();
962 }
963 })
964 .detach();
965 }
966 }
967
968 pub fn insert_dragged_files(
969 &mut self,
970 paths: Vec<project::ProjectPath>,
971 added_worktrees: Vec<Entity<Worktree>>,
972 window: &mut Window,
973 cx: &mut Context<Self>,
974 ) {
975 let path_style = self.project.read(cx).path_style(cx);
976 let buffer = self.editor.read(cx).buffer().clone();
977 let Some(buffer) = buffer.read(cx).as_singleton() else {
978 return;
979 };
980 let mut tasks = Vec::new();
981 for path in paths {
982 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
983 continue;
984 };
985 let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else {
986 continue;
987 };
988 let abs_path = worktree.read(cx).absolutize(&path.path);
989 let (file_name, _) =
990 crate::context_picker::file_context_picker::extract_file_name_and_directory(
991 &path.path,
992 worktree.read(cx).root_name(),
993 path_style,
994 );
995
996 let uri = if entry.is_dir() {
997 MentionUri::Directory { abs_path }
998 } else {
999 MentionUri::File { abs_path }
1000 };
1001
1002 let new_text = format!("{} ", uri.as_link());
1003 let content_len = new_text.len() - 1;
1004
1005 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
1006
1007 self.editor.update(cx, |message_editor, cx| {
1008 message_editor.edit(
1009 [(
1010 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
1011 new_text,
1012 )],
1013 cx,
1014 );
1015 });
1016 tasks.push(self.confirm_mention_completion(
1017 file_name,
1018 anchor,
1019 content_len,
1020 uri,
1021 window,
1022 cx,
1023 ));
1024 }
1025 cx.spawn(async move |_, _| {
1026 join_all(tasks).await;
1027 drop(added_worktrees);
1028 })
1029 .detach();
1030 }
1031
1032 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1033 let editor = self.editor.read(cx);
1034 let editor_buffer = editor.buffer().read(cx);
1035 let Some(buffer) = editor_buffer.as_singleton() else {
1036 return;
1037 };
1038 let cursor_anchor = editor.selections.newest_anchor().head();
1039 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1040 let anchor = buffer.update(cx, |buffer, _cx| {
1041 buffer.anchor_before(cursor_offset.min(buffer.len()))
1042 });
1043 let Some(workspace) = self.workspace.upgrade() else {
1044 return;
1045 };
1046 let Some(completion) = ContextPickerCompletionProvider::completion_for_action(
1047 ContextPickerAction::AddSelections,
1048 anchor..anchor,
1049 cx.weak_entity(),
1050 &workspace,
1051 cx,
1052 ) else {
1053 return;
1054 };
1055
1056 self.editor.update(cx, |message_editor, cx| {
1057 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1058 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1059 });
1060 if let Some(confirm) = completion.confirm {
1061 confirm(CompletionIntent::Complete, window, cx);
1062 }
1063 }
1064
1065 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1066 self.editor.update(cx, |message_editor, cx| {
1067 message_editor.set_read_only(read_only);
1068 cx.notify()
1069 })
1070 }
1071
1072 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1073 self.editor.update(cx, |editor, cx| {
1074 editor.set_mode(mode);
1075 cx.notify()
1076 });
1077 }
1078
1079 pub fn set_message(
1080 &mut self,
1081 message: Vec<acp::ContentBlock>,
1082 window: &mut Window,
1083 cx: &mut Context<Self>,
1084 ) {
1085 self.clear(window, cx);
1086
1087 let path_style = self.project.read(cx).path_style(cx);
1088 let mut text = String::new();
1089 let mut mentions = Vec::new();
1090
1091 for chunk in message {
1092 match chunk {
1093 acp::ContentBlock::Text(text_content) => {
1094 text.push_str(&text_content.text);
1095 }
1096 acp::ContentBlock::Resource(acp::EmbeddedResource {
1097 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1098 ..
1099 }) => {
1100 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1101 else {
1102 continue;
1103 };
1104 let start = text.len();
1105 write!(&mut text, "{}", mention_uri.as_link()).ok();
1106 let end = text.len();
1107 mentions.push((
1108 start..end,
1109 mention_uri,
1110 Mention::Text {
1111 content: resource.text,
1112 tracked_buffers: Vec::new(),
1113 },
1114 ));
1115 }
1116 acp::ContentBlock::ResourceLink(resource) => {
1117 if let Some(mention_uri) =
1118 MentionUri::parse(&resource.uri, path_style).log_err()
1119 {
1120 let start = text.len();
1121 write!(&mut text, "{}", mention_uri.as_link()).ok();
1122 let end = text.len();
1123 mentions.push((start..end, mention_uri, Mention::Link));
1124 }
1125 }
1126 acp::ContentBlock::Image(acp::ImageContent {
1127 uri,
1128 data,
1129 mime_type,
1130 annotations: _,
1131 meta: _,
1132 }) => {
1133 let mention_uri = if let Some(uri) = uri {
1134 MentionUri::parse(&uri, path_style)
1135 } else {
1136 Ok(MentionUri::PastedImage)
1137 };
1138 let Some(mention_uri) = mention_uri.log_err() else {
1139 continue;
1140 };
1141 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1142 log::error!("failed to parse MIME type for image: {mime_type:?}");
1143 continue;
1144 };
1145 let start = text.len();
1146 write!(&mut text, "{}", mention_uri.as_link()).ok();
1147 let end = text.len();
1148 mentions.push((
1149 start..end,
1150 mention_uri,
1151 Mention::Image(MentionImage {
1152 data: data.into(),
1153 format,
1154 }),
1155 ));
1156 }
1157 acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {}
1158 }
1159 }
1160
1161 let snapshot = self.editor.update(cx, |editor, cx| {
1162 editor.set_text(text, window, cx);
1163 editor.buffer().read(cx).snapshot(cx)
1164 });
1165
1166 for (range, mention_uri, mention) in mentions {
1167 let anchor = snapshot.anchor_before(range.start);
1168 let Some((crease_id, tx)) = insert_crease_for_mention(
1169 anchor.excerpt_id,
1170 anchor.text_anchor,
1171 range.end - range.start,
1172 mention_uri.name().into(),
1173 mention_uri.icon_path(cx),
1174 None,
1175 self.editor.clone(),
1176 window,
1177 cx,
1178 ) else {
1179 continue;
1180 };
1181 drop(tx);
1182
1183 self.mention_set.mentions.insert(
1184 crease_id,
1185 (mention_uri.clone(), Task::ready(Ok(mention)).shared()),
1186 );
1187 }
1188 cx.notify();
1189 }
1190
1191 pub fn text(&self, cx: &App) -> String {
1192 self.editor.read(cx).text(cx)
1193 }
1194
1195 #[cfg(test)]
1196 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1197 self.editor.update(cx, |editor, cx| {
1198 editor.set_text(text, window, cx);
1199 });
1200 }
1201}
1202
1203fn full_mention_for_directory(
1204 project: &Entity<Project>,
1205 abs_path: &Path,
1206 cx: &mut App,
1207) -> Task<Result<Mention>> {
1208 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1209 let mut files = Vec::new();
1210
1211 for entry in worktree.child_entries(path) {
1212 if entry.is_dir() {
1213 files.extend(collect_files_in_path(worktree, &entry.path));
1214 } else if entry.is_file() {
1215 files.push((
1216 entry.path.clone(),
1217 worktree
1218 .full_path(&entry.path)
1219 .to_string_lossy()
1220 .to_string(),
1221 ));
1222 }
1223 }
1224
1225 files
1226 }
1227
1228 let Some(project_path) = project
1229 .read(cx)
1230 .project_path_for_absolute_path(&abs_path, cx)
1231 else {
1232 return Task::ready(Err(anyhow!("project path not found")));
1233 };
1234 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1235 return Task::ready(Err(anyhow!("project entry not found")));
1236 };
1237 let directory_path = entry.path.clone();
1238 let worktree_id = project_path.worktree_id;
1239 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1240 return Task::ready(Err(anyhow!("worktree not found")));
1241 };
1242 let project = project.clone();
1243 cx.spawn(async move |cx| {
1244 let file_paths = worktree.read_with(cx, |worktree, _cx| {
1245 collect_files_in_path(worktree, &directory_path)
1246 })?;
1247 let descendants_future = cx.update(|cx| {
1248 join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
1249 let rel_path = worktree_path
1250 .strip_prefix(&directory_path)
1251 .log_err()
1252 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1253
1254 let open_task = project.update(cx, |project, cx| {
1255 project.buffer_store().update(cx, |buffer_store, cx| {
1256 let project_path = ProjectPath {
1257 worktree_id,
1258 path: worktree_path,
1259 };
1260 buffer_store.open_buffer(project_path, cx)
1261 })
1262 });
1263
1264 cx.spawn(async move |cx| {
1265 let buffer = open_task.await.log_err()?;
1266 let buffer_content = outline::get_buffer_content_or_outline(
1267 buffer.clone(),
1268 Some(&full_path),
1269 &cx,
1270 )
1271 .await
1272 .ok()?;
1273
1274 Some((rel_path, full_path, buffer_content.text, buffer))
1275 })
1276 }))
1277 })?;
1278
1279 let contents = cx
1280 .background_spawn(async move {
1281 let (contents, tracked_buffers) = descendants_future
1282 .await
1283 .into_iter()
1284 .flatten()
1285 .map(|(rel_path, full_path, rope, buffer)| {
1286 ((rel_path, full_path, rope), buffer)
1287 })
1288 .unzip();
1289 Mention::Text {
1290 content: render_directory_contents(contents),
1291 tracked_buffers,
1292 }
1293 })
1294 .await;
1295 anyhow::Ok(contents)
1296 })
1297}
1298
1299fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1300 let mut output = String::new();
1301 for (_relative_path, full_path, content) in entries {
1302 let fence = codeblock_fence_for_path(Some(&full_path), None);
1303 write!(output, "\n{fence}\n{content}\n```").unwrap();
1304 }
1305 output
1306}
1307
1308impl Focusable for MessageEditor {
1309 fn focus_handle(&self, cx: &App) -> FocusHandle {
1310 self.editor.focus_handle(cx)
1311 }
1312}
1313
1314impl Render for MessageEditor {
1315 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1316 div()
1317 .key_context("MessageEditor")
1318 .on_action(cx.listener(Self::chat))
1319 .on_action(cx.listener(Self::chat_with_follow))
1320 .on_action(cx.listener(Self::cancel))
1321 .capture_action(cx.listener(Self::paste))
1322 .flex_1()
1323 .child({
1324 let settings = ThemeSettings::get_global(cx);
1325
1326 let text_style = TextStyle {
1327 color: cx.theme().colors().text,
1328 font_family: settings.buffer_font.family.clone(),
1329 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1330 font_features: settings.buffer_font.features.clone(),
1331 font_size: settings.agent_buffer_font_size(cx).into(),
1332 line_height: relative(settings.buffer_line_height.value()),
1333 ..Default::default()
1334 };
1335
1336 EditorElement::new(
1337 &self.editor,
1338 EditorStyle {
1339 background: cx.theme().colors().editor_background,
1340 local_player: cx.theme().players().local(),
1341 text: text_style,
1342 syntax: cx.theme().syntax().clone(),
1343 inlay_hints_style: editor::make_inlay_hints_style(cx),
1344 ..Default::default()
1345 },
1346 )
1347 })
1348 }
1349}
1350
1351pub(crate) fn insert_crease_for_mention(
1352 excerpt_id: ExcerptId,
1353 anchor: text::Anchor,
1354 content_len: usize,
1355 crease_label: SharedString,
1356 crease_icon: SharedString,
1357 // abs_path: Option<Arc<Path>>,
1358 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1359 editor: Entity<Editor>,
1360 window: &mut Window,
1361 cx: &mut App,
1362) -> Option<(CreaseId, postage::barrier::Sender)> {
1363 let (tx, rx) = postage::barrier::channel();
1364
1365 let crease_id = editor.update(cx, |editor, cx| {
1366 let snapshot = editor.buffer().read(cx).snapshot(cx);
1367
1368 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
1369
1370 let start = start.bias_right(&snapshot);
1371 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
1372
1373 let placeholder = FoldPlaceholder {
1374 render: render_mention_fold_button(
1375 crease_label,
1376 crease_icon,
1377 start..end,
1378 rx,
1379 image,
1380 cx.weak_entity(),
1381 cx,
1382 ),
1383 merge_adjacent: false,
1384 ..Default::default()
1385 };
1386
1387 let crease = Crease::Inline {
1388 range: start..end,
1389 placeholder,
1390 render_toggle: None,
1391 render_trailer: None,
1392 metadata: None,
1393 };
1394
1395 let ids = editor.insert_creases(vec![crease.clone()], cx);
1396 editor.fold_creases(vec![crease], false, window, cx);
1397
1398 Some(ids[0])
1399 })?;
1400
1401 Some((crease_id, tx))
1402}
1403
1404fn render_mention_fold_button(
1405 label: SharedString,
1406 icon: SharedString,
1407 range: Range<Anchor>,
1408 mut loading_finished: postage::barrier::Receiver,
1409 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1410 editor: WeakEntity<Editor>,
1411 cx: &mut App,
1412) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1413 let loading = cx.new(|cx| {
1414 let loading = cx.spawn(async move |this, cx| {
1415 loading_finished.recv().await;
1416 this.update(cx, |this: &mut LoadingContext, cx| {
1417 this.loading = None;
1418 cx.notify();
1419 })
1420 .ok();
1421 });
1422 LoadingContext {
1423 id: cx.entity_id(),
1424 label,
1425 icon,
1426 range,
1427 editor,
1428 loading: Some(loading),
1429 image: image_task.clone(),
1430 }
1431 });
1432 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1433}
1434
1435struct LoadingContext {
1436 id: EntityId,
1437 label: SharedString,
1438 icon: SharedString,
1439 range: Range<Anchor>,
1440 editor: WeakEntity<Editor>,
1441 loading: Option<Task<()>>,
1442 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1443}
1444
1445impl Render for LoadingContext {
1446 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1447 let is_in_text_selection = self
1448 .editor
1449 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1450 .unwrap_or_default();
1451 ButtonLike::new(("loading-context", self.id))
1452 .style(ButtonStyle::Filled)
1453 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1454 .toggle_state(is_in_text_selection)
1455 .when_some(self.image.clone(), |el, image_task| {
1456 el.hoverable_tooltip(move |_, cx| {
1457 let image = image_task.peek().cloned().transpose().ok().flatten();
1458 let image_task = image_task.clone();
1459 cx.new::<ImageHover>(|cx| ImageHover {
1460 image,
1461 _task: cx.spawn(async move |this, cx| {
1462 if let Ok(image) = image_task.clone().await {
1463 this.update(cx, |this, cx| {
1464 if this.image.replace(image).is_none() {
1465 cx.notify();
1466 }
1467 })
1468 .ok();
1469 }
1470 }),
1471 })
1472 .into()
1473 })
1474 })
1475 .child(
1476 h_flex()
1477 .gap_1()
1478 .child(
1479 Icon::from_path(self.icon.clone())
1480 .size(IconSize::XSmall)
1481 .color(Color::Muted),
1482 )
1483 .child(
1484 Label::new(self.label.clone())
1485 .size(LabelSize::Small)
1486 .buffer_font(cx)
1487 .single_line(),
1488 )
1489 .map(|el| {
1490 if self.loading.is_some() {
1491 el.with_animation(
1492 "loading-context-crease",
1493 Animation::new(Duration::from_secs(2))
1494 .repeat()
1495 .with_easing(pulsating_between(0.4, 0.8)),
1496 |label, delta| label.opacity(delta),
1497 )
1498 .into_any()
1499 } else {
1500 el.into_any()
1501 }
1502 }),
1503 )
1504 }
1505}
1506
1507struct ImageHover {
1508 image: Option<Arc<Image>>,
1509 _task: Task<()>,
1510}
1511
1512impl Render for ImageHover {
1513 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1514 if let Some(image) = self.image.clone() {
1515 gpui::img(image).max_w_96().max_h_96().into_any_element()
1516 } else {
1517 gpui::Empty.into_any_element()
1518 }
1519 }
1520}
1521
1522#[derive(Debug, Clone, Eq, PartialEq)]
1523pub enum Mention {
1524 Text {
1525 content: String,
1526 tracked_buffers: Vec<Entity<Buffer>>,
1527 },
1528 Image(MentionImage),
1529 Link,
1530}
1531
1532#[derive(Clone, Debug, Eq, PartialEq)]
1533pub struct MentionImage {
1534 pub data: SharedString,
1535 pub format: ImageFormat,
1536}
1537
1538#[derive(Default)]
1539pub struct MentionSet {
1540 mentions: HashMap<CreaseId, (MentionUri, Shared<Task<Result<Mention, String>>>)>,
1541}
1542
1543impl MentionSet {
1544 fn contents(
1545 &self,
1546 full_mention_content: bool,
1547 project: Entity<Project>,
1548 cx: &mut App,
1549 ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
1550 let mentions = self.mentions.clone();
1551 cx.spawn(async move |cx| {
1552 let mut contents = HashMap::default();
1553 for (crease_id, (mention_uri, task)) in mentions {
1554 let content = if full_mention_content
1555 && let MentionUri::Directory { abs_path } = &mention_uri
1556 {
1557 cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))?
1558 .await?
1559 } else {
1560 task.await.map_err(|e| anyhow!("{e}"))?
1561 };
1562
1563 contents.insert(crease_id, (mention_uri, content));
1564 }
1565 Ok(contents)
1566 })
1567 }
1568
1569 fn remove_invalid(&mut self, snapshot: EditorSnapshot) {
1570 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
1571 if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) {
1572 self.mentions.remove(&crease_id);
1573 }
1574 }
1575 }
1576}
1577
1578pub struct MessageEditorAddon {}
1579
1580impl MessageEditorAddon {
1581 pub fn new() -> Self {
1582 Self {}
1583 }
1584}
1585
1586impl Addon for MessageEditorAddon {
1587 fn to_any(&self) -> &dyn std::any::Any {
1588 self
1589 }
1590
1591 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1592 Some(self)
1593 }
1594
1595 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1596 let settings = agent_settings::AgentSettings::get_global(cx);
1597 if settings.use_modifier_to_send {
1598 key_context.add("use_modifier_to_send");
1599 }
1600 }
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1606
1607 use acp_thread::MentionUri;
1608 use agent::{HistoryStore, outline};
1609 use agent_client_protocol as acp;
1610 use assistant_text_thread::TextThreadStore;
1611 use editor::{AnchorRangeExt as _, Editor, EditorMode};
1612 use fs::FakeFs;
1613 use futures::StreamExt as _;
1614 use gpui::{
1615 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1616 };
1617 use language_model::LanguageModelRegistry;
1618 use lsp::{CompletionContext, CompletionTriggerKind};
1619 use project::{CompletionIntent, Project, ProjectPath};
1620 use serde_json::json;
1621 use text::Point;
1622 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1623 use util::{path, paths::PathStyle, rel_path::rel_path};
1624 use workspace::{AppState, Item, Workspace};
1625
1626 use crate::acp::{
1627 message_editor::{Mention, MessageEditor},
1628 thread_view::tests::init_test,
1629 };
1630
1631 #[gpui::test]
1632 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1633 init_test(cx);
1634
1635 let fs = FakeFs::new(cx.executor());
1636 fs.insert_tree("/project", json!({"file": ""})).await;
1637 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1638
1639 let (workspace, cx) =
1640 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1641
1642 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1643 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1644
1645 let message_editor = cx.update(|window, cx| {
1646 cx.new(|cx| {
1647 MessageEditor::new(
1648 workspace.downgrade(),
1649 project.clone(),
1650 history_store.clone(),
1651 None,
1652 Default::default(),
1653 Default::default(),
1654 "Test Agent".into(),
1655 "Test",
1656 EditorMode::AutoHeight {
1657 min_lines: 1,
1658 max_lines: None,
1659 },
1660 window,
1661 cx,
1662 )
1663 })
1664 });
1665 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1666
1667 cx.run_until_parked();
1668
1669 let excerpt_id = editor.update(cx, |editor, cx| {
1670 editor
1671 .buffer()
1672 .read(cx)
1673 .excerpt_ids()
1674 .into_iter()
1675 .next()
1676 .unwrap()
1677 });
1678 let completions = editor.update_in(cx, |editor, window, cx| {
1679 editor.set_text("Hello @file ", window, cx);
1680 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1681 let completion_provider = editor.completion_provider().unwrap();
1682 completion_provider.completions(
1683 excerpt_id,
1684 &buffer,
1685 text::Anchor::MAX,
1686 CompletionContext {
1687 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1688 trigger_character: Some("@".into()),
1689 },
1690 window,
1691 cx,
1692 )
1693 });
1694 let [_, completion]: [_; 2] = completions
1695 .await
1696 .unwrap()
1697 .into_iter()
1698 .flat_map(|response| response.completions)
1699 .collect::<Vec<_>>()
1700 .try_into()
1701 .unwrap();
1702
1703 editor.update_in(cx, |editor, window, cx| {
1704 let snapshot = editor.buffer().read(cx).snapshot(cx);
1705 let range = snapshot
1706 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1707 .unwrap();
1708 editor.edit([(range, completion.new_text)], cx);
1709 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1710 });
1711
1712 cx.run_until_parked();
1713
1714 // Backspace over the inserted crease (and the following space).
1715 editor.update_in(cx, |editor, window, cx| {
1716 editor.backspace(&Default::default(), window, cx);
1717 editor.backspace(&Default::default(), window, cx);
1718 });
1719
1720 let (content, _) = message_editor
1721 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1722 .await
1723 .unwrap();
1724
1725 // We don't send a resource link for the deleted crease.
1726 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1727 }
1728
1729 #[gpui::test]
1730 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1731 init_test(cx);
1732 let fs = FakeFs::new(cx.executor());
1733 fs.insert_tree(
1734 "/test",
1735 json!({
1736 ".zed": {
1737 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1738 },
1739 "src": {
1740 "main.rs": "fn main() {}",
1741 },
1742 }),
1743 )
1744 .await;
1745
1746 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1747 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1748 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1749 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1750 // Start with no available commands - simulating Claude which doesn't support slash commands
1751 let available_commands = Rc::new(RefCell::new(vec![]));
1752
1753 let (workspace, cx) =
1754 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1755 let workspace_handle = workspace.downgrade();
1756 let message_editor = workspace.update_in(cx, |_, window, cx| {
1757 cx.new(|cx| {
1758 MessageEditor::new(
1759 workspace_handle.clone(),
1760 project.clone(),
1761 history_store.clone(),
1762 None,
1763 prompt_capabilities.clone(),
1764 available_commands.clone(),
1765 "Claude Code".into(),
1766 "Test",
1767 EditorMode::AutoHeight {
1768 min_lines: 1,
1769 max_lines: None,
1770 },
1771 window,
1772 cx,
1773 )
1774 })
1775 });
1776 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1777
1778 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1779 editor.update_in(cx, |editor, window, cx| {
1780 editor.set_text("/file test.txt", window, cx);
1781 });
1782
1783 let contents_result = message_editor
1784 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1785 .await;
1786
1787 // Should fail because available_commands is empty (no commands supported)
1788 assert!(contents_result.is_err());
1789 let error_message = contents_result.unwrap_err().to_string();
1790 assert!(error_message.contains("not supported by Claude Code"));
1791 assert!(error_message.contains("Available commands: none"));
1792
1793 // Now simulate Claude providing its list of available commands (which doesn't include file)
1794 available_commands.replace(vec![acp::AvailableCommand {
1795 name: "help".to_string(),
1796 description: "Get help".to_string(),
1797 input: None,
1798 meta: None,
1799 }]);
1800
1801 // Test that unsupported slash commands trigger an error when we have a list of available commands
1802 editor.update_in(cx, |editor, window, cx| {
1803 editor.set_text("/file test.txt", window, cx);
1804 });
1805
1806 let contents_result = message_editor
1807 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1808 .await;
1809
1810 assert!(contents_result.is_err());
1811 let error_message = contents_result.unwrap_err().to_string();
1812 assert!(error_message.contains("not supported by Claude Code"));
1813 assert!(error_message.contains("/file"));
1814 assert!(error_message.contains("Available commands: /help"));
1815
1816 // Test that supported commands work fine
1817 editor.update_in(cx, |editor, window, cx| {
1818 editor.set_text("/help", window, cx);
1819 });
1820
1821 let contents_result = message_editor
1822 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1823 .await;
1824
1825 // Should succeed because /help is in available_commands
1826 assert!(contents_result.is_ok());
1827
1828 // Test that regular text works fine
1829 editor.update_in(cx, |editor, window, cx| {
1830 editor.set_text("Hello Claude!", window, cx);
1831 });
1832
1833 let (content, _) = message_editor
1834 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1835 .await
1836 .unwrap();
1837
1838 assert_eq!(content.len(), 1);
1839 if let acp::ContentBlock::Text(text) = &content[0] {
1840 assert_eq!(text.text, "Hello Claude!");
1841 } else {
1842 panic!("Expected ContentBlock::Text");
1843 }
1844
1845 // Test that @ mentions still work
1846 editor.update_in(cx, |editor, window, cx| {
1847 editor.set_text("Check this @", window, cx);
1848 });
1849
1850 // The @ mention functionality should not be affected
1851 let (content, _) = message_editor
1852 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1853 .await
1854 .unwrap();
1855
1856 assert_eq!(content.len(), 1);
1857 if let acp::ContentBlock::Text(text) = &content[0] {
1858 assert_eq!(text.text, "Check this @");
1859 } else {
1860 panic!("Expected ContentBlock::Text");
1861 }
1862 }
1863
1864 struct MessageEditorItem(Entity<MessageEditor>);
1865
1866 impl Item for MessageEditorItem {
1867 type Event = ();
1868
1869 fn include_in_nav_history() -> bool {
1870 false
1871 }
1872
1873 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1874 "Test".into()
1875 }
1876 }
1877
1878 impl EventEmitter<()> for MessageEditorItem {}
1879
1880 impl Focusable for MessageEditorItem {
1881 fn focus_handle(&self, cx: &App) -> FocusHandle {
1882 self.0.read(cx).focus_handle(cx)
1883 }
1884 }
1885
1886 impl Render for MessageEditorItem {
1887 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1888 self.0.clone().into_any_element()
1889 }
1890 }
1891
1892 #[gpui::test]
1893 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1894 init_test(cx);
1895
1896 let app_state = cx.update(AppState::test);
1897
1898 cx.update(|cx| {
1899 language::init(cx);
1900 editor::init(cx);
1901 workspace::init(app_state.clone(), cx);
1902 Project::init_settings(cx);
1903 });
1904
1905 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1906 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1907 let workspace = window.root(cx).unwrap();
1908
1909 let mut cx = VisualTestContext::from_window(*window, cx);
1910
1911 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1912 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1913 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1914 let available_commands = Rc::new(RefCell::new(vec![
1915 acp::AvailableCommand {
1916 name: "quick-math".to_string(),
1917 description: "2 + 2 = 4 - 1 = 3".to_string(),
1918 input: None,
1919 meta: None,
1920 },
1921 acp::AvailableCommand {
1922 name: "say-hello".to_string(),
1923 description: "Say hello to whoever you want".to_string(),
1924 input: Some(acp::AvailableCommandInput::Unstructured {
1925 hint: "<name>".to_string(),
1926 }),
1927 meta: None,
1928 },
1929 ]));
1930
1931 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1932 let workspace_handle = cx.weak_entity();
1933 let message_editor = cx.new(|cx| {
1934 MessageEditor::new(
1935 workspace_handle,
1936 project.clone(),
1937 history_store.clone(),
1938 None,
1939 prompt_capabilities.clone(),
1940 available_commands.clone(),
1941 "Test Agent".into(),
1942 "Test",
1943 EditorMode::AutoHeight {
1944 max_lines: None,
1945 min_lines: 1,
1946 },
1947 window,
1948 cx,
1949 )
1950 });
1951 workspace.active_pane().update(cx, |pane, cx| {
1952 pane.add_item(
1953 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1954 true,
1955 true,
1956 None,
1957 window,
1958 cx,
1959 );
1960 });
1961 message_editor.read(cx).focus_handle(cx).focus(window);
1962 message_editor.read(cx).editor().clone()
1963 });
1964
1965 cx.simulate_input("/");
1966
1967 editor.update_in(&mut cx, |editor, window, cx| {
1968 assert_eq!(editor.text(cx), "/");
1969 assert!(editor.has_visible_completions_menu());
1970
1971 assert_eq!(
1972 current_completion_labels_with_documentation(editor),
1973 &[
1974 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1975 ("say-hello".into(), "Say hello to whoever you want".into())
1976 ]
1977 );
1978 editor.set_text("", window, cx);
1979 });
1980
1981 cx.simulate_input("/qui");
1982
1983 editor.update_in(&mut cx, |editor, window, cx| {
1984 assert_eq!(editor.text(cx), "/qui");
1985 assert!(editor.has_visible_completions_menu());
1986
1987 assert_eq!(
1988 current_completion_labels_with_documentation(editor),
1989 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1990 );
1991 editor.set_text("", window, cx);
1992 });
1993
1994 editor.update_in(&mut cx, |editor, window, cx| {
1995 assert!(editor.has_visible_completions_menu());
1996 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1997 });
1998
1999 cx.run_until_parked();
2000
2001 editor.update_in(&mut cx, |editor, window, cx| {
2002 assert_eq!(editor.display_text(cx), "/quick-math ");
2003 assert!(!editor.has_visible_completions_menu());
2004 editor.set_text("", window, cx);
2005 });
2006
2007 cx.simulate_input("/say");
2008
2009 editor.update_in(&mut cx, |editor, _window, cx| {
2010 assert_eq!(editor.display_text(cx), "/say");
2011 assert!(editor.has_visible_completions_menu());
2012
2013 assert_eq!(
2014 current_completion_labels_with_documentation(editor),
2015 &[("say-hello".into(), "Say hello to whoever you want".into())]
2016 );
2017 });
2018
2019 editor.update_in(&mut cx, |editor, window, cx| {
2020 assert!(editor.has_visible_completions_menu());
2021 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2022 });
2023
2024 cx.run_until_parked();
2025
2026 editor.update_in(&mut cx, |editor, _window, cx| {
2027 assert_eq!(editor.text(cx), "/say-hello ");
2028 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2029 assert!(!editor.has_visible_completions_menu());
2030 });
2031
2032 cx.simulate_input("GPT5");
2033
2034 cx.run_until_parked();
2035
2036 editor.update_in(&mut cx, |editor, window, cx| {
2037 assert_eq!(editor.text(cx), "/say-hello GPT5");
2038 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2039 assert!(!editor.has_visible_completions_menu());
2040
2041 // Delete argument
2042 for _ in 0..5 {
2043 editor.backspace(&editor::actions::Backspace, window, cx);
2044 }
2045 });
2046
2047 cx.run_until_parked();
2048
2049 editor.update_in(&mut cx, |editor, window, cx| {
2050 assert_eq!(editor.text(cx), "/say-hello");
2051 // Hint is visible because argument was deleted
2052 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2053
2054 // Delete last command letter
2055 editor.backspace(&editor::actions::Backspace, window, cx);
2056 });
2057
2058 cx.run_until_parked();
2059
2060 editor.update_in(&mut cx, |editor, _window, cx| {
2061 // Hint goes away once command no longer matches an available one
2062 assert_eq!(editor.text(cx), "/say-hell");
2063 assert_eq!(editor.display_text(cx), "/say-hell");
2064 assert!(!editor.has_visible_completions_menu());
2065 });
2066 }
2067
2068 #[gpui::test]
2069 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2070 init_test(cx);
2071
2072 let app_state = cx.update(AppState::test);
2073
2074 cx.update(|cx| {
2075 language::init(cx);
2076 editor::init(cx);
2077 workspace::init(app_state.clone(), cx);
2078 Project::init_settings(cx);
2079 });
2080
2081 app_state
2082 .fs
2083 .as_fake()
2084 .insert_tree(
2085 path!("/dir"),
2086 json!({
2087 "editor": "",
2088 "a": {
2089 "one.txt": "1",
2090 "two.txt": "2",
2091 "three.txt": "3",
2092 "four.txt": "4"
2093 },
2094 "b": {
2095 "five.txt": "5",
2096 "six.txt": "6",
2097 "seven.txt": "7",
2098 "eight.txt": "8",
2099 },
2100 "x.png": "",
2101 }),
2102 )
2103 .await;
2104
2105 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2106 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2107 let workspace = window.root(cx).unwrap();
2108
2109 let worktree = project.update(cx, |project, cx| {
2110 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2111 assert_eq!(worktrees.len(), 1);
2112 worktrees.pop().unwrap()
2113 });
2114 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2115
2116 let mut cx = VisualTestContext::from_window(*window, cx);
2117
2118 let paths = vec![
2119 rel_path("a/one.txt"),
2120 rel_path("a/two.txt"),
2121 rel_path("a/three.txt"),
2122 rel_path("a/four.txt"),
2123 rel_path("b/five.txt"),
2124 rel_path("b/six.txt"),
2125 rel_path("b/seven.txt"),
2126 rel_path("b/eight.txt"),
2127 ];
2128
2129 let slash = PathStyle::local().separator();
2130
2131 let mut opened_editors = Vec::new();
2132 for path in paths {
2133 let buffer = workspace
2134 .update_in(&mut cx, |workspace, window, cx| {
2135 workspace.open_path(
2136 ProjectPath {
2137 worktree_id,
2138 path: path.into(),
2139 },
2140 None,
2141 false,
2142 window,
2143 cx,
2144 )
2145 })
2146 .await
2147 .unwrap();
2148 opened_editors.push(buffer);
2149 }
2150
2151 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2152 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2153 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2154
2155 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2156 let workspace_handle = cx.weak_entity();
2157 let message_editor = cx.new(|cx| {
2158 MessageEditor::new(
2159 workspace_handle,
2160 project.clone(),
2161 history_store.clone(),
2162 None,
2163 prompt_capabilities.clone(),
2164 Default::default(),
2165 "Test Agent".into(),
2166 "Test",
2167 EditorMode::AutoHeight {
2168 max_lines: None,
2169 min_lines: 1,
2170 },
2171 window,
2172 cx,
2173 )
2174 });
2175 workspace.active_pane().update(cx, |pane, cx| {
2176 pane.add_item(
2177 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2178 true,
2179 true,
2180 None,
2181 window,
2182 cx,
2183 );
2184 });
2185 message_editor.read(cx).focus_handle(cx).focus(window);
2186 let editor = message_editor.read(cx).editor().clone();
2187 (message_editor, editor)
2188 });
2189
2190 cx.simulate_input("Lorem @");
2191
2192 editor.update_in(&mut cx, |editor, window, cx| {
2193 assert_eq!(editor.text(cx), "Lorem @");
2194 assert!(editor.has_visible_completions_menu());
2195
2196 assert_eq!(
2197 current_completion_labels(editor),
2198 &[
2199 format!("eight.txt b{slash}"),
2200 format!("seven.txt b{slash}"),
2201 format!("six.txt b{slash}"),
2202 format!("five.txt b{slash}"),
2203 "Files & Directories".into(),
2204 "Symbols".into()
2205 ]
2206 );
2207 editor.set_text("", window, cx);
2208 });
2209
2210 prompt_capabilities.replace(acp::PromptCapabilities {
2211 image: true,
2212 audio: true,
2213 embedded_context: true,
2214 meta: None,
2215 });
2216
2217 cx.simulate_input("Lorem ");
2218
2219 editor.update(&mut cx, |editor, cx| {
2220 assert_eq!(editor.text(cx), "Lorem ");
2221 assert!(!editor.has_visible_completions_menu());
2222 });
2223
2224 cx.simulate_input("@");
2225
2226 editor.update(&mut cx, |editor, cx| {
2227 assert_eq!(editor.text(cx), "Lorem @");
2228 assert!(editor.has_visible_completions_menu());
2229 assert_eq!(
2230 current_completion_labels(editor),
2231 &[
2232 format!("eight.txt b{slash}"),
2233 format!("seven.txt b{slash}"),
2234 format!("six.txt b{slash}"),
2235 format!("five.txt b{slash}"),
2236 "Files & Directories".into(),
2237 "Symbols".into(),
2238 "Threads".into(),
2239 "Fetch".into()
2240 ]
2241 );
2242 });
2243
2244 // Select and confirm "File"
2245 editor.update_in(&mut cx, |editor, window, cx| {
2246 assert!(editor.has_visible_completions_menu());
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.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2251 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2252 });
2253
2254 cx.run_until_parked();
2255
2256 editor.update(&mut cx, |editor, cx| {
2257 assert_eq!(editor.text(cx), "Lorem @file ");
2258 assert!(editor.has_visible_completions_menu());
2259 });
2260
2261 cx.simulate_input("one");
2262
2263 editor.update(&mut cx, |editor, cx| {
2264 assert_eq!(editor.text(cx), "Lorem @file one");
2265 assert!(editor.has_visible_completions_menu());
2266 assert_eq!(
2267 current_completion_labels(editor),
2268 vec![format!("one.txt a{slash}")]
2269 );
2270 });
2271
2272 editor.update_in(&mut cx, |editor, window, cx| {
2273 assert!(editor.has_visible_completions_menu());
2274 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2275 });
2276
2277 let url_one = MentionUri::File {
2278 abs_path: path!("/dir/a/one.txt").into(),
2279 }
2280 .to_uri()
2281 .to_string();
2282 editor.update(&mut cx, |editor, cx| {
2283 let text = editor.text(cx);
2284 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2285 assert!(!editor.has_visible_completions_menu());
2286 assert_eq!(fold_ranges(editor, cx).len(), 1);
2287 });
2288
2289 let contents = message_editor
2290 .update(&mut cx, |message_editor, cx| {
2291 message_editor
2292 .mention_set()
2293 .contents(false, project.clone(), cx)
2294 })
2295 .await
2296 .unwrap()
2297 .into_values()
2298 .collect::<Vec<_>>();
2299
2300 {
2301 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2302 panic!("Unexpected mentions");
2303 };
2304 pretty_assertions::assert_eq!(content, "1");
2305 pretty_assertions::assert_eq!(
2306 uri,
2307 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2308 );
2309 }
2310
2311 cx.simulate_input(" ");
2312
2313 editor.update(&mut cx, |editor, cx| {
2314 let text = editor.text(cx);
2315 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2316 assert!(!editor.has_visible_completions_menu());
2317 assert_eq!(fold_ranges(editor, cx).len(), 1);
2318 });
2319
2320 cx.simulate_input("Ipsum ");
2321
2322 editor.update(&mut cx, |editor, cx| {
2323 let text = editor.text(cx);
2324 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2325 assert!(!editor.has_visible_completions_menu());
2326 assert_eq!(fold_ranges(editor, cx).len(), 1);
2327 });
2328
2329 cx.simulate_input("@file ");
2330
2331 editor.update(&mut cx, |editor, cx| {
2332 let text = editor.text(cx);
2333 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2334 assert!(editor.has_visible_completions_menu());
2335 assert_eq!(fold_ranges(editor, cx).len(), 1);
2336 });
2337
2338 editor.update_in(&mut cx, |editor, window, cx| {
2339 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2340 });
2341
2342 cx.run_until_parked();
2343
2344 let contents = message_editor
2345 .update(&mut cx, |message_editor, cx| {
2346 message_editor
2347 .mention_set()
2348 .contents(false, project.clone(), cx)
2349 })
2350 .await
2351 .unwrap()
2352 .into_values()
2353 .collect::<Vec<_>>();
2354
2355 let url_eight = MentionUri::File {
2356 abs_path: path!("/dir/b/eight.txt").into(),
2357 }
2358 .to_uri()
2359 .to_string();
2360
2361 {
2362 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2363 panic!("Unexpected mentions");
2364 };
2365 pretty_assertions::assert_eq!(content, "8");
2366 pretty_assertions::assert_eq!(
2367 uri,
2368 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2369 );
2370 }
2371
2372 editor.update(&mut cx, |editor, cx| {
2373 assert_eq!(
2374 editor.text(cx),
2375 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2376 );
2377 assert!(!editor.has_visible_completions_menu());
2378 assert_eq!(fold_ranges(editor, cx).len(), 2);
2379 });
2380
2381 let plain_text_language = Arc::new(language::Language::new(
2382 language::LanguageConfig {
2383 name: "Plain Text".into(),
2384 matcher: language::LanguageMatcher {
2385 path_suffixes: vec!["txt".to_string()],
2386 ..Default::default()
2387 },
2388 ..Default::default()
2389 },
2390 None,
2391 ));
2392
2393 // Register the language and fake LSP
2394 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2395 language_registry.add(plain_text_language);
2396
2397 let mut fake_language_servers = language_registry.register_fake_lsp(
2398 "Plain Text",
2399 language::FakeLspAdapter {
2400 capabilities: lsp::ServerCapabilities {
2401 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2402 ..Default::default()
2403 },
2404 ..Default::default()
2405 },
2406 );
2407
2408 // Open the buffer to trigger LSP initialization
2409 let buffer = project
2410 .update(&mut cx, |project, cx| {
2411 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2412 })
2413 .await
2414 .unwrap();
2415
2416 // Register the buffer with language servers
2417 let _handle = project.update(&mut cx, |project, cx| {
2418 project.register_buffer_with_language_servers(&buffer, cx)
2419 });
2420
2421 cx.run_until_parked();
2422
2423 let fake_language_server = fake_language_servers.next().await.unwrap();
2424 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2425 move |_, _| async move {
2426 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2427 #[allow(deprecated)]
2428 lsp::SymbolInformation {
2429 name: "MySymbol".into(),
2430 location: lsp::Location {
2431 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2432 range: lsp::Range::new(
2433 lsp::Position::new(0, 0),
2434 lsp::Position::new(0, 1),
2435 ),
2436 },
2437 kind: lsp::SymbolKind::CONSTANT,
2438 tags: None,
2439 container_name: None,
2440 deprecated: None,
2441 },
2442 ])))
2443 },
2444 );
2445
2446 cx.simulate_input("@symbol ");
2447
2448 editor.update(&mut cx, |editor, cx| {
2449 assert_eq!(
2450 editor.text(cx),
2451 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2452 );
2453 assert!(editor.has_visible_completions_menu());
2454 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2455 });
2456
2457 editor.update_in(&mut cx, |editor, window, cx| {
2458 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2459 });
2460
2461 let symbol = MentionUri::Symbol {
2462 abs_path: path!("/dir/a/one.txt").into(),
2463 name: "MySymbol".into(),
2464 line_range: 0..=0,
2465 };
2466
2467 let contents = message_editor
2468 .update(&mut cx, |message_editor, cx| {
2469 message_editor
2470 .mention_set()
2471 .contents(false, project.clone(), cx)
2472 })
2473 .await
2474 .unwrap()
2475 .into_values()
2476 .collect::<Vec<_>>();
2477
2478 {
2479 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2480 panic!("Unexpected mentions");
2481 };
2482 pretty_assertions::assert_eq!(content, "1");
2483 pretty_assertions::assert_eq!(uri, &symbol);
2484 }
2485
2486 cx.run_until_parked();
2487
2488 editor.read_with(&cx, |editor, cx| {
2489 assert_eq!(
2490 editor.text(cx),
2491 format!(
2492 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2493 symbol.to_uri(),
2494 )
2495 );
2496 });
2497
2498 // Try to mention an "image" file that will fail to load
2499 cx.simulate_input("@file x.png");
2500
2501 editor.update(&mut cx, |editor, cx| {
2502 assert_eq!(
2503 editor.text(cx),
2504 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2505 );
2506 assert!(editor.has_visible_completions_menu());
2507 assert_eq!(current_completion_labels(editor), &["x.png "]);
2508 });
2509
2510 editor.update_in(&mut cx, |editor, window, cx| {
2511 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2512 });
2513
2514 // Getting the message contents fails
2515 message_editor
2516 .update(&mut cx, |message_editor, cx| {
2517 message_editor
2518 .mention_set()
2519 .contents(false, project.clone(), cx)
2520 })
2521 .await
2522 .expect_err("Should fail to load x.png");
2523
2524 cx.run_until_parked();
2525
2526 // Mention was removed
2527 editor.read_with(&cx, |editor, cx| {
2528 assert_eq!(
2529 editor.text(cx),
2530 format!(
2531 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2532 symbol.to_uri()
2533 )
2534 );
2535 });
2536
2537 // Once more
2538 cx.simulate_input("@file x.png");
2539
2540 editor.update(&mut cx, |editor, cx| {
2541 assert_eq!(
2542 editor.text(cx),
2543 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2544 );
2545 assert!(editor.has_visible_completions_menu());
2546 assert_eq!(current_completion_labels(editor), &["x.png "]);
2547 });
2548
2549 editor.update_in(&mut cx, |editor, window, cx| {
2550 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2551 });
2552
2553 // This time don't immediately get the contents, just let the confirmed completion settle
2554 cx.run_until_parked();
2555
2556 // Mention was removed
2557 editor.read_with(&cx, |editor, cx| {
2558 assert_eq!(
2559 editor.text(cx),
2560 format!(
2561 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2562 symbol.to_uri()
2563 )
2564 );
2565 });
2566
2567 // Now getting the contents succeeds, because the invalid mention was removed
2568 let contents = message_editor
2569 .update(&mut cx, |message_editor, cx| {
2570 message_editor
2571 .mention_set()
2572 .contents(false, project.clone(), cx)
2573 })
2574 .await
2575 .unwrap();
2576 assert_eq!(contents.len(), 3);
2577 }
2578
2579 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2580 let snapshot = editor.buffer().read(cx).snapshot(cx);
2581 editor.display_map.update(cx, |display_map, cx| {
2582 display_map
2583 .snapshot(cx)
2584 .folds_in_range(0..snapshot.len())
2585 .map(|fold| fold.range.to_point(&snapshot))
2586 .collect()
2587 })
2588 }
2589
2590 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2591 let completions = editor.current_completions().expect("Missing completions");
2592 completions
2593 .into_iter()
2594 .map(|completion| completion.label.text)
2595 .collect::<Vec<_>>()
2596 }
2597
2598 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2599 let completions = editor.current_completions().expect("Missing completions");
2600 completions
2601 .into_iter()
2602 .map(|completion| {
2603 (
2604 completion.label.text,
2605 completion
2606 .documentation
2607 .map(|d| d.text().to_string())
2608 .unwrap_or_default(),
2609 )
2610 })
2611 .collect::<Vec<_>>()
2612 }
2613
2614 #[gpui::test]
2615 async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) {
2616 init_test(cx);
2617
2618 let fs = FakeFs::new(cx.executor());
2619
2620 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2621 const LINE: &str = "fn example_function() { /* some code */ }\n";
2622 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2623 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2624
2625 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2626 let small_content = "fn small_function() { /* small */ }\n";
2627 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2628
2629 fs.insert_tree(
2630 "/project",
2631 json!({
2632 "large_file.rs": large_content.clone(),
2633 "small_file.rs": small_content,
2634 }),
2635 )
2636 .await;
2637
2638 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2639
2640 let (workspace, cx) =
2641 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2642
2643 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2644 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2645
2646 let message_editor = cx.update(|window, cx| {
2647 cx.new(|cx| {
2648 let editor = MessageEditor::new(
2649 workspace.downgrade(),
2650 project.clone(),
2651 history_store.clone(),
2652 None,
2653 Default::default(),
2654 Default::default(),
2655 "Test Agent".into(),
2656 "Test",
2657 EditorMode::AutoHeight {
2658 min_lines: 1,
2659 max_lines: None,
2660 },
2661 window,
2662 cx,
2663 );
2664 // Enable embedded context so files are actually included
2665 editor.prompt_capabilities.replace(acp::PromptCapabilities {
2666 embedded_context: true,
2667 meta: None,
2668 ..Default::default()
2669 });
2670 editor
2671 })
2672 });
2673
2674 // Test large file mention
2675 // Get the absolute path using the project's worktree
2676 let large_file_abs_path = project.read_with(cx, |project, cx| {
2677 let worktree = project.worktrees(cx).next().unwrap();
2678 let worktree_root = worktree.read(cx).abs_path();
2679 worktree_root.join("large_file.rs")
2680 });
2681 let large_file_task = message_editor.update(cx, |editor, cx| {
2682 editor.confirm_mention_for_file(large_file_abs_path, cx)
2683 });
2684
2685 let large_file_mention = large_file_task.await.unwrap();
2686 match large_file_mention {
2687 Mention::Text { content, .. } => {
2688 // Should contain outline header for large files
2689 assert!(content.contains("File outline for"));
2690 assert!(content.contains("file too large to show full content"));
2691 // Should not contain the full repeated content
2692 assert!(!content.contains(&LINE.repeat(100)));
2693 }
2694 _ => panic!("Expected Text mention for large file"),
2695 }
2696
2697 // Test small file mention
2698 // Get the absolute path using the project's worktree
2699 let small_file_abs_path = project.read_with(cx, |project, cx| {
2700 let worktree = project.worktrees(cx).next().unwrap();
2701 let worktree_root = worktree.read(cx).abs_path();
2702 worktree_root.join("small_file.rs")
2703 });
2704 let small_file_task = message_editor.update(cx, |editor, cx| {
2705 editor.confirm_mention_for_file(small_file_abs_path, cx)
2706 });
2707
2708 let small_file_mention = small_file_task.await.unwrap();
2709 match small_file_mention {
2710 Mention::Text { content, .. } => {
2711 // Should contain the actual content
2712 assert_eq!(content, small_content);
2713 // Should not contain outline header
2714 assert!(!content.contains("File outline for"));
2715 }
2716 _ => panic!("Expected Text mention for small file"),
2717 }
2718 }
2719
2720 #[gpui::test]
2721 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2722 init_test(cx);
2723 cx.update(LanguageModelRegistry::test);
2724
2725 let fs = FakeFs::new(cx.executor());
2726 fs.insert_tree("/project", json!({"file": ""})).await;
2727 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2728
2729 let (workspace, cx) =
2730 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2731
2732 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2733 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2734
2735 // Create a thread metadata to insert as summary
2736 let thread_metadata = agent::DbThreadMetadata {
2737 id: acp::SessionId("thread-123".into()),
2738 title: "Previous Conversation".into(),
2739 updated_at: chrono::Utc::now(),
2740 };
2741
2742 let message_editor = cx.update(|window, cx| {
2743 cx.new(|cx| {
2744 let mut editor = MessageEditor::new(
2745 workspace.downgrade(),
2746 project.clone(),
2747 history_store.clone(),
2748 None,
2749 Default::default(),
2750 Default::default(),
2751 "Test Agent".into(),
2752 "Test",
2753 EditorMode::AutoHeight {
2754 min_lines: 1,
2755 max_lines: None,
2756 },
2757 window,
2758 cx,
2759 );
2760 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2761 editor
2762 })
2763 });
2764
2765 // Construct expected values for verification
2766 let expected_uri = MentionUri::Thread {
2767 id: thread_metadata.id.clone(),
2768 name: thread_metadata.title.to_string(),
2769 };
2770 let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2771
2772 message_editor.read_with(cx, |editor, cx| {
2773 let text = editor.text(cx);
2774
2775 assert!(
2776 text.contains(&expected_link),
2777 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2778 expected_link,
2779 text
2780 );
2781
2782 let mentions = editor.mentions();
2783 assert_eq!(
2784 mentions.len(),
2785 1,
2786 "Expected exactly one mention after inserting thread summary"
2787 );
2788
2789 assert!(
2790 mentions.contains(&expected_uri),
2791 "Expected mentions to contain the thread URI"
2792 );
2793 });
2794 }
2795
2796 #[gpui::test]
2797 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2798 init_test(cx);
2799
2800 let fs = FakeFs::new(cx.executor());
2801 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2802 .await;
2803 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2804
2805 let (workspace, cx) =
2806 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2807
2808 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2809 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2810
2811 let message_editor = cx.update(|window, cx| {
2812 cx.new(|cx| {
2813 MessageEditor::new(
2814 workspace.downgrade(),
2815 project.clone(),
2816 history_store.clone(),
2817 None,
2818 Default::default(),
2819 Default::default(),
2820 "Test Agent".into(),
2821 "Test",
2822 EditorMode::AutoHeight {
2823 min_lines: 1,
2824 max_lines: None,
2825 },
2826 window,
2827 cx,
2828 )
2829 })
2830 });
2831 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2832
2833 cx.run_until_parked();
2834
2835 editor.update_in(cx, |editor, window, cx| {
2836 editor.set_text(" hello world ", window, cx);
2837 });
2838
2839 let (content, _) = message_editor
2840 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2841 .await
2842 .unwrap();
2843
2844 assert_eq!(
2845 content,
2846 vec![acp::ContentBlock::Text(acp::TextContent {
2847 text: "hello world".into(),
2848 annotations: None,
2849 meta: None
2850 })]
2851 );
2852 }
2853
2854 #[gpui::test]
2855 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2856 init_test(cx);
2857
2858 let fs = FakeFs::new(cx.executor());
2859
2860 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2861
2862 fs.insert_tree(
2863 "/project",
2864 json!({
2865 "src": {
2866 "main.rs": file_content,
2867 }
2868 }),
2869 )
2870 .await;
2871
2872 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2873
2874 let (workspace, cx) =
2875 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2876
2877 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2878 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2879
2880 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2881 let workspace_handle = cx.weak_entity();
2882 let message_editor = cx.new(|cx| {
2883 MessageEditor::new(
2884 workspace_handle,
2885 project.clone(),
2886 history_store.clone(),
2887 None,
2888 Default::default(),
2889 Default::default(),
2890 "Test Agent".into(),
2891 "Test",
2892 EditorMode::AutoHeight {
2893 max_lines: None,
2894 min_lines: 1,
2895 },
2896 window,
2897 cx,
2898 )
2899 });
2900 workspace.active_pane().update(cx, |pane, cx| {
2901 pane.add_item(
2902 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2903 true,
2904 true,
2905 None,
2906 window,
2907 cx,
2908 );
2909 });
2910 message_editor.read(cx).focus_handle(cx).focus(window);
2911 let editor = message_editor.read(cx).editor().clone();
2912 (message_editor, editor)
2913 });
2914
2915 cx.simulate_input("What is in @file main");
2916
2917 editor.update_in(cx, |editor, window, cx| {
2918 assert!(editor.has_visible_completions_menu());
2919 assert_eq!(editor.text(cx), "What is in @file main");
2920 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2921 });
2922
2923 let content = message_editor
2924 .update(cx, |editor, cx| editor.contents(false, cx))
2925 .await
2926 .unwrap()
2927 .0;
2928
2929 let main_rs_uri = if cfg!(windows) {
2930 "file:///C:/project/src/main.rs".to_string()
2931 } else {
2932 "file:///project/src/main.rs".to_string()
2933 };
2934
2935 // When embedded context is `false` we should get a resource link
2936 pretty_assertions::assert_eq!(
2937 content,
2938 vec![
2939 acp::ContentBlock::Text(acp::TextContent {
2940 text: "What is in ".to_string(),
2941 annotations: None,
2942 meta: None
2943 }),
2944 acp::ContentBlock::ResourceLink(acp::ResourceLink {
2945 uri: main_rs_uri.clone(),
2946 name: "main.rs".to_string(),
2947 annotations: None,
2948 meta: None,
2949 description: None,
2950 mime_type: None,
2951 size: None,
2952 title: None,
2953 })
2954 ]
2955 );
2956
2957 message_editor.update(cx, |editor, _cx| {
2958 editor.prompt_capabilities.replace(acp::PromptCapabilities {
2959 embedded_context: true,
2960 ..Default::default()
2961 })
2962 });
2963
2964 let content = message_editor
2965 .update(cx, |editor, cx| editor.contents(false, cx))
2966 .await
2967 .unwrap()
2968 .0;
2969
2970 // When embedded context is `true` we should get a resource
2971 pretty_assertions::assert_eq!(
2972 content,
2973 vec![
2974 acp::ContentBlock::Text(acp::TextContent {
2975 text: "What is in ".to_string(),
2976 annotations: None,
2977 meta: None
2978 }),
2979 acp::ContentBlock::Resource(acp::EmbeddedResource {
2980 resource: acp::EmbeddedResourceResource::TextResourceContents(
2981 acp::TextResourceContents {
2982 text: file_content.to_string(),
2983 uri: main_rs_uri,
2984 mime_type: None,
2985 meta: None
2986 }
2987 ),
2988 annotations: None,
2989 meta: None
2990 })
2991 ]
2992 );
2993 }
2994
2995 #[gpui::test]
2996 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2997 init_test(cx);
2998
2999 let app_state = cx.update(AppState::test);
3000
3001 cx.update(|cx| {
3002 language::init(cx);
3003 editor::init(cx);
3004 workspace::init(app_state.clone(), cx);
3005 Project::init_settings(cx);
3006 });
3007
3008 app_state
3009 .fs
3010 .as_fake()
3011 .insert_tree(
3012 path!("/dir"),
3013 json!({
3014 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3015 }),
3016 )
3017 .await;
3018
3019 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3020 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3021 let workspace = window.root(cx).unwrap();
3022
3023 let worktree = project.update(cx, |project, cx| {
3024 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3025 assert_eq!(worktrees.len(), 1);
3026 worktrees.pop().unwrap()
3027 });
3028 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3029
3030 let mut cx = VisualTestContext::from_window(*window, cx);
3031
3032 // Open a regular editor with the created file, and select a portion of
3033 // the text that will be used for the selections that are meant to be
3034 // inserted in the agent panel.
3035 let editor = workspace
3036 .update_in(&mut cx, |workspace, window, cx| {
3037 workspace.open_path(
3038 ProjectPath {
3039 worktree_id,
3040 path: rel_path("test.txt").into(),
3041 },
3042 None,
3043 false,
3044 window,
3045 cx,
3046 )
3047 })
3048 .await
3049 .unwrap()
3050 .downcast::<Editor>()
3051 .unwrap();
3052
3053 editor.update_in(&mut cx, |editor, window, cx| {
3054 editor.change_selections(Default::default(), window, cx, |selections| {
3055 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3056 });
3057 });
3058
3059 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3060 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
3061
3062 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3063 // to ensure we have a fixed viewport, so we can eventually actually
3064 // place the cursor outside of the visible area.
3065 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3066 let workspace_handle = cx.weak_entity();
3067 let message_editor = cx.new(|cx| {
3068 MessageEditor::new(
3069 workspace_handle,
3070 project.clone(),
3071 history_store.clone(),
3072 None,
3073 Default::default(),
3074 Default::default(),
3075 "Test Agent".into(),
3076 "Test",
3077 EditorMode::full(),
3078 window,
3079 cx,
3080 )
3081 });
3082 workspace.active_pane().update(cx, |pane, cx| {
3083 pane.add_item(
3084 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3085 true,
3086 true,
3087 None,
3088 window,
3089 cx,
3090 );
3091 });
3092
3093 message_editor
3094 });
3095
3096 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3097 message_editor.editor.update(cx, |editor, cx| {
3098 // Update the Agent Panel's Message Editor text to have 100
3099 // lines, ensuring that the cursor is set at line 90 and that we
3100 // then scroll all the way to the top, so the cursor's position
3101 // remains off screen.
3102 let mut lines = String::new();
3103 for _ in 1..=100 {
3104 lines.push_str(&"Another line in the agent panel's message editor\n");
3105 }
3106 editor.set_text(lines.as_str(), window, cx);
3107 editor.change_selections(Default::default(), window, cx, |selections| {
3108 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3109 });
3110 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3111 });
3112 });
3113
3114 cx.run_until_parked();
3115
3116 // Before proceeding, let's assert that the cursor is indeed off screen,
3117 // otherwise the rest of the test doesn't make sense.
3118 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3119 message_editor.editor.update(cx, |editor, cx| {
3120 let snapshot = editor.snapshot(window, cx);
3121 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3122 let scroll_top = snapshot.scroll_position().y as u32;
3123 let visible_lines = editor.visible_line_count().unwrap() as u32;
3124 let visible_range = scroll_top..(scroll_top + visible_lines);
3125
3126 assert!(!visible_range.contains(&cursor_row));
3127 })
3128 });
3129
3130 // Now let's insert the selection in the Agent Panel's editor and
3131 // confirm that, after the insertion, the cursor is now in the visible
3132 // range.
3133 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3134 message_editor.insert_selections(window, cx);
3135 });
3136
3137 cx.run_until_parked();
3138
3139 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3140 message_editor.editor.update(cx, |editor, cx| {
3141 let snapshot = editor.snapshot(window, cx);
3142 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3143 let scroll_top = snapshot.scroll_position().y as u32;
3144 let visible_lines = editor.visible_line_count().unwrap() as u32;
3145 let visible_range = scroll_top..(scroll_top + visible_lines);
3146
3147 assert!(visible_range.contains(&cursor_row));
3148 })
3149 });
3150 }
3151}