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