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 editor::init(cx);
1905 workspace::init(app_state.clone(), cx);
1906 });
1907
1908 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1909 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1910 let workspace = window.root(cx).unwrap();
1911
1912 let mut cx = VisualTestContext::from_window(*window, cx);
1913
1914 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1915 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1916 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1917 let available_commands = Rc::new(RefCell::new(vec![
1918 acp::AvailableCommand {
1919 name: "quick-math".to_string(),
1920 description: "2 + 2 = 4 - 1 = 3".to_string(),
1921 input: None,
1922 meta: None,
1923 },
1924 acp::AvailableCommand {
1925 name: "say-hello".to_string(),
1926 description: "Say hello to whoever you want".to_string(),
1927 input: Some(acp::AvailableCommandInput::Unstructured {
1928 hint: "<name>".to_string(),
1929 }),
1930 meta: None,
1931 },
1932 ]));
1933
1934 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1935 let workspace_handle = cx.weak_entity();
1936 let message_editor = cx.new(|cx| {
1937 MessageEditor::new(
1938 workspace_handle,
1939 project.clone(),
1940 history_store.clone(),
1941 None,
1942 prompt_capabilities.clone(),
1943 available_commands.clone(),
1944 "Test Agent".into(),
1945 "Test",
1946 EditorMode::AutoHeight {
1947 max_lines: None,
1948 min_lines: 1,
1949 },
1950 window,
1951 cx,
1952 )
1953 });
1954 workspace.active_pane().update(cx, |pane, cx| {
1955 pane.add_item(
1956 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1957 true,
1958 true,
1959 None,
1960 window,
1961 cx,
1962 );
1963 });
1964 message_editor.read(cx).focus_handle(cx).focus(window);
1965 message_editor.read(cx).editor().clone()
1966 });
1967
1968 cx.simulate_input("/");
1969
1970 editor.update_in(&mut cx, |editor, window, cx| {
1971 assert_eq!(editor.text(cx), "/");
1972 assert!(editor.has_visible_completions_menu());
1973
1974 assert_eq!(
1975 current_completion_labels_with_documentation(editor),
1976 &[
1977 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1978 ("say-hello".into(), "Say hello to whoever you want".into())
1979 ]
1980 );
1981 editor.set_text("", window, cx);
1982 });
1983
1984 cx.simulate_input("/qui");
1985
1986 editor.update_in(&mut cx, |editor, window, cx| {
1987 assert_eq!(editor.text(cx), "/qui");
1988 assert!(editor.has_visible_completions_menu());
1989
1990 assert_eq!(
1991 current_completion_labels_with_documentation(editor),
1992 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1993 );
1994 editor.set_text("", window, cx);
1995 });
1996
1997 editor.update_in(&mut cx, |editor, window, cx| {
1998 assert!(editor.has_visible_completions_menu());
1999 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2000 });
2001
2002 cx.run_until_parked();
2003
2004 editor.update_in(&mut cx, |editor, window, cx| {
2005 assert_eq!(editor.display_text(cx), "/quick-math ");
2006 assert!(!editor.has_visible_completions_menu());
2007 editor.set_text("", window, cx);
2008 });
2009
2010 cx.simulate_input("/say");
2011
2012 editor.update_in(&mut cx, |editor, _window, cx| {
2013 assert_eq!(editor.display_text(cx), "/say");
2014 assert!(editor.has_visible_completions_menu());
2015
2016 assert_eq!(
2017 current_completion_labels_with_documentation(editor),
2018 &[("say-hello".into(), "Say hello to whoever you want".into())]
2019 );
2020 });
2021
2022 editor.update_in(&mut cx, |editor, window, cx| {
2023 assert!(editor.has_visible_completions_menu());
2024 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2025 });
2026
2027 cx.run_until_parked();
2028
2029 editor.update_in(&mut cx, |editor, _window, cx| {
2030 assert_eq!(editor.text(cx), "/say-hello ");
2031 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2032 assert!(!editor.has_visible_completions_menu());
2033 });
2034
2035 cx.simulate_input("GPT5");
2036
2037 cx.run_until_parked();
2038
2039 editor.update_in(&mut cx, |editor, window, cx| {
2040 assert_eq!(editor.text(cx), "/say-hello GPT5");
2041 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2042 assert!(!editor.has_visible_completions_menu());
2043
2044 // Delete argument
2045 for _ in 0..5 {
2046 editor.backspace(&editor::actions::Backspace, window, cx);
2047 }
2048 });
2049
2050 cx.run_until_parked();
2051
2052 editor.update_in(&mut cx, |editor, window, cx| {
2053 assert_eq!(editor.text(cx), "/say-hello");
2054 // Hint is visible because argument was deleted
2055 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2056
2057 // Delete last command letter
2058 editor.backspace(&editor::actions::Backspace, window, cx);
2059 });
2060
2061 cx.run_until_parked();
2062
2063 editor.update_in(&mut cx, |editor, _window, cx| {
2064 // Hint goes away once command no longer matches an available one
2065 assert_eq!(editor.text(cx), "/say-hell");
2066 assert_eq!(editor.display_text(cx), "/say-hell");
2067 assert!(!editor.has_visible_completions_menu());
2068 });
2069 }
2070
2071 #[gpui::test]
2072 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2073 init_test(cx);
2074
2075 let app_state = cx.update(AppState::test);
2076
2077 cx.update(|cx| {
2078 editor::init(cx);
2079 workspace::init(app_state.clone(), cx);
2080 });
2081
2082 app_state
2083 .fs
2084 .as_fake()
2085 .insert_tree(
2086 path!("/dir"),
2087 json!({
2088 "editor": "",
2089 "a": {
2090 "one.txt": "1",
2091 "two.txt": "2",
2092 "three.txt": "3",
2093 "four.txt": "4"
2094 },
2095 "b": {
2096 "five.txt": "5",
2097 "six.txt": "6",
2098 "seven.txt": "7",
2099 "eight.txt": "8",
2100 },
2101 "x.png": "",
2102 }),
2103 )
2104 .await;
2105
2106 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2107 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2108 let workspace = window.root(cx).unwrap();
2109
2110 let worktree = project.update(cx, |project, cx| {
2111 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2112 assert_eq!(worktrees.len(), 1);
2113 worktrees.pop().unwrap()
2114 });
2115 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2116
2117 let mut cx = VisualTestContext::from_window(*window, cx);
2118
2119 let paths = vec![
2120 rel_path("a/one.txt"),
2121 rel_path("a/two.txt"),
2122 rel_path("a/three.txt"),
2123 rel_path("a/four.txt"),
2124 rel_path("b/five.txt"),
2125 rel_path("b/six.txt"),
2126 rel_path("b/seven.txt"),
2127 rel_path("b/eight.txt"),
2128 ];
2129
2130 let slash = PathStyle::local().separator();
2131
2132 let mut opened_editors = Vec::new();
2133 for path in paths {
2134 let buffer = workspace
2135 .update_in(&mut cx, |workspace, window, cx| {
2136 workspace.open_path(
2137 ProjectPath {
2138 worktree_id,
2139 path: path.into(),
2140 },
2141 None,
2142 false,
2143 window,
2144 cx,
2145 )
2146 })
2147 .await
2148 .unwrap();
2149 opened_editors.push(buffer);
2150 }
2151
2152 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2153 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2154 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2155
2156 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2157 let workspace_handle = cx.weak_entity();
2158 let message_editor = cx.new(|cx| {
2159 MessageEditor::new(
2160 workspace_handle,
2161 project.clone(),
2162 history_store.clone(),
2163 None,
2164 prompt_capabilities.clone(),
2165 Default::default(),
2166 "Test Agent".into(),
2167 "Test",
2168 EditorMode::AutoHeight {
2169 max_lines: None,
2170 min_lines: 1,
2171 },
2172 window,
2173 cx,
2174 )
2175 });
2176 workspace.active_pane().update(cx, |pane, cx| {
2177 pane.add_item(
2178 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2179 true,
2180 true,
2181 None,
2182 window,
2183 cx,
2184 );
2185 });
2186 message_editor.read(cx).focus_handle(cx).focus(window);
2187 let editor = message_editor.read(cx).editor().clone();
2188 (message_editor, editor)
2189 });
2190
2191 cx.simulate_input("Lorem @");
2192
2193 editor.update_in(&mut cx, |editor, window, cx| {
2194 assert_eq!(editor.text(cx), "Lorem @");
2195 assert!(editor.has_visible_completions_menu());
2196
2197 assert_eq!(
2198 current_completion_labels(editor),
2199 &[
2200 format!("eight.txt b{slash}"),
2201 format!("seven.txt b{slash}"),
2202 format!("six.txt b{slash}"),
2203 format!("five.txt b{slash}"),
2204 ]
2205 );
2206 editor.set_text("", window, cx);
2207 });
2208
2209 prompt_capabilities.replace(acp::PromptCapabilities {
2210 image: true,
2211 audio: true,
2212 embedded_context: true,
2213 meta: None,
2214 });
2215
2216 cx.simulate_input("Lorem ");
2217
2218 editor.update(&mut cx, |editor, cx| {
2219 assert_eq!(editor.text(cx), "Lorem ");
2220 assert!(!editor.has_visible_completions_menu());
2221 });
2222
2223 cx.simulate_input("@");
2224
2225 editor.update(&mut cx, |editor, cx| {
2226 assert_eq!(editor.text(cx), "Lorem @");
2227 assert!(editor.has_visible_completions_menu());
2228 assert_eq!(
2229 current_completion_labels(editor),
2230 &[
2231 format!("eight.txt b{slash}"),
2232 format!("seven.txt b{slash}"),
2233 format!("six.txt b{slash}"),
2234 format!("five.txt b{slash}"),
2235 "Files & Directories".into(),
2236 "Symbols".into(),
2237 "Threads".into(),
2238 "Fetch".into()
2239 ]
2240 );
2241 });
2242
2243 // Select and confirm "File"
2244 editor.update_in(&mut cx, |editor, window, cx| {
2245 assert!(editor.has_visible_completions_menu());
2246 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
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.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2251 });
2252
2253 cx.run_until_parked();
2254
2255 editor.update(&mut cx, |editor, cx| {
2256 assert_eq!(editor.text(cx), "Lorem @file ");
2257 assert!(editor.has_visible_completions_menu());
2258 });
2259
2260 cx.simulate_input("one");
2261
2262 editor.update(&mut cx, |editor, cx| {
2263 assert_eq!(editor.text(cx), "Lorem @file one");
2264 assert!(editor.has_visible_completions_menu());
2265 assert_eq!(
2266 current_completion_labels(editor),
2267 vec![format!("one.txt a{slash}")]
2268 );
2269 });
2270
2271 editor.update_in(&mut cx, |editor, window, cx| {
2272 assert!(editor.has_visible_completions_menu());
2273 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2274 });
2275
2276 let url_one = MentionUri::File {
2277 abs_path: path!("/dir/a/one.txt").into(),
2278 }
2279 .to_uri()
2280 .to_string();
2281 editor.update(&mut cx, |editor, cx| {
2282 let text = editor.text(cx);
2283 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2284 assert!(!editor.has_visible_completions_menu());
2285 assert_eq!(fold_ranges(editor, cx).len(), 1);
2286 });
2287
2288 let all_prompt_capabilities = acp::PromptCapabilities {
2289 image: true,
2290 audio: true,
2291 embedded_context: true,
2292 meta: None,
2293 };
2294
2295 let contents = message_editor
2296 .update(&mut cx, |message_editor, cx| {
2297 message_editor.mention_set().contents(
2298 &all_prompt_capabilities,
2299 false,
2300 project.clone(),
2301 cx,
2302 )
2303 })
2304 .await
2305 .unwrap()
2306 .into_values()
2307 .collect::<Vec<_>>();
2308
2309 {
2310 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2311 panic!("Unexpected mentions");
2312 };
2313 pretty_assertions::assert_eq!(content, "1");
2314 pretty_assertions::assert_eq!(
2315 uri,
2316 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2317 );
2318 }
2319
2320 let contents = message_editor
2321 .update(&mut cx, |message_editor, cx| {
2322 message_editor.mention_set().contents(
2323 &acp::PromptCapabilities::default(),
2324 false,
2325 project.clone(),
2326 cx,
2327 )
2328 })
2329 .await
2330 .unwrap()
2331 .into_values()
2332 .collect::<Vec<_>>();
2333
2334 {
2335 let [(uri, Mention::UriOnly)] = contents.as_slice() else {
2336 panic!("Unexpected mentions");
2337 };
2338 pretty_assertions::assert_eq!(
2339 uri,
2340 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2341 );
2342 }
2343
2344 cx.simulate_input(" ");
2345
2346 editor.update(&mut cx, |editor, cx| {
2347 let text = editor.text(cx);
2348 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2349 assert!(!editor.has_visible_completions_menu());
2350 assert_eq!(fold_ranges(editor, cx).len(), 1);
2351 });
2352
2353 cx.simulate_input("Ipsum ");
2354
2355 editor.update(&mut cx, |editor, cx| {
2356 let text = editor.text(cx);
2357 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2358 assert!(!editor.has_visible_completions_menu());
2359 assert_eq!(fold_ranges(editor, cx).len(), 1);
2360 });
2361
2362 cx.simulate_input("@file ");
2363
2364 editor.update(&mut cx, |editor, cx| {
2365 let text = editor.text(cx);
2366 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2367 assert!(editor.has_visible_completions_menu());
2368 assert_eq!(fold_ranges(editor, cx).len(), 1);
2369 });
2370
2371 editor.update_in(&mut cx, |editor, window, cx| {
2372 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2373 });
2374
2375 cx.run_until_parked();
2376
2377 let contents = message_editor
2378 .update(&mut cx, |message_editor, cx| {
2379 message_editor.mention_set().contents(
2380 &all_prompt_capabilities,
2381 false,
2382 project.clone(),
2383 cx,
2384 )
2385 })
2386 .await
2387 .unwrap()
2388 .into_values()
2389 .collect::<Vec<_>>();
2390
2391 let url_eight = MentionUri::File {
2392 abs_path: path!("/dir/b/eight.txt").into(),
2393 }
2394 .to_uri()
2395 .to_string();
2396
2397 {
2398 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2399 panic!("Unexpected mentions");
2400 };
2401 pretty_assertions::assert_eq!(content, "8");
2402 pretty_assertions::assert_eq!(
2403 uri,
2404 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2405 );
2406 }
2407
2408 editor.update(&mut cx, |editor, cx| {
2409 assert_eq!(
2410 editor.text(cx),
2411 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2412 );
2413 assert!(!editor.has_visible_completions_menu());
2414 assert_eq!(fold_ranges(editor, cx).len(), 2);
2415 });
2416
2417 let plain_text_language = Arc::new(language::Language::new(
2418 language::LanguageConfig {
2419 name: "Plain Text".into(),
2420 matcher: language::LanguageMatcher {
2421 path_suffixes: vec!["txt".to_string()],
2422 ..Default::default()
2423 },
2424 ..Default::default()
2425 },
2426 None,
2427 ));
2428
2429 // Register the language and fake LSP
2430 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2431 language_registry.add(plain_text_language);
2432
2433 let mut fake_language_servers = language_registry.register_fake_lsp(
2434 "Plain Text",
2435 language::FakeLspAdapter {
2436 capabilities: lsp::ServerCapabilities {
2437 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2438 ..Default::default()
2439 },
2440 ..Default::default()
2441 },
2442 );
2443
2444 // Open the buffer to trigger LSP initialization
2445 let buffer = project
2446 .update(&mut cx, |project, cx| {
2447 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2448 })
2449 .await
2450 .unwrap();
2451
2452 // Register the buffer with language servers
2453 let _handle = project.update(&mut cx, |project, cx| {
2454 project.register_buffer_with_language_servers(&buffer, cx)
2455 });
2456
2457 cx.run_until_parked();
2458
2459 let fake_language_server = fake_language_servers.next().await.unwrap();
2460 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2461 move |_, _| async move {
2462 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2463 #[allow(deprecated)]
2464 lsp::SymbolInformation {
2465 name: "MySymbol".into(),
2466 location: lsp::Location {
2467 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2468 range: lsp::Range::new(
2469 lsp::Position::new(0, 0),
2470 lsp::Position::new(0, 1),
2471 ),
2472 },
2473 kind: lsp::SymbolKind::CONSTANT,
2474 tags: None,
2475 container_name: None,
2476 deprecated: None,
2477 },
2478 ])))
2479 },
2480 );
2481
2482 cx.simulate_input("@symbol ");
2483
2484 editor.update(&mut cx, |editor, cx| {
2485 assert_eq!(
2486 editor.text(cx),
2487 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2488 );
2489 assert!(editor.has_visible_completions_menu());
2490 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2491 });
2492
2493 editor.update_in(&mut cx, |editor, window, cx| {
2494 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2495 });
2496
2497 let symbol = MentionUri::Symbol {
2498 abs_path: path!("/dir/a/one.txt").into(),
2499 name: "MySymbol".into(),
2500 line_range: 0..=0,
2501 };
2502
2503 let contents = message_editor
2504 .update(&mut cx, |message_editor, cx| {
2505 message_editor.mention_set().contents(
2506 &all_prompt_capabilities,
2507 false,
2508 project.clone(),
2509 cx,
2510 )
2511 })
2512 .await
2513 .unwrap()
2514 .into_values()
2515 .collect::<Vec<_>>();
2516
2517 {
2518 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2519 panic!("Unexpected mentions");
2520 };
2521 pretty_assertions::assert_eq!(content, "1");
2522 pretty_assertions::assert_eq!(uri, &symbol);
2523 }
2524
2525 cx.run_until_parked();
2526
2527 editor.read_with(&cx, |editor, cx| {
2528 assert_eq!(
2529 editor.text(cx),
2530 format!(
2531 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2532 symbol.to_uri(),
2533 )
2534 );
2535 });
2536
2537 // Try to mention an "image" file that will fail to load
2538 cx.simulate_input("@file x.png");
2539
2540 editor.update(&mut cx, |editor, cx| {
2541 assert_eq!(
2542 editor.text(cx),
2543 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2544 );
2545 assert!(editor.has_visible_completions_menu());
2546 assert_eq!(current_completion_labels(editor), &["x.png "]);
2547 });
2548
2549 editor.update_in(&mut cx, |editor, window, cx| {
2550 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2551 });
2552
2553 // Getting the message contents fails
2554 message_editor
2555 .update(&mut cx, |message_editor, cx| {
2556 message_editor.mention_set().contents(
2557 &all_prompt_capabilities,
2558 false,
2559 project.clone(),
2560 cx,
2561 )
2562 })
2563 .await
2564 .expect_err("Should fail to load x.png");
2565
2566 cx.run_until_parked();
2567
2568 // Mention was removed
2569 editor.read_with(&cx, |editor, cx| {
2570 assert_eq!(
2571 editor.text(cx),
2572 format!(
2573 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2574 symbol.to_uri()
2575 )
2576 );
2577 });
2578
2579 // Once more
2580 cx.simulate_input("@file x.png");
2581
2582 editor.update(&mut cx, |editor, cx| {
2583 assert_eq!(
2584 editor.text(cx),
2585 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2586 );
2587 assert!(editor.has_visible_completions_menu());
2588 assert_eq!(current_completion_labels(editor), &["x.png "]);
2589 });
2590
2591 editor.update_in(&mut cx, |editor, window, cx| {
2592 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2593 });
2594
2595 // This time don't immediately get the contents, just let the confirmed completion settle
2596 cx.run_until_parked();
2597
2598 // Mention was removed
2599 editor.read_with(&cx, |editor, cx| {
2600 assert_eq!(
2601 editor.text(cx),
2602 format!(
2603 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2604 symbol.to_uri()
2605 )
2606 );
2607 });
2608
2609 // Now getting the contents succeeds, because the invalid mention was removed
2610 let contents = message_editor
2611 .update(&mut cx, |message_editor, cx| {
2612 message_editor.mention_set().contents(
2613 &all_prompt_capabilities,
2614 false,
2615 project.clone(),
2616 cx,
2617 )
2618 })
2619 .await
2620 .unwrap();
2621 assert_eq!(contents.len(), 3);
2622 }
2623
2624 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2625 let snapshot = editor.buffer().read(cx).snapshot(cx);
2626 editor.display_map.update(cx, |display_map, cx| {
2627 display_map
2628 .snapshot(cx)
2629 .folds_in_range(0..snapshot.len())
2630 .map(|fold| fold.range.to_point(&snapshot))
2631 .collect()
2632 })
2633 }
2634
2635 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2636 let completions = editor.current_completions().expect("Missing completions");
2637 completions
2638 .into_iter()
2639 .map(|completion| completion.label.text)
2640 .collect::<Vec<_>>()
2641 }
2642
2643 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2644 let completions = editor.current_completions().expect("Missing completions");
2645 completions
2646 .into_iter()
2647 .map(|completion| {
2648 (
2649 completion.label.text,
2650 completion
2651 .documentation
2652 .map(|d| d.text().to_string())
2653 .unwrap_or_default(),
2654 )
2655 })
2656 .collect::<Vec<_>>()
2657 }
2658
2659 #[gpui::test]
2660 async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) {
2661 init_test(cx);
2662
2663 let fs = FakeFs::new(cx.executor());
2664
2665 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2666 const LINE: &str = "fn example_function() { /* some code */ }\n";
2667 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2668 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2669
2670 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2671 let small_content = "fn small_function() { /* small */ }\n";
2672 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2673
2674 fs.insert_tree(
2675 "/project",
2676 json!({
2677 "large_file.rs": large_content.clone(),
2678 "small_file.rs": small_content,
2679 }),
2680 )
2681 .await;
2682
2683 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2684
2685 let (workspace, cx) =
2686 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2687
2688 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2689 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2690
2691 let message_editor = cx.update(|window, cx| {
2692 cx.new(|cx| {
2693 let editor = MessageEditor::new(
2694 workspace.downgrade(),
2695 project.clone(),
2696 history_store.clone(),
2697 None,
2698 Default::default(),
2699 Default::default(),
2700 "Test Agent".into(),
2701 "Test",
2702 EditorMode::AutoHeight {
2703 min_lines: 1,
2704 max_lines: None,
2705 },
2706 window,
2707 cx,
2708 );
2709 // Enable embedded context so files are actually included
2710 editor.prompt_capabilities.replace(acp::PromptCapabilities {
2711 embedded_context: true,
2712 meta: None,
2713 ..Default::default()
2714 });
2715 editor
2716 })
2717 });
2718
2719 // Test large file mention
2720 // Get the absolute path using the project's worktree
2721 let large_file_abs_path = project.read_with(cx, |project, cx| {
2722 let worktree = project.worktrees(cx).next().unwrap();
2723 let worktree_root = worktree.read(cx).abs_path();
2724 worktree_root.join("large_file.rs")
2725 });
2726 let large_file_task = message_editor.update(cx, |editor, cx| {
2727 editor.confirm_mention_for_file(large_file_abs_path, cx)
2728 });
2729
2730 let large_file_mention = large_file_task.await.unwrap();
2731 match large_file_mention {
2732 Mention::Text { content, .. } => {
2733 // Should contain outline header for large files
2734 assert!(content.contains("File outline for"));
2735 assert!(content.contains("file too large to show full content"));
2736 // Should not contain the full repeated content
2737 assert!(!content.contains(&LINE.repeat(100)));
2738 }
2739 _ => panic!("Expected Text mention for large file"),
2740 }
2741
2742 // Test small file mention
2743 // Get the absolute path using the project's worktree
2744 let small_file_abs_path = project.read_with(cx, |project, cx| {
2745 let worktree = project.worktrees(cx).next().unwrap();
2746 let worktree_root = worktree.read(cx).abs_path();
2747 worktree_root.join("small_file.rs")
2748 });
2749 let small_file_task = message_editor.update(cx, |editor, cx| {
2750 editor.confirm_mention_for_file(small_file_abs_path, cx)
2751 });
2752
2753 let small_file_mention = small_file_task.await.unwrap();
2754 match small_file_mention {
2755 Mention::Text { content, .. } => {
2756 // Should contain the actual content
2757 assert_eq!(content, small_content);
2758 // Should not contain outline header
2759 assert!(!content.contains("File outline for"));
2760 }
2761 _ => panic!("Expected Text mention for small file"),
2762 }
2763 }
2764
2765 #[gpui::test]
2766 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2767 init_test(cx);
2768 cx.update(LanguageModelRegistry::test);
2769
2770 let fs = FakeFs::new(cx.executor());
2771 fs.insert_tree("/project", json!({"file": ""})).await;
2772 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2773
2774 let (workspace, cx) =
2775 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2776
2777 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2778 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2779
2780 // Create a thread metadata to insert as summary
2781 let thread_metadata = agent::DbThreadMetadata {
2782 id: acp::SessionId("thread-123".into()),
2783 title: "Previous Conversation".into(),
2784 updated_at: chrono::Utc::now(),
2785 };
2786
2787 let message_editor = cx.update(|window, cx| {
2788 cx.new(|cx| {
2789 let mut editor = MessageEditor::new(
2790 workspace.downgrade(),
2791 project.clone(),
2792 history_store.clone(),
2793 None,
2794 Default::default(),
2795 Default::default(),
2796 "Test Agent".into(),
2797 "Test",
2798 EditorMode::AutoHeight {
2799 min_lines: 1,
2800 max_lines: None,
2801 },
2802 window,
2803 cx,
2804 );
2805 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2806 editor
2807 })
2808 });
2809
2810 // Construct expected values for verification
2811 let expected_uri = MentionUri::Thread {
2812 id: thread_metadata.id.clone(),
2813 name: thread_metadata.title.to_string(),
2814 };
2815 let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2816
2817 message_editor.read_with(cx, |editor, cx| {
2818 let text = editor.text(cx);
2819
2820 assert!(
2821 text.contains(&expected_link),
2822 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2823 expected_link,
2824 text
2825 );
2826
2827 let mentions = editor.mentions();
2828 assert_eq!(
2829 mentions.len(),
2830 1,
2831 "Expected exactly one mention after inserting thread summary"
2832 );
2833
2834 assert!(
2835 mentions.contains(&expected_uri),
2836 "Expected mentions to contain the thread URI"
2837 );
2838 });
2839 }
2840
2841 #[gpui::test]
2842 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2843 init_test(cx);
2844
2845 let fs = FakeFs::new(cx.executor());
2846 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2847 .await;
2848 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2849
2850 let (workspace, cx) =
2851 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2852
2853 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2854 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2855
2856 let message_editor = cx.update(|window, cx| {
2857 cx.new(|cx| {
2858 MessageEditor::new(
2859 workspace.downgrade(),
2860 project.clone(),
2861 history_store.clone(),
2862 None,
2863 Default::default(),
2864 Default::default(),
2865 "Test Agent".into(),
2866 "Test",
2867 EditorMode::AutoHeight {
2868 min_lines: 1,
2869 max_lines: None,
2870 },
2871 window,
2872 cx,
2873 )
2874 })
2875 });
2876 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2877
2878 cx.run_until_parked();
2879
2880 editor.update_in(cx, |editor, window, cx| {
2881 editor.set_text(" \u{A0}してhello world ", window, cx);
2882 });
2883
2884 let (content, _) = message_editor
2885 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2886 .await
2887 .unwrap();
2888
2889 assert_eq!(
2890 content,
2891 vec![acp::ContentBlock::Text(acp::TextContent {
2892 text: "してhello world".into(),
2893 annotations: None,
2894 meta: None
2895 })]
2896 );
2897 }
2898
2899 #[gpui::test]
2900 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2901 init_test(cx);
2902
2903 let app_state = cx.update(AppState::test);
2904
2905 cx.update(|cx| {
2906 editor::init(cx);
2907 workspace::init(app_state.clone(), cx);
2908 });
2909
2910 app_state
2911 .fs
2912 .as_fake()
2913 .insert_tree(
2914 path!("/dir"),
2915 json!({
2916 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2917 }),
2918 )
2919 .await;
2920
2921 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2922 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2923 let workspace = window.root(cx).unwrap();
2924
2925 let worktree = project.update(cx, |project, cx| {
2926 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2927 assert_eq!(worktrees.len(), 1);
2928 worktrees.pop().unwrap()
2929 });
2930 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2931
2932 let mut cx = VisualTestContext::from_window(*window, cx);
2933
2934 // Open a regular editor with the created file, and select a portion of
2935 // the text that will be used for the selections that are meant to be
2936 // inserted in the agent panel.
2937 let editor = workspace
2938 .update_in(&mut cx, |workspace, window, cx| {
2939 workspace.open_path(
2940 ProjectPath {
2941 worktree_id,
2942 path: rel_path("test.txt").into(),
2943 },
2944 None,
2945 false,
2946 window,
2947 cx,
2948 )
2949 })
2950 .await
2951 .unwrap()
2952 .downcast::<Editor>()
2953 .unwrap();
2954
2955 editor.update_in(&mut cx, |editor, window, cx| {
2956 editor.change_selections(Default::default(), window, cx, |selections| {
2957 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2958 });
2959 });
2960
2961 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2962 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2963
2964 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2965 // to ensure we have a fixed viewport, so we can eventually actually
2966 // place the cursor outside of the visible area.
2967 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2968 let workspace_handle = cx.weak_entity();
2969 let message_editor = cx.new(|cx| {
2970 MessageEditor::new(
2971 workspace_handle,
2972 project.clone(),
2973 history_store.clone(),
2974 None,
2975 Default::default(),
2976 Default::default(),
2977 "Test Agent".into(),
2978 "Test",
2979 EditorMode::full(),
2980 window,
2981 cx,
2982 )
2983 });
2984 workspace.active_pane().update(cx, |pane, cx| {
2985 pane.add_item(
2986 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2987 true,
2988 true,
2989 None,
2990 window,
2991 cx,
2992 );
2993 });
2994
2995 message_editor
2996 });
2997
2998 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2999 message_editor.editor.update(cx, |editor, cx| {
3000 // Update the Agent Panel's Message Editor text to have 100
3001 // lines, ensuring that the cursor is set at line 90 and that we
3002 // then scroll all the way to the top, so the cursor's position
3003 // remains off screen.
3004 let mut lines = String::new();
3005 for _ in 1..=100 {
3006 lines.push_str(&"Another line in the agent panel's message editor\n");
3007 }
3008 editor.set_text(lines.as_str(), window, cx);
3009 editor.change_selections(Default::default(), window, cx, |selections| {
3010 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3011 });
3012 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3013 });
3014 });
3015
3016 cx.run_until_parked();
3017
3018 // Before proceeding, let's assert that the cursor is indeed off screen,
3019 // otherwise the rest of the test doesn't make sense.
3020 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3021 message_editor.editor.update(cx, |editor, cx| {
3022 let snapshot = editor.snapshot(window, cx);
3023 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3024 let scroll_top = snapshot.scroll_position().y as u32;
3025 let visible_lines = editor.visible_line_count().unwrap() as u32;
3026 let visible_range = scroll_top..(scroll_top + visible_lines);
3027
3028 assert!(!visible_range.contains(&cursor_row));
3029 })
3030 });
3031
3032 // Now let's insert the selection in the Agent Panel's editor and
3033 // confirm that, after the insertion, the cursor is now in the visible
3034 // range.
3035 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3036 message_editor.insert_selections(window, cx);
3037 });
3038
3039 cx.run_until_parked();
3040
3041 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3042 message_editor.editor.update(cx, |editor, cx| {
3043 let snapshot = editor.snapshot(window, cx);
3044 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3045 let scroll_top = snapshot.scroll_position().y as u32;
3046 let visible_lines = editor.visible_line_count().unwrap() as u32;
3047 let visible_range = scroll_top..(scroll_top + visible_lines);
3048
3049 assert!(visible_range.contains(&cursor_row));
3050 })
3051 });
3052 }
3053}