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