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