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