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