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