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