1use crate::DEFAULT_THREAD_TITLE;
2use crate::SendImmediately;
3use crate::{
4 ChatWithFollow,
5 completion_provider::{
6 PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
7 PromptContextType, SlashCommandCompletion,
8 },
9 mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
10};
11use acp_thread::MentionUri;
12use agent::ThreadStore;
13use agent_client_protocol as acp;
14use anyhow::{Result, anyhow};
15use editor::{
16 Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
17 EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
18 actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll,
19};
20use futures::{FutureExt as _, future::join_all};
21use gpui::{
22 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
23 KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
24};
25use language::{Buffer, language_settings::InlayHintKind};
26use parking_lot::RwLock;
27use project::AgentId;
28use project::{
29 CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
30};
31use prompt_store::PromptStore;
32use rope::Point;
33use settings::Settings;
34use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc};
35use theme_settings::ThemeSettings;
36use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*};
37use util::paths::PathStyle;
38use util::{ResultExt, debug_panic};
39use workspace::{CollaboratorId, Workspace};
40use zed_actions::agent::{Chat, PasteRaw};
41
42#[derive(Default)]
43pub struct SessionCapabilities {
44 prompt_capabilities: acp::PromptCapabilities,
45 available_commands: Vec<acp::AvailableCommand>,
46}
47
48impl SessionCapabilities {
49 pub fn new(
50 prompt_capabilities: acp::PromptCapabilities,
51 available_commands: Vec<acp::AvailableCommand>,
52 ) -> Self {
53 Self {
54 prompt_capabilities,
55 available_commands,
56 }
57 }
58
59 pub fn supports_images(&self) -> bool {
60 self.prompt_capabilities.image
61 }
62
63 pub fn supports_embedded_context(&self) -> bool {
64 self.prompt_capabilities.embedded_context
65 }
66
67 pub fn available_commands(&self) -> &[acp::AvailableCommand] {
68 &self.available_commands
69 }
70
71 fn supported_modes(&self, has_thread_store: bool) -> Vec<PromptContextType> {
72 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
73 if self.prompt_capabilities.embedded_context {
74 if has_thread_store {
75 supported.push(PromptContextType::Thread);
76 }
77 supported.extend(&[
78 PromptContextType::Diagnostics,
79 PromptContextType::Fetch,
80 PromptContextType::Rules,
81 PromptContextType::BranchDiff,
82 ]);
83 }
84 supported
85 }
86
87 pub fn completion_commands(&self) -> Vec<crate::completion_provider::AvailableCommand> {
88 self.available_commands
89 .iter()
90 .map(|cmd| crate::completion_provider::AvailableCommand {
91 name: cmd.name.clone().into(),
92 description: cmd.description.clone().into(),
93 requires_argument: cmd.input.is_some(),
94 })
95 .collect()
96 }
97
98 pub fn set_prompt_capabilities(&mut self, prompt_capabilities: acp::PromptCapabilities) {
99 self.prompt_capabilities = prompt_capabilities;
100 }
101
102 pub fn set_available_commands(&mut self, available_commands: Vec<acp::AvailableCommand>) {
103 self.available_commands = available_commands;
104 }
105}
106
107pub type SharedSessionCapabilities = Arc<RwLock<SessionCapabilities>>;
108
109struct MessageEditorCompletionDelegate {
110 session_capabilities: SharedSessionCapabilities,
111 has_thread_store: bool,
112 message_editor: WeakEntity<MessageEditor>,
113}
114
115impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate {
116 fn supports_images(&self, _cx: &App) -> bool {
117 self.session_capabilities.read().supports_images()
118 }
119
120 fn supported_modes(&self, _cx: &App) -> Vec<PromptContextType> {
121 self.session_capabilities
122 .read()
123 .supported_modes(self.has_thread_store)
124 }
125
126 fn available_commands(&self, _cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
127 self.session_capabilities.read().completion_commands()
128 }
129
130 fn confirm_command(&self, cx: &mut App) {
131 let _ = self.message_editor.update(cx, |this, cx| this.send(cx));
132 }
133}
134
135pub struct MessageEditor {
136 mention_set: Entity<MentionSet>,
137 editor: Entity<Editor>,
138 workspace: WeakEntity<Workspace>,
139 session_capabilities: SharedSessionCapabilities,
140 agent_id: AgentId,
141 thread_store: Option<Entity<ThreadStore>>,
142 _subscriptions: Vec<Subscription>,
143 _parse_slash_command_task: Task<()>,
144}
145
146#[derive(Clone, Debug)]
147pub enum MessageEditorEvent {
148 Send,
149 SendImmediately,
150 Cancel,
151 Focus,
152 LostFocus,
153 InputAttempted {
154 text: Arc<str>,
155 cursor_offset: usize,
156 },
157}
158
159impl EventEmitter<MessageEditorEvent> for MessageEditor {}
160
161const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
162
163enum MentionInsertPosition {
164 AtCursor,
165 EndOfBuffer,
166}
167
168fn insert_mention_for_project_path(
169 project_path: &ProjectPath,
170 position: MentionInsertPosition,
171 editor: &Entity<Editor>,
172 mention_set: &Entity<MentionSet>,
173 project: &Entity<Project>,
174 workspace: &Entity<Workspace>,
175 supports_images: bool,
176 window: &mut Window,
177 cx: &mut App,
178) -> Option<Task<()>> {
179 let (file_name, mention_uri) = {
180 let project = project.read(cx);
181 let path_style = project.path_style(cx);
182 let entry = project.entry_for_path(project_path, cx)?;
183 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
184 let abs_path = worktree.read(cx).absolutize(&project_path.path);
185 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
186 &project_path.path,
187 worktree.read(cx).root_name(),
188 path_style,
189 );
190 let mention_uri = if entry.is_dir() {
191 MentionUri::Directory { abs_path }
192 } else {
193 MentionUri::File { abs_path }
194 };
195 (file_name, mention_uri)
196 };
197
198 let mention_text = mention_uri.as_link().to_string();
199 let content_len = mention_text.len();
200
201 let text_anchor = match position {
202 MentionInsertPosition::AtCursor => editor.update(cx, |editor, cx| {
203 let buffer = editor.buffer().read(cx);
204 let snapshot = buffer.snapshot(cx);
205 let buffer_snapshot = snapshot.as_singleton()?;
206 let text_anchor = snapshot
207 .anchor_to_buffer_anchor(editor.selections.newest_anchor().start)?
208 .0
209 .bias_left(&buffer_snapshot);
210
211 editor.insert(&mention_text, window, cx);
212 editor.insert(" ", window, cx);
213
214 Some(text_anchor)
215 }),
216 MentionInsertPosition::EndOfBuffer => {
217 let multi_buffer = editor.read(cx).buffer().clone();
218 let buffer = multi_buffer.read(cx).as_singleton()?;
219 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
220 let new_text = format!("{mention_text} ");
221 editor.update(cx, |editor, cx| {
222 editor.edit(
223 [(
224 multi_buffer::Anchor::Max..multi_buffer::Anchor::Max,
225 new_text,
226 )],
227 cx,
228 );
229 });
230 Some(anchor)
231 }
232 }?;
233
234 Some(mention_set.update(cx, |mention_set, cx| {
235 mention_set.confirm_mention_completion(
236 file_name,
237 text_anchor,
238 content_len,
239 mention_uri,
240 supports_images,
241 editor.clone(),
242 workspace,
243 window,
244 cx,
245 )
246 }))
247}
248
249enum ResolvedPastedContextItem {
250 Image(gpui::Image, gpui::SharedString),
251 ProjectPath(ProjectPath),
252}
253
254async fn resolve_pasted_context_items(
255 project: Entity<Project>,
256 project_is_local: bool,
257 supports_images: bool,
258 entries: Vec<ClipboardEntry>,
259 cx: &mut gpui::AsyncWindowContext,
260) -> (Vec<ResolvedPastedContextItem>, Vec<Entity<Worktree>>) {
261 let mut items = Vec::new();
262 let mut added_worktrees = Vec::new();
263 let default_image_name: SharedString = "Image".into();
264
265 for entry in entries {
266 match entry {
267 ClipboardEntry::String(_) => {}
268 ClipboardEntry::Image(image) => {
269 if supports_images {
270 items.push(ResolvedPastedContextItem::Image(
271 image,
272 default_image_name.clone(),
273 ));
274 }
275 }
276 ClipboardEntry::ExternalPaths(paths) => {
277 for path in paths.paths().iter() {
278 if let Some((image, name)) = cx
279 .background_spawn({
280 let path = path.clone();
281 let default_image_name = default_image_name.clone();
282 async move {
283 crate::mention_set::load_external_image_from_path(
284 &path,
285 &default_image_name,
286 )
287 }
288 })
289 .await
290 {
291 if supports_images {
292 items.push(ResolvedPastedContextItem::Image(image, name));
293 }
294 continue;
295 }
296
297 if !project_is_local {
298 continue;
299 }
300
301 let path = path.clone();
302 let Ok(resolve_task) = cx.update({
303 let project = project.clone();
304 move |_, cx| Workspace::project_path_for_path(project, &path, false, cx)
305 }) else {
306 continue;
307 };
308
309 if let Some((worktree, project_path)) = resolve_task.await.log_err() {
310 added_worktrees.push(worktree);
311 items.push(ResolvedPastedContextItem::ProjectPath(project_path));
312 }
313 }
314 }
315 }
316 }
317
318 (items, added_worktrees)
319}
320
321fn insert_project_path_as_context(
322 project_path: ProjectPath,
323 editor: Entity<Editor>,
324 mention_set: Entity<MentionSet>,
325 workspace: WeakEntity<Workspace>,
326 supports_images: bool,
327 cx: &mut gpui::AsyncWindowContext,
328) -> Option<Task<()>> {
329 let workspace = workspace.upgrade()?;
330
331 cx.update(move |window, cx| {
332 let project = workspace.read(cx).project().clone();
333 insert_mention_for_project_path(
334 &project_path,
335 MentionInsertPosition::AtCursor,
336 &editor,
337 &mention_set,
338 &project,
339 &workspace,
340 supports_images,
341 window,
342 cx,
343 )
344 })
345 .ok()
346 .flatten()
347}
348
349async fn insert_resolved_pasted_context_items(
350 items: Vec<ResolvedPastedContextItem>,
351 added_worktrees: Vec<Entity<Worktree>>,
352 editor: Entity<Editor>,
353 mention_set: Entity<MentionSet>,
354 workspace: WeakEntity<Workspace>,
355 supports_images: bool,
356 cx: &mut gpui::AsyncWindowContext,
357) {
358 let mut path_mention_tasks = Vec::new();
359
360 for item in items {
361 match item {
362 ResolvedPastedContextItem::Image(image, name) => {
363 crate::mention_set::insert_images_as_context(
364 vec![(image, name)],
365 editor.clone(),
366 mention_set.clone(),
367 workspace.clone(),
368 cx,
369 )
370 .await;
371 }
372 ResolvedPastedContextItem::ProjectPath(project_path) => {
373 if let Some(task) = insert_project_path_as_context(
374 project_path,
375 editor.clone(),
376 mention_set.clone(),
377 workspace.clone(),
378 supports_images,
379 cx,
380 ) {
381 path_mention_tasks.push(task);
382 }
383 }
384 }
385 }
386
387 join_all(path_mention_tasks).await;
388 drop(added_worktrees);
389}
390
391impl MessageEditor {
392 pub fn new(
393 workspace: WeakEntity<Workspace>,
394 project: WeakEntity<Project>,
395 thread_store: Option<Entity<ThreadStore>>,
396 prompt_store: Option<Entity<PromptStore>>,
397 session_capabilities: SharedSessionCapabilities,
398 agent_id: AgentId,
399 placeholder: &str,
400 mode: EditorMode,
401 window: &mut Window,
402 cx: &mut Context<Self>,
403 ) -> Self {
404 let language_registry = project
405 .upgrade()
406 .map(|project| project.read(cx).languages().clone());
407
408 let editor = cx.new(|cx| {
409 let buffer = cx.new(|cx| {
410 let buffer = Buffer::local("", cx);
411 if let Some(language_registry) = language_registry.as_ref() {
412 buffer.set_language_registry(language_registry.clone());
413 }
414 buffer
415 });
416 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
417
418 let mut editor = Editor::new(mode, buffer, None, window, cx);
419 editor.set_placeholder_text(placeholder, window, cx);
420 editor.set_show_indent_guides(false, cx);
421 editor.set_show_completions_on_input(Some(true));
422 editor.set_soft_wrap();
423 editor.disable_mouse_wheel_zoom();
424 editor.set_use_modal_editing(true);
425 editor.set_context_menu_options(ContextMenuOptions {
426 min_entries_visible: 12,
427 max_entries_visible: 12,
428 placement: None,
429 });
430 editor.register_addon(MessageEditorAddon::new());
431
432 editor.set_custom_context_menu(|editor, _point, window, cx| {
433 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
434
435 Some(ContextMenu::build(window, cx, |menu, _, _| {
436 menu.action("Cut", Box::new(editor::actions::Cut))
437 .action_disabled_when(
438 !has_selection,
439 "Copy",
440 Box::new(editor::actions::Copy),
441 )
442 .action("Paste", Box::new(editor::actions::Paste))
443 .action("Paste as Plain Text", Box::new(PasteRaw))
444 }))
445 });
446
447 editor
448 });
449 let mention_set =
450 cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
451 let completion_provider = Rc::new(PromptCompletionProvider::new(
452 MessageEditorCompletionDelegate {
453 session_capabilities: session_capabilities.clone(),
454 has_thread_store: thread_store.is_some(),
455 message_editor: cx.weak_entity(),
456 },
457 editor.downgrade(),
458 mention_set.clone(),
459 prompt_store.clone(),
460 workspace.clone(),
461 ));
462 editor.update(cx, |editor, _cx| {
463 editor.set_completion_provider(Some(completion_provider.clone()))
464 });
465
466 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
467 cx.emit(MessageEditorEvent::Focus)
468 })
469 .detach();
470 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
471 cx.emit(MessageEditorEvent::LostFocus)
472 })
473 .detach();
474
475 let mut has_hint = false;
476 let mut subscriptions = Vec::new();
477
478 subscriptions.push(cx.subscribe_in(&editor, window, {
479 move |this, editor, event, window, cx| {
480 let input_attempted_text = match event {
481 EditorEvent::InputHandled { text, .. } => Some(text),
482 EditorEvent::InputIgnored { text } => Some(text),
483 _ => None,
484 };
485 if let Some(text) = input_attempted_text
486 && editor.read(cx).read_only(cx)
487 && !text.is_empty()
488 {
489 let editor = editor.read(cx);
490 let cursor_anchor = editor.selections.newest_anchor().head();
491 let cursor_offset = cursor_anchor
492 .to_offset(&editor.buffer().read(cx).snapshot(cx))
493 .0;
494 cx.emit(MessageEditorEvent::InputAttempted {
495 text: text.clone(),
496 cursor_offset,
497 });
498 }
499
500 if let EditorEvent::Edited { .. } = event
501 && !editor.read(cx).read_only(cx)
502 {
503 editor.update(cx, |editor, cx| {
504 let snapshot = editor.snapshot(window, cx);
505 this.mention_set
506 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
507
508 let new_hints = this
509 .command_hint(snapshot.buffer())
510 .into_iter()
511 .collect::<Vec<_>>();
512 let has_new_hint = !new_hints.is_empty();
513 editor.splice_inlays(
514 if has_hint {
515 &[COMMAND_HINT_INLAY_ID]
516 } else {
517 &[]
518 },
519 new_hints,
520 cx,
521 );
522 has_hint = has_new_hint;
523 });
524 cx.notify();
525 }
526 }
527 }));
528
529 if let Some(language_registry) = language_registry {
530 let editor = editor.clone();
531 cx.spawn(async move |_, cx| {
532 let markdown = language_registry.language_for_name("Markdown").await?;
533 editor.update(cx, |editor, cx| {
534 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
535 buffer.update(cx, |buffer, cx| {
536 buffer.set_language(Some(markdown), cx);
537 });
538 }
539 });
540 anyhow::Ok(())
541 })
542 .detach_and_log_err(cx);
543 }
544
545 Self {
546 editor,
547 mention_set,
548 workspace,
549 session_capabilities,
550 agent_id,
551 thread_store,
552 _subscriptions: subscriptions,
553 _parse_slash_command_task: Task::ready(()),
554 }
555 }
556
557 pub fn set_session_capabilities(
558 &mut self,
559 session_capabilities: SharedSessionCapabilities,
560 _cx: &mut Context<Self>,
561 ) {
562 self.session_capabilities = session_capabilities;
563 }
564
565 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
566 let session_capabilities = self.session_capabilities.read();
567 let available_commands = session_capabilities.available_commands();
568 if available_commands.is_empty() {
569 return None;
570 }
571
572 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
573 if parsed_command.argument.is_some() {
574 return None;
575 }
576
577 let command_name = parsed_command.command?;
578 let available_command = available_commands
579 .iter()
580 .find(|command| command.name == command_name)?;
581
582 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
583 mut hint,
584 ..
585 }) = available_command.input.clone()?
586 else {
587 return None;
588 };
589
590 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
591 if hint_pos > snapshot.len() {
592 hint_pos = snapshot.len();
593 hint.insert(0, ' ');
594 }
595
596 let hint_pos = snapshot.anchor_after(hint_pos);
597
598 Some(Inlay::hint(
599 COMMAND_HINT_INLAY_ID,
600 hint_pos,
601 &InlayHint {
602 position: snapshot.anchor_to_buffer_anchor(hint_pos)?.0,
603 label: InlayHintLabel::String(hint),
604 kind: Some(InlayHintKind::Parameter),
605 padding_left: false,
606 padding_right: false,
607 tooltip: None,
608 resolve_state: project::ResolveState::Resolved,
609 },
610 ))
611 }
612
613 pub fn insert_thread_summary(
614 &mut self,
615 session_id: acp::SessionId,
616 title: Option<SharedString>,
617 window: &mut Window,
618 cx: &mut Context<Self>,
619 ) {
620 if self.thread_store.is_none() {
621 return;
622 }
623 let Some(workspace) = self.workspace.upgrade() else {
624 return;
625 };
626 let thread_title = title
627 .filter(|title| !title.is_empty())
628 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
629 let uri = MentionUri::Thread {
630 id: session_id,
631 name: thread_title.to_string(),
632 };
633 let content = format!("{}\n", uri.as_link());
634
635 let content_len = content.len() - 1;
636
637 let start = self.editor.update(cx, |editor, cx| {
638 editor.set_text(content, window, cx);
639 let snapshot = editor.buffer().read(cx).snapshot(cx);
640 snapshot
641 .anchor_to_buffer_anchor(snapshot.anchor_before(Point::zero()))
642 .unwrap()
643 .0
644 });
645
646 let supports_images = self.session_capabilities.read().supports_images();
647
648 self.mention_set
649 .update(cx, |mention_set, cx| {
650 mention_set.confirm_mention_completion(
651 thread_title,
652 start,
653 content_len,
654 uri,
655 supports_images,
656 self.editor.clone(),
657 &workspace,
658 window,
659 cx,
660 )
661 })
662 .detach();
663 }
664
665 #[cfg(test)]
666 pub(crate) fn editor(&self) -> &Entity<Editor> {
667 &self.editor
668 }
669
670 pub fn is_empty(&self, cx: &App) -> bool {
671 self.editor.read(cx).is_empty(cx)
672 }
673
674 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
675 self.editor
676 .read(cx)
677 .context_menu()
678 .borrow()
679 .as_ref()
680 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
681 }
682
683 #[cfg(test)]
684 pub fn mention_set(&self) -> &Entity<MentionSet> {
685 &self.mention_set
686 }
687
688 fn validate_slash_commands(
689 text: &str,
690 available_commands: &[acp::AvailableCommand],
691 agent_id: &AgentId,
692 ) -> Result<()> {
693 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
694 if let Some(command_name) = parsed_command.command {
695 // Check if this command is in the list of available commands from the server
696 let is_supported = available_commands
697 .iter()
698 .any(|cmd| cmd.name == command_name);
699
700 if !is_supported {
701 return Err(anyhow!(
702 "The /{} command is not supported by {}.\n\nAvailable commands: {}",
703 command_name,
704 agent_id,
705 if available_commands.is_empty() {
706 "none".to_string()
707 } else {
708 available_commands
709 .iter()
710 .map(|cmd| format!("/{}", cmd.name))
711 .collect::<Vec<_>>()
712 .join(", ")
713 }
714 ));
715 }
716 }
717 }
718 Ok(())
719 }
720
721 pub fn contents(
722 &self,
723 full_mention_content: bool,
724 cx: &mut Context<Self>,
725 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
726 let text = self.editor.read(cx).text(cx);
727 let available_commands = self
728 .session_capabilities
729 .read()
730 .available_commands()
731 .to_vec();
732 let agent_id = self.agent_id.clone();
733 let build_task = self.build_content_blocks(full_mention_content, cx);
734
735 cx.spawn(async move |_, _cx| {
736 Self::validate_slash_commands(&text, &available_commands, &agent_id)?;
737 build_task.await
738 })
739 }
740
741 pub fn draft_contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
742 let build_task = self.build_content_blocks(false, cx);
743 cx.spawn(async move |_, _cx| {
744 let (blocks, _tracked_buffers) = build_task.await?;
745 Ok(blocks)
746 })
747 }
748
749 fn build_content_blocks(
750 &self,
751 full_mention_content: bool,
752 cx: &mut Context<Self>,
753 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
754 let contents = self
755 .mention_set
756 .update(cx, |store, cx| store.contents(full_mention_content, cx));
757 let editor = self.editor.clone();
758 let supports_embedded_context =
759 self.session_capabilities.read().supports_embedded_context();
760
761 cx.spawn(async move |_, cx| {
762 let contents = contents.await?;
763 let mut all_tracked_buffers = Vec::new();
764
765 let result = editor.update(cx, |editor, cx| {
766 let text = editor.text(cx);
767 let (mut ix, _) = text
768 .char_indices()
769 .find(|(_, c)| !c.is_whitespace())
770 .unwrap_or((0, '\0'));
771 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
772 editor.display_map.update(cx, |map, cx| {
773 let snapshot = map.snapshot(cx);
774 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
775 let Some((uri, mention)) = contents.get(&crease_id) else {
776 continue;
777 };
778
779 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
780 if crease_range.start.0 > ix {
781 let chunk = text[ix..crease_range.start.0].into();
782 chunks.push(chunk);
783 }
784 let chunk = match mention {
785 Mention::Text {
786 content,
787 tracked_buffers,
788 } => {
789 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
790 if supports_embedded_context {
791 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
792 acp::EmbeddedResourceResource::TextResourceContents(
793 acp::TextResourceContents::new(
794 content.clone(),
795 uri.to_uri().to_string(),
796 ),
797 ),
798 ))
799 } else {
800 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
801 uri.name(),
802 uri.to_uri().to_string(),
803 ))
804 }
805 }
806 Mention::Image(mention_image) => acp::ContentBlock::Image(
807 acp::ImageContent::new(
808 mention_image.data.clone(),
809 mention_image.format.mime_type(),
810 )
811 .uri(match uri {
812 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
813 MentionUri::PastedImage { .. } => {
814 Some(uri.to_uri().to_string())
815 }
816 other => {
817 debug_panic!(
818 "unexpected mention uri for image: {:?}",
819 other
820 );
821 None
822 }
823 }),
824 ),
825 Mention::Link => acp::ContentBlock::ResourceLink(
826 acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
827 ),
828 };
829 chunks.push(chunk);
830 ix = crease_range.end.0;
831 }
832
833 if ix < text.len() {
834 let last_chunk = text[ix..].trim_end().to_owned();
835 if !last_chunk.is_empty() {
836 chunks.push(last_chunk.into());
837 }
838 }
839 });
840 anyhow::Ok((chunks, all_tracked_buffers))
841 })?;
842 Ok(result)
843 })
844 }
845
846 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
847 self.editor.update(cx, |editor, cx| {
848 editor.clear(window, cx);
849 editor.remove_creases(
850 self.mention_set.update(cx, |mention_set, _cx| {
851 mention_set
852 .clear()
853 .map(|(crease_id, _)| crease_id)
854 .collect::<Vec<_>>()
855 }),
856 cx,
857 )
858 });
859 }
860
861 pub fn send(&mut self, cx: &mut Context<Self>) {
862 if !self.is_empty(cx) {
863 self.editor.update(cx, |editor, cx| {
864 editor.clear_inlay_hints(cx);
865 });
866 }
867 cx.emit(MessageEditorEvent::Send)
868 }
869
870 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
871 self.insert_context_prefix("@", window, cx);
872 }
873
874 pub fn insert_context_type(
875 &mut self,
876 context_keyword: &str,
877 window: &mut Window,
878 cx: &mut Context<Self>,
879 ) {
880 let prefix = format!("@{}", context_keyword);
881 self.insert_context_prefix(&prefix, window, cx);
882 }
883
884 fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
885 let editor = self.editor.clone();
886 let prefix = prefix.to_string();
887
888 cx.spawn_in(window, async move |_, cx| {
889 editor
890 .update_in(cx, |editor, window, cx| {
891 let menu_is_open =
892 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
893 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
894 });
895
896 let has_prefix = {
897 let snapshot = editor.display_snapshot(cx);
898 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
899 let offset = cursor.to_offset(&snapshot);
900 let buffer_snapshot = snapshot.buffer_snapshot();
901 let prefix_char_count = prefix.chars().count();
902 buffer_snapshot
903 .reversed_chars_at(offset)
904 .take(prefix_char_count)
905 .eq(prefix.chars().rev())
906 };
907
908 if menu_is_open && has_prefix {
909 return;
910 }
911
912 editor.insert(&prefix, window, cx);
913 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
914 })
915 .log_err();
916 })
917 .detach();
918 }
919
920 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
921 self.send(cx);
922 }
923
924 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
925 if self.is_empty(cx) {
926 return;
927 }
928
929 self.editor.update(cx, |editor, cx| {
930 editor.clear_inlay_hints(cx);
931 });
932
933 cx.emit(MessageEditorEvent::SendImmediately)
934 }
935
936 fn chat_with_follow(
937 &mut self,
938 _: &ChatWithFollow,
939 window: &mut Window,
940 cx: &mut Context<Self>,
941 ) {
942 self.workspace
943 .update(cx, |this, cx| {
944 this.follow(CollaboratorId::Agent, window, cx)
945 })
946 .log_err();
947
948 self.send(cx);
949 }
950
951 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
952 cx.emit(MessageEditorEvent::Cancel)
953 }
954
955 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
956 let Some(workspace) = self.workspace.upgrade() else {
957 return;
958 };
959 let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| {
960 item.entries().iter().find_map(|entry| match entry {
961 ClipboardEntry::String(text) => {
962 text.metadata_json::<Vec<editor::ClipboardSelection>>()
963 }
964 _ => None,
965 })
966 });
967
968 // Insert creases for pasted clipboard selections that:
969 // 1. Contain exactly one selection
970 // 2. Have an associated file path
971 // 3. Span multiple lines (not single-line selections)
972 // 4. Belong to a file that exists in the current project
973 let should_insert_creases = util::maybe!({
974 let selections = editor_clipboard_selections.as_ref()?;
975 if selections.len() > 1 {
976 return Some(false);
977 }
978 let selection = selections.first()?;
979 let file_path = selection.file_path.as_ref()?;
980 let line_range = selection.line_range.as_ref()?;
981
982 if line_range.start() == line_range.end() {
983 return Some(false);
984 }
985
986 Some(
987 workspace
988 .read(cx)
989 .project()
990 .read(cx)
991 .project_path_for_absolute_path(file_path, cx)
992 .is_some(),
993 )
994 })
995 .unwrap_or(false);
996
997 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
998 cx.stop_propagation();
999 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
1000 let (insertion_target, _) = snapshot
1001 .anchor_to_buffer_anchor(self.editor.read(cx).selections.newest_anchor().start)
1002 .unwrap();
1003
1004 let project = workspace.read(cx).project().clone();
1005 for selection in selections {
1006 if let (Some(file_path), Some(line_range)) =
1007 (selection.file_path, selection.line_range)
1008 {
1009 let crease_text =
1010 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
1011
1012 let mention_uri = MentionUri::Selection {
1013 abs_path: Some(file_path.clone()),
1014 line_range: line_range.clone(),
1015 };
1016
1017 let mention_text = mention_uri.as_link().to_string();
1018 let (text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
1019 let buffer = editor.buffer().read(cx);
1020 let snapshot = buffer.snapshot(cx);
1021 let buffer_snapshot = snapshot.as_singleton().unwrap();
1022 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
1023
1024 editor.insert(&mention_text, window, cx);
1025 editor.insert(" ", window, cx);
1026
1027 (text_anchor, mention_text.len())
1028 });
1029
1030 let Some((crease_id, tx)) = insert_crease_for_mention(
1031 text_anchor,
1032 content_len,
1033 crease_text.into(),
1034 mention_uri.icon_path(cx),
1035 mention_uri.tooltip_text(),
1036 Some(mention_uri.clone()),
1037 Some(self.workspace.clone()),
1038 None,
1039 self.editor.clone(),
1040 window,
1041 cx,
1042 ) else {
1043 continue;
1044 };
1045 drop(tx);
1046
1047 let mention_task = cx
1048 .spawn({
1049 let project = project.clone();
1050 async move |_, cx| {
1051 let project_path = project
1052 .update(cx, |project, cx| {
1053 project.project_path_for_absolute_path(&file_path, cx)
1054 })
1055 .ok_or_else(|| "project path not found".to_string())?;
1056
1057 let buffer = project
1058 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1059 .await
1060 .map_err(|e| e.to_string())?;
1061
1062 Ok(buffer.update(cx, |buffer, cx| {
1063 let start =
1064 Point::new(*line_range.start(), 0).min(buffer.max_point());
1065 let end = Point::new(*line_range.end() + 1, 0)
1066 .min(buffer.max_point());
1067 let content = buffer.text_for_range(start..end).collect();
1068 Mention::Text {
1069 content,
1070 tracked_buffers: vec![cx.entity()],
1071 }
1072 }))
1073 }
1074 })
1075 .shared();
1076
1077 self.mention_set.update(cx, |mention_set, _cx| {
1078 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
1079 });
1080 }
1081 }
1082 return;
1083 }
1084 // Handle text paste with potential markdown mention links before
1085 // clipboard context entries so markdown text still pastes as text.
1086 if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| {
1087 item.entries().iter().find_map(|entry| match entry {
1088 ClipboardEntry::String(text) => Some(text.text().to_string()),
1089 _ => None,
1090 })
1091 }) {
1092 if clipboard_text.contains("[@") {
1093 cx.stop_propagation();
1094 let selections_before = self.editor.update(cx, |editor, cx| {
1095 let snapshot = editor.buffer().read(cx).snapshot(cx);
1096 editor
1097 .selections
1098 .disjoint_anchors()
1099 .iter()
1100 .map(|selection| {
1101 (
1102 selection.start.bias_left(&snapshot),
1103 selection.end.bias_right(&snapshot),
1104 )
1105 })
1106 .collect::<Vec<_>>()
1107 });
1108
1109 self.editor.update(cx, |editor, cx| {
1110 editor.insert(&clipboard_text, window, cx);
1111 });
1112
1113 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
1114 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1115
1116 let mut all_mentions = Vec::new();
1117 for (start_anchor, end_anchor) in selections_before {
1118 let start_offset = start_anchor.to_offset(&snapshot);
1119 let end_offset = end_anchor.to_offset(&snapshot);
1120
1121 // Get the actual inserted text from the buffer (may differ due to auto-indent)
1122 let inserted_text: String =
1123 snapshot.text_for_range(start_offset..end_offset).collect();
1124
1125 let parsed_mentions = parse_mention_links(&inserted_text, path_style);
1126 for (range, mention_uri) in parsed_mentions {
1127 let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start);
1128 let anchor = snapshot.anchor_before(mention_start_offset);
1129 let content_len = range.end - range.start;
1130 all_mentions.push((anchor, content_len, mention_uri));
1131 }
1132 }
1133
1134 if !all_mentions.is_empty() {
1135 let supports_images = self.session_capabilities.read().supports_images();
1136 let http_client = workspace.read(cx).client().http_client();
1137
1138 for (anchor, content_len, mention_uri) in all_mentions {
1139 let Some((crease_id, tx)) = insert_crease_for_mention(
1140 snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
1141 content_len,
1142 mention_uri.name().into(),
1143 mention_uri.icon_path(cx),
1144 mention_uri.tooltip_text(),
1145 Some(mention_uri.clone()),
1146 Some(self.workspace.clone()),
1147 None,
1148 self.editor.clone(),
1149 window,
1150 cx,
1151 ) else {
1152 continue;
1153 };
1154
1155 // Create the confirmation task based on the mention URI type.
1156 // This properly loads file content, fetches URLs, etc.
1157 let task = self.mention_set.update(cx, |mention_set, cx| {
1158 mention_set.confirm_mention_for_uri(
1159 mention_uri.clone(),
1160 supports_images,
1161 http_client.clone(),
1162 cx,
1163 )
1164 });
1165 let task = cx
1166 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
1167 .shared();
1168
1169 self.mention_set.update(cx, |mention_set, _cx| {
1170 mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
1171 });
1172
1173 // Drop the tx after inserting to signal the crease is ready
1174 drop(tx);
1175 }
1176 return;
1177 }
1178 }
1179 }
1180
1181 if self.handle_pasted_context(window, cx) {
1182 return;
1183 }
1184
1185 // Fall through to default editor paste
1186 cx.propagate();
1187 }
1188
1189 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
1190 let editor = self.editor.clone();
1191 window.defer(cx, move |window, cx| {
1192 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
1193 });
1194 }
1195
1196 fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1197 let Some(clipboard) = cx.read_from_clipboard() else {
1198 return false;
1199 };
1200
1201 if matches!(
1202 clipboard.entries().first(),
1203 Some(ClipboardEntry::String(_)) | None
1204 ) {
1205 return false;
1206 }
1207
1208 let Some(workspace) = self.workspace.upgrade() else {
1209 return false;
1210 };
1211 let project = workspace.read(cx).project().clone();
1212 let project_is_local = project.read(cx).is_local();
1213 let supports_images = self.session_capabilities.read().supports_images();
1214 if !project_is_local && !supports_images {
1215 return false;
1216 }
1217 let editor = self.editor.clone();
1218 let mention_set = self.mention_set.clone();
1219 let workspace = self.workspace.clone();
1220 let entries = clipboard.into_entries().collect::<Vec<_>>();
1221
1222 cx.stop_propagation();
1223
1224 window
1225 .spawn(cx, async move |mut cx| {
1226 let (items, added_worktrees) = resolve_pasted_context_items(
1227 project,
1228 project_is_local,
1229 supports_images,
1230 entries,
1231 &mut cx,
1232 )
1233 .await;
1234 insert_resolved_pasted_context_items(
1235 items,
1236 added_worktrees,
1237 editor,
1238 mention_set,
1239 workspace,
1240 supports_images,
1241 &mut cx,
1242 )
1243 .await;
1244 Ok::<(), anyhow::Error>(())
1245 })
1246 .detach_and_log_err(cx);
1247
1248 true
1249 }
1250
1251 pub fn insert_dragged_files(
1252 &mut self,
1253 paths: Vec<project::ProjectPath>,
1254 added_worktrees: Vec<Entity<Worktree>>,
1255 window: &mut Window,
1256 cx: &mut Context<Self>,
1257 ) {
1258 let Some(workspace) = self.workspace.upgrade() else {
1259 return;
1260 };
1261 let project = workspace.read(cx).project().clone();
1262 let supports_images = self.session_capabilities.read().supports_images();
1263 let mut tasks = Vec::new();
1264 for path in paths {
1265 if let Some(task) = insert_mention_for_project_path(
1266 &path,
1267 MentionInsertPosition::EndOfBuffer,
1268 &self.editor,
1269 &self.mention_set,
1270 &project,
1271 &workspace,
1272 supports_images,
1273 window,
1274 cx,
1275 ) {
1276 tasks.push(task);
1277 }
1278 }
1279 cx.spawn(async move |_, _| {
1280 join_all(tasks).await;
1281 drop(added_worktrees);
1282 })
1283 .detach();
1284 }
1285
1286 /// Inserts code snippets as creases into the editor.
1287 /// Each tuple contains (code_text, crease_title).
1288 pub fn insert_code_creases(
1289 &mut self,
1290 creases: Vec<(String, String)>,
1291 window: &mut Window,
1292 cx: &mut Context<Self>,
1293 ) {
1294 self.editor.update(cx, |editor, cx| {
1295 editor.insert("\n", window, cx);
1296 });
1297 for (text, crease_title) in creases {
1298 self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
1299 }
1300 }
1301
1302 pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1303 let Some(workspace) = self.workspace.upgrade() else {
1304 return;
1305 };
1306
1307 let project = workspace.read(cx).project().clone();
1308
1309 let Some(repo) = project.read(cx).active_repository(cx) else {
1310 return;
1311 };
1312
1313 let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
1314 let editor = self.editor.clone();
1315 let mention_set = self.mention_set.clone();
1316 let weak_workspace = self.workspace.clone();
1317
1318 window
1319 .spawn(cx, async move |cx| {
1320 let base_ref: SharedString = default_branch_receiver
1321 .await
1322 .ok()
1323 .and_then(|r| r.ok())
1324 .flatten()
1325 .ok_or_else(|| anyhow!("Could not determine default branch"))?;
1326
1327 cx.update(|window, cx| {
1328 let mention_uri = MentionUri::GitDiff {
1329 base_ref: base_ref.to_string(),
1330 };
1331 let mention_text = mention_uri.as_link().to_string();
1332
1333 let (text_anchor, content_len) = editor.update(cx, |editor, cx| {
1334 let buffer = editor.buffer().read(cx);
1335 let snapshot = buffer.snapshot(cx);
1336 let buffer_snapshot = snapshot.as_singleton().unwrap();
1337 let text_anchor = snapshot
1338 .anchor_to_buffer_anchor(editor.selections.newest_anchor().start)
1339 .unwrap()
1340 .0
1341 .bias_left(&buffer_snapshot);
1342
1343 editor.insert(&mention_text, window, cx);
1344 editor.insert(" ", window, cx);
1345
1346 (text_anchor, mention_text.len())
1347 });
1348
1349 let Some((crease_id, tx)) = insert_crease_for_mention(
1350 text_anchor,
1351 content_len,
1352 mention_uri.name().into(),
1353 mention_uri.icon_path(cx),
1354 mention_uri.tooltip_text(),
1355 Some(mention_uri.clone()),
1356 Some(weak_workspace),
1357 None,
1358 editor,
1359 window,
1360 cx,
1361 ) else {
1362 return;
1363 };
1364 drop(tx);
1365
1366 let confirm_task = mention_set.update(cx, |mention_set, cx| {
1367 mention_set.confirm_mention_for_git_diff(base_ref, cx)
1368 });
1369
1370 let mention_task = cx
1371 .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
1372 .shared();
1373
1374 mention_set.update(cx, |mention_set, _| {
1375 mention_set.insert_mention(crease_id, mention_uri, mention_task);
1376 });
1377 })
1378 })
1379 .detach_and_log_err(cx);
1380 }
1381
1382 fn insert_crease_impl(
1383 &mut self,
1384 text: String,
1385 title: String,
1386 icon: IconName,
1387 add_trailing_newline: bool,
1388 window: &mut Window,
1389 cx: &mut Context<Self>,
1390 ) {
1391 use editor::display_map::{Crease, FoldPlaceholder};
1392 use multi_buffer::MultiBufferRow;
1393 use rope::Point;
1394
1395 self.editor.update(cx, |editor, cx| {
1396 let point = editor
1397 .selections
1398 .newest::<Point>(&editor.display_snapshot(cx))
1399 .head();
1400 let start_row = MultiBufferRow(point.row);
1401
1402 editor.insert(&text, window, cx);
1403
1404 let snapshot = editor.buffer().read(cx).snapshot(cx);
1405 let anchor_before = snapshot.anchor_after(point);
1406 let anchor_after = editor
1407 .selections
1408 .newest_anchor()
1409 .head()
1410 .bias_left(&snapshot);
1411
1412 if add_trailing_newline {
1413 editor.insert("\n", window, cx);
1414 }
1415
1416 let fold_placeholder = FoldPlaceholder {
1417 render: Arc::new({
1418 let title = title.clone();
1419 move |_fold_id, _fold_range, _cx| {
1420 Button::new("crease", title.clone())
1421 .layer(ElevationIndex::ElevatedSurface)
1422 .start_icon(Icon::new(icon))
1423 .into_any_element()
1424 }
1425 }),
1426 merge_adjacent: false,
1427 ..Default::default()
1428 };
1429
1430 let crease = Crease::inline(
1431 anchor_before..anchor_after,
1432 fold_placeholder,
1433 |row, is_folded, fold, _window, _cx| {
1434 Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1435 .toggle_state(is_folded)
1436 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1437 .into_any_element()
1438 },
1439 |_, _, _, _| gpui::Empty.into_any(),
1440 );
1441 editor.insert_creases(vec![crease], cx);
1442 editor.fold_at(start_row, window, cx);
1443 });
1444 }
1445
1446 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1447 let editor = self.editor.read(cx);
1448 let editor_buffer = editor.buffer().read(cx);
1449 let Some(buffer) = editor_buffer.as_singleton() else {
1450 return;
1451 };
1452 let cursor_anchor = editor.selections.newest_anchor().head();
1453 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1454 let anchor = buffer.update(cx, |buffer, _cx| {
1455 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1456 });
1457 let Some(workspace) = self.workspace.upgrade() else {
1458 return;
1459 };
1460 let Some(completion) =
1461 PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
1462 PromptContextAction::AddSelections,
1463 anchor..anchor,
1464 self.editor.downgrade(),
1465 self.mention_set.downgrade(),
1466 &workspace,
1467 cx,
1468 )
1469 else {
1470 return;
1471 };
1472
1473 self.editor.update(cx, |message_editor, cx| {
1474 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1475 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1476 });
1477 if let Some(confirm) = completion.confirm {
1478 confirm(CompletionIntent::Complete, window, cx);
1479 }
1480 }
1481
1482 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1483 if !self.session_capabilities.read().supports_images() {
1484 return;
1485 }
1486
1487 let editor = self.editor.clone();
1488 let mention_set = self.mention_set.clone();
1489 let workspace = self.workspace.clone();
1490
1491 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1492 files: true,
1493 directories: false,
1494 multiple: true,
1495 prompt: Some("Select Images".into()),
1496 });
1497
1498 window
1499 .spawn(cx, async move |cx| {
1500 let paths = match paths_receiver.await {
1501 Ok(Ok(Some(paths))) => paths,
1502 _ => return Ok::<(), anyhow::Error>(()),
1503 };
1504
1505 let default_image_name: SharedString = "Image".into();
1506 let images = cx
1507 .background_spawn(async move {
1508 paths
1509 .into_iter()
1510 .filter_map(|path| {
1511 crate::mention_set::load_external_image_from_path(
1512 &path,
1513 &default_image_name,
1514 )
1515 })
1516 .collect::<Vec<_>>()
1517 })
1518 .await;
1519
1520 crate::mention_set::insert_images_as_context(
1521 images,
1522 editor,
1523 mention_set,
1524 workspace,
1525 cx,
1526 )
1527 .await;
1528 Ok(())
1529 })
1530 .detach_and_log_err(cx);
1531 }
1532
1533 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1534 self.editor.update(cx, |message_editor, cx| {
1535 message_editor.set_read_only(read_only);
1536 cx.notify()
1537 })
1538 }
1539
1540 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1541 self.editor.update(cx, |editor, cx| {
1542 if *editor.mode() != mode {
1543 editor.set_mode(mode);
1544 cx.notify()
1545 }
1546 });
1547 }
1548
1549 pub fn set_message(
1550 &mut self,
1551 message: Vec<acp::ContentBlock>,
1552 window: &mut Window,
1553 cx: &mut Context<Self>,
1554 ) {
1555 self.clear(window, cx);
1556 self.insert_message_blocks(message, false, window, cx);
1557 }
1558
1559 pub fn append_message(
1560 &mut self,
1561 message: Vec<acp::ContentBlock>,
1562 separator: Option<&str>,
1563 window: &mut Window,
1564 cx: &mut Context<Self>,
1565 ) {
1566 if message.is_empty() {
1567 return;
1568 }
1569
1570 if let Some(separator) = separator
1571 && !separator.is_empty()
1572 && !self.is_empty(cx)
1573 {
1574 self.editor.update(cx, |editor, cx| {
1575 editor.insert(separator, window, cx);
1576 });
1577 }
1578
1579 self.insert_message_blocks(message, true, window, cx);
1580 }
1581
1582 fn insert_message_blocks(
1583 &mut self,
1584 message: Vec<acp::ContentBlock>,
1585 append_to_existing: bool,
1586 window: &mut Window,
1587 cx: &mut Context<Self>,
1588 ) {
1589 let Some(workspace) = self.workspace.upgrade() else {
1590 return;
1591 };
1592
1593 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1594 let mut text = String::new();
1595 let mut mentions = Vec::new();
1596
1597 for chunk in message {
1598 match chunk {
1599 acp::ContentBlock::Text(text_content) => {
1600 text.push_str(&text_content.text);
1601 }
1602 acp::ContentBlock::Resource(acp::EmbeddedResource {
1603 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1604 ..
1605 }) => {
1606 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1607 else {
1608 continue;
1609 };
1610 let start = text.len();
1611 write!(&mut text, "{}", mention_uri.as_link()).ok();
1612 let end = text.len();
1613 mentions.push((
1614 start..end,
1615 mention_uri,
1616 Mention::Text {
1617 content: resource.text,
1618 tracked_buffers: Vec::new(),
1619 },
1620 ));
1621 }
1622 acp::ContentBlock::ResourceLink(resource) => {
1623 if let Some(mention_uri) =
1624 MentionUri::parse(&resource.uri, path_style).log_err()
1625 {
1626 let start = text.len();
1627 write!(&mut text, "{}", mention_uri.as_link()).ok();
1628 let end = text.len();
1629 mentions.push((start..end, mention_uri, Mention::Link));
1630 }
1631 }
1632 acp::ContentBlock::Image(acp::ImageContent {
1633 uri,
1634 data,
1635 mime_type,
1636 ..
1637 }) => {
1638 let mention_uri = if let Some(uri) = uri {
1639 MentionUri::parse(&uri, path_style)
1640 } else {
1641 Ok(MentionUri::PastedImage {
1642 name: "Image".to_string(),
1643 })
1644 };
1645 let Some(mention_uri) = mention_uri.log_err() else {
1646 continue;
1647 };
1648 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1649 log::error!("failed to parse MIME type for image: {mime_type:?}");
1650 continue;
1651 };
1652 let start = text.len();
1653 write!(&mut text, "{}", mention_uri.as_link()).ok();
1654 let end = text.len();
1655 mentions.push((
1656 start..end,
1657 mention_uri,
1658 Mention::Image(MentionImage {
1659 data: data.into(),
1660 format,
1661 }),
1662 ));
1663 }
1664 _ => {}
1665 }
1666 }
1667
1668 if text.is_empty() && mentions.is_empty() {
1669 return;
1670 }
1671
1672 let insertion_start = if append_to_existing {
1673 self.editor.read(cx).text(cx).len()
1674 } else {
1675 0
1676 };
1677
1678 let snapshot = if append_to_existing {
1679 self.editor.update(cx, |editor, cx| {
1680 editor.insert(&text, window, cx);
1681 editor.buffer().read(cx).snapshot(cx)
1682 })
1683 } else {
1684 self.editor.update(cx, |editor, cx| {
1685 editor.set_text(text, window, cx);
1686 editor.buffer().read(cx).snapshot(cx)
1687 })
1688 };
1689
1690 for (range, mention_uri, mention) in mentions {
1691 let adjusted_start = insertion_start + range.start;
1692 let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
1693 let Some((crease_id, tx)) = insert_crease_for_mention(
1694 snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
1695 range.end - range.start,
1696 mention_uri.name().into(),
1697 mention_uri.icon_path(cx),
1698 mention_uri.tooltip_text(),
1699 Some(mention_uri.clone()),
1700 Some(self.workspace.clone()),
1701 None,
1702 self.editor.clone(),
1703 window,
1704 cx,
1705 ) else {
1706 continue;
1707 };
1708 drop(tx);
1709
1710 self.mention_set.update(cx, |mention_set, _cx| {
1711 mention_set.insert_mention(
1712 crease_id,
1713 mention_uri.clone(),
1714 Task::ready(Ok(mention)).shared(),
1715 )
1716 });
1717 }
1718
1719 cx.notify();
1720 }
1721
1722 pub fn text(&self, cx: &App) -> String {
1723 self.editor.read(cx).text(cx)
1724 }
1725
1726 pub fn set_cursor_offset(
1727 &mut self,
1728 offset: usize,
1729 window: &mut Window,
1730 cx: &mut Context<Self>,
1731 ) {
1732 self.editor.update(cx, |editor, cx| {
1733 let snapshot = editor.buffer().read(cx).snapshot(cx);
1734 let offset = snapshot.clip_offset(MultiBufferOffset(offset), text::Bias::Left);
1735 editor.change_selections(Default::default(), window, cx, |selections| {
1736 selections.select_ranges([offset..offset]);
1737 });
1738 });
1739 }
1740
1741 pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1742 if text.is_empty() {
1743 return;
1744 }
1745
1746 self.editor.update(cx, |editor, cx| {
1747 editor.insert(text, window, cx);
1748 });
1749 }
1750
1751 pub fn set_placeholder_text(
1752 &mut self,
1753 placeholder: &str,
1754 window: &mut Window,
1755 cx: &mut Context<Self>,
1756 ) {
1757 self.editor.update(cx, |editor, cx| {
1758 editor.set_placeholder_text(placeholder, window, cx);
1759 });
1760 }
1761
1762 #[cfg(any(test, feature = "test-support"))]
1763 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1764 self.editor.update(cx, |editor, cx| {
1765 editor.set_text(text, window, cx);
1766 });
1767 }
1768}
1769
1770impl Focusable for MessageEditor {
1771 fn focus_handle(&self, cx: &App) -> FocusHandle {
1772 self.editor.focus_handle(cx)
1773 }
1774}
1775
1776impl Render for MessageEditor {
1777 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1778 div()
1779 .key_context("MessageEditor")
1780 .on_action(cx.listener(Self::chat))
1781 .on_action(cx.listener(Self::send_immediately))
1782 .on_action(cx.listener(Self::chat_with_follow))
1783 .on_action(cx.listener(Self::cancel))
1784 .on_action(cx.listener(Self::paste_raw))
1785 .capture_action(cx.listener(Self::paste))
1786 .flex_1()
1787 .child({
1788 let settings = ThemeSettings::get_global(cx);
1789
1790 let text_style = TextStyle {
1791 color: cx.theme().colors().text,
1792 font_family: settings.buffer_font.family.clone(),
1793 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1794 font_features: settings.buffer_font.features.clone(),
1795 font_size: settings.agent_buffer_font_size(cx).into(),
1796 font_weight: settings.buffer_font.weight,
1797 line_height: relative(settings.buffer_line_height.value()),
1798 ..Default::default()
1799 };
1800
1801 EditorElement::new(
1802 &self.editor,
1803 EditorStyle {
1804 background: cx.theme().colors().editor_background,
1805 local_player: cx.theme().players().local(),
1806 text: text_style,
1807 syntax: cx.theme().syntax().clone(),
1808 inlay_hints_style: editor::make_inlay_hints_style(cx),
1809 ..Default::default()
1810 },
1811 )
1812 })
1813 }
1814}
1815
1816pub struct MessageEditorAddon {}
1817
1818impl MessageEditorAddon {
1819 pub fn new() -> Self {
1820 Self {}
1821 }
1822}
1823
1824impl Addon for MessageEditorAddon {
1825 fn to_any(&self) -> &dyn std::any::Any {
1826 self
1827 }
1828
1829 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1830 Some(self)
1831 }
1832
1833 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1834 let settings = agent_settings::AgentSettings::get_global(cx);
1835 if settings.use_modifier_to_send {
1836 key_context.add("use_modifier_to_send");
1837 }
1838 }
1839}
1840
1841/// Parses markdown mention links in the format `[@name](uri)` from text.
1842/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1843fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1844 let mut mentions = Vec::new();
1845 let mut search_start = 0;
1846
1847 while let Some(link_start) = text[search_start..].find("[@") {
1848 let absolute_start = search_start + link_start;
1849
1850 // Find the matching closing bracket for the name, handling nested brackets.
1851 // Start at the '[' character so find_matching_bracket can track depth correctly.
1852 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1853 search_start = absolute_start + 2;
1854 continue;
1855 };
1856 let name_end = absolute_start + name_end;
1857
1858 // Check for opening parenthesis immediately after
1859 if text.get(name_end + 1..name_end + 2) != Some("(") {
1860 search_start = name_end + 1;
1861 continue;
1862 }
1863
1864 // Find the matching closing parenthesis for the URI, handling nested parens
1865 let uri_start = name_end + 2;
1866 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1867 search_start = uri_start;
1868 continue;
1869 };
1870 let uri_end = name_end + 1 + uri_end_relative;
1871 let link_end = uri_end + 1;
1872
1873 let uri_str = &text[uri_start..uri_end];
1874
1875 // Try to parse the URI as a MentionUri
1876 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1877 mentions.push((absolute_start..link_end, mention_uri));
1878 }
1879
1880 search_start = link_end;
1881 }
1882
1883 mentions
1884}
1885
1886/// Finds the position of the matching closing bracket, handling nested brackets.
1887/// The input `text` should start with the opening bracket.
1888/// Returns the index of the matching closing bracket relative to `text`.
1889fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1890 let mut depth = 0;
1891 for (index, character) in text.char_indices() {
1892 if character == open {
1893 depth += 1;
1894 } else if character == close {
1895 depth -= 1;
1896 if depth == 0 {
1897 return Some(index);
1898 }
1899 }
1900 }
1901 None
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906 use std::{ops::Range, path::Path, path::PathBuf, sync::Arc};
1907
1908 use acp_thread::MentionUri;
1909 use agent::{ThreadStore, outline};
1910 use agent_client_protocol as acp;
1911 use base64::Engine as _;
1912 use editor::{
1913 AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
1914 actions::Paste,
1915 };
1916
1917 use fs::FakeFs;
1918 use futures::StreamExt as _;
1919 use gpui::{
1920 AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths,
1921 FocusHandle, Focusable, TestAppContext, VisualTestContext,
1922 };
1923 use language_model::LanguageModelRegistry;
1924 use lsp::{CompletionContext, CompletionTriggerKind};
1925 use parking_lot::RwLock;
1926 use project::{CompletionIntent, Project, ProjectPath};
1927 use serde_json::{Value, json};
1928
1929 use text::Point;
1930 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1931 use util::{path, paths::PathStyle, rel_path::rel_path};
1932 use workspace::{AppState, Item, MultiWorkspace};
1933
1934 use crate::completion_provider::PromptContextType;
1935 use crate::{
1936 conversation_view::tests::init_test,
1937 message_editor::{Mention, MessageEditor, SessionCapabilities, parse_mention_links},
1938 };
1939
1940 #[test]
1941 fn test_parse_mention_links() {
1942 // Single file mention
1943 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1944 let mentions = parse_mention_links(text, PathStyle::local());
1945 assert_eq!(mentions.len(), 1);
1946 assert_eq!(mentions[0].0, 0..text.len());
1947 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1948
1949 // Multiple mentions
1950 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1951 let mentions = parse_mention_links(text, PathStyle::local());
1952 assert_eq!(mentions.len(), 2);
1953
1954 // Text without mentions
1955 let text = "Just some regular text without mentions";
1956 let mentions = parse_mention_links(text, PathStyle::local());
1957 assert_eq!(mentions.len(), 0);
1958
1959 // Malformed mentions (should be skipped)
1960 let text = "[@incomplete](invalid://uri) and [@missing](";
1961 let mentions = parse_mention_links(text, PathStyle::local());
1962 assert_eq!(mentions.len(), 0);
1963
1964 // Mixed content with valid mention
1965 let text = "Before [@valid](file:///path/to/file) after";
1966 let mentions = parse_mention_links(text, PathStyle::local());
1967 assert_eq!(mentions.len(), 1);
1968 assert_eq!(mentions[0].0.start, 7);
1969
1970 // HTTP URL mention (Fetch)
1971 let text = "Check out [@docs](https://example.com/docs) for more info";
1972 let mentions = parse_mention_links(text, PathStyle::local());
1973 assert_eq!(mentions.len(), 1);
1974 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1975
1976 // Directory mention (trailing slash)
1977 let text = "[@src](file:///path/to/src/)";
1978 let mentions = parse_mention_links(text, PathStyle::local());
1979 assert_eq!(mentions.len(), 1);
1980 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1981
1982 // Multiple different mention types
1983 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1984 let mentions = parse_mention_links(text, PathStyle::local());
1985 assert_eq!(mentions.len(), 3);
1986 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1987 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1988 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1989
1990 // Adjacent mentions without separator
1991 let text = "[@a](file:///a)[@b](file:///b)";
1992 let mentions = parse_mention_links(text, PathStyle::local());
1993 assert_eq!(mentions.len(), 2);
1994
1995 // Regular markdown link (not a mention) should be ignored
1996 let text = "[regular link](https://example.com)";
1997 let mentions = parse_mention_links(text, PathStyle::local());
1998 assert_eq!(mentions.len(), 0);
1999
2000 // Incomplete mention link patterns
2001 let text = "[@name] without url and [@name( malformed";
2002 let mentions = parse_mention_links(text, PathStyle::local());
2003 assert_eq!(mentions.len(), 0);
2004
2005 // Nested brackets in name portion
2006 let text = "[@name [with brackets]](file:///path/to/file)";
2007 let mentions = parse_mention_links(text, PathStyle::local());
2008 assert_eq!(mentions.len(), 1);
2009 assert_eq!(mentions[0].0, 0..text.len());
2010
2011 // Deeply nested brackets
2012 let text = "[@outer [inner [deep]]](file:///path)";
2013 let mentions = parse_mention_links(text, PathStyle::local());
2014 assert_eq!(mentions.len(), 1);
2015
2016 // Unbalanced brackets should fail gracefully
2017 let text = "[@unbalanced [bracket](file:///path)";
2018 let mentions = parse_mention_links(text, PathStyle::local());
2019 assert_eq!(mentions.len(), 0);
2020
2021 // Nested parentheses in URI (common in URLs with query params)
2022 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
2023 let mentions = parse_mention_links(text, PathStyle::local());
2024 assert_eq!(mentions.len(), 1);
2025 if let MentionUri::Fetch { url } = &mentions[0].1 {
2026 assert!(url.as_str().contains("Rust_(programming_language)"));
2027 } else {
2028 panic!("Expected Fetch URI");
2029 }
2030 }
2031
2032 #[gpui::test]
2033 async fn test_at_mention_removal(cx: &mut TestAppContext) {
2034 init_test(cx);
2035
2036 let fs = FakeFs::new(cx.executor());
2037 fs.insert_tree("/project", json!({"file": ""})).await;
2038 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2039
2040 let (multi_workspace, cx) =
2041 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2042 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2043
2044 let thread_store = None;
2045
2046 let message_editor = cx.update(|window, cx| {
2047 cx.new(|cx| {
2048 MessageEditor::new(
2049 workspace.downgrade(),
2050 project.downgrade(),
2051 thread_store.clone(),
2052 None,
2053 Default::default(),
2054 "Test Agent".into(),
2055 "Test",
2056 EditorMode::AutoHeight {
2057 min_lines: 1,
2058 max_lines: None,
2059 },
2060 window,
2061 cx,
2062 )
2063 })
2064 });
2065 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2066
2067 cx.run_until_parked();
2068
2069 let completions = editor.update_in(cx, |editor, window, cx| {
2070 editor.set_text("Hello @file ", window, cx);
2071 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
2072 let completion_provider = editor.completion_provider().unwrap();
2073 completion_provider.completions(
2074 &buffer,
2075 text::Anchor::max_for_buffer(buffer.read(cx).remote_id()),
2076 CompletionContext {
2077 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
2078 trigger_character: Some("@".into()),
2079 },
2080 window,
2081 cx,
2082 )
2083 });
2084 let [_, completion]: [_; 2] = completions
2085 .await
2086 .unwrap()
2087 .into_iter()
2088 .flat_map(|response| response.completions)
2089 .collect::<Vec<_>>()
2090 .try_into()
2091 .unwrap();
2092
2093 editor.update_in(cx, |editor, window, cx| {
2094 let snapshot = editor.buffer().read(cx).snapshot(cx);
2095 let range = snapshot
2096 .buffer_anchor_range_to_anchor_range(completion.replace_range)
2097 .unwrap();
2098 editor.edit([(range, completion.new_text)], cx);
2099 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
2100 });
2101
2102 cx.run_until_parked();
2103
2104 // Backspace over the inserted crease (and the following space).
2105 editor.update_in(cx, |editor, window, cx| {
2106 editor.backspace(&Default::default(), window, cx);
2107 editor.backspace(&Default::default(), window, cx);
2108 });
2109
2110 let (content, _) = message_editor
2111 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2112 .await
2113 .unwrap();
2114
2115 // We don't send a resource link for the deleted crease.
2116 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
2117 }
2118
2119 #[gpui::test]
2120 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
2121 init_test(cx);
2122 let fs = FakeFs::new(cx.executor());
2123 fs.insert_tree(
2124 "/test",
2125 json!({
2126 ".zed": {
2127 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
2128 },
2129 "src": {
2130 "main.rs": "fn main() {}",
2131 },
2132 }),
2133 )
2134 .await;
2135
2136 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
2137 let thread_store = None;
2138 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
2139 acp::PromptCapabilities::default(),
2140 vec![],
2141 )));
2142
2143 let (multi_workspace, cx) =
2144 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2145 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2146 let workspace_handle = workspace.downgrade();
2147 let message_editor = workspace.update_in(cx, |_, window, cx| {
2148 cx.new(|cx| {
2149 MessageEditor::new(
2150 workspace_handle.clone(),
2151 project.downgrade(),
2152 thread_store.clone(),
2153 None,
2154 session_capabilities.clone(),
2155 "Claude Agent".into(),
2156 "Test",
2157 EditorMode::AutoHeight {
2158 min_lines: 1,
2159 max_lines: None,
2160 },
2161 window,
2162 cx,
2163 )
2164 })
2165 });
2166 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2167
2168 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
2169 editor.update_in(cx, |editor, window, cx| {
2170 editor.set_text("/file test.txt", window, cx);
2171 });
2172
2173 let contents_result = message_editor
2174 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2175 .await;
2176
2177 // Should fail because available_commands is empty (no commands supported)
2178 assert!(contents_result.is_err());
2179 let error_message = contents_result.unwrap_err().to_string();
2180 assert!(error_message.contains("not supported by Claude Agent"));
2181 assert!(error_message.contains("Available commands: none"));
2182
2183 // Now simulate Claude providing its list of available commands (which doesn't include file)
2184 session_capabilities
2185 .write()
2186 .set_available_commands(vec![acp::AvailableCommand::new("help", "Get help")]);
2187
2188 // Test that unsupported slash commands trigger an error when we have a list of available commands
2189 editor.update_in(cx, |editor, window, cx| {
2190 editor.set_text("/file test.txt", window, cx);
2191 });
2192
2193 let contents_result = message_editor
2194 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2195 .await;
2196
2197 assert!(contents_result.is_err());
2198 let error_message = contents_result.unwrap_err().to_string();
2199 assert!(error_message.contains("not supported by Claude Agent"));
2200 assert!(error_message.contains("/file"));
2201 assert!(error_message.contains("Available commands: /help"));
2202
2203 // Test that supported commands work fine
2204 editor.update_in(cx, |editor, window, cx| {
2205 editor.set_text("/help", window, cx);
2206 });
2207
2208 let contents_result = message_editor
2209 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2210 .await;
2211
2212 // Should succeed because /help is in available_commands
2213 assert!(contents_result.is_ok());
2214
2215 // Test that regular text works fine
2216 editor.update_in(cx, |editor, window, cx| {
2217 editor.set_text("Hello Claude!", window, cx);
2218 });
2219
2220 let (content, _) = message_editor
2221 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2222 .await
2223 .unwrap();
2224
2225 assert_eq!(content.len(), 1);
2226 if let acp::ContentBlock::Text(text) = &content[0] {
2227 assert_eq!(text.text, "Hello Claude!");
2228 } else {
2229 panic!("Expected ContentBlock::Text");
2230 }
2231
2232 // Test that @ mentions still work
2233 editor.update_in(cx, |editor, window, cx| {
2234 editor.set_text("Check this @", window, cx);
2235 });
2236
2237 // The @ mention functionality should not be affected
2238 let (content, _) = message_editor
2239 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2240 .await
2241 .unwrap();
2242
2243 assert_eq!(content.len(), 1);
2244 if let acp::ContentBlock::Text(text) = &content[0] {
2245 assert_eq!(text.text, "Check this @");
2246 } else {
2247 panic!("Expected ContentBlock::Text");
2248 }
2249 }
2250
2251 struct MessageEditorItem(Entity<MessageEditor>);
2252
2253 impl Item for MessageEditorItem {
2254 type Event = ();
2255
2256 fn include_in_nav_history() -> bool {
2257 false
2258 }
2259
2260 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
2261 "Test".into()
2262 }
2263 }
2264
2265 impl EventEmitter<()> for MessageEditorItem {}
2266
2267 impl Focusable for MessageEditorItem {
2268 fn focus_handle(&self, cx: &App) -> FocusHandle {
2269 self.0.read(cx).focus_handle(cx)
2270 }
2271 }
2272
2273 impl Render for MessageEditorItem {
2274 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2275 self.0.clone().into_any_element()
2276 }
2277 }
2278
2279 #[gpui::test]
2280 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
2281 init_test(cx);
2282
2283 let app_state = cx.update(AppState::test);
2284
2285 cx.update(|cx| {
2286 editor::init(cx);
2287 workspace::init(app_state.clone(), cx);
2288 });
2289
2290 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2291 let window =
2292 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2293 let workspace = window
2294 .read_with(cx, |mw, _| mw.workspace().clone())
2295 .unwrap();
2296
2297 let mut cx = VisualTestContext::from_window(window.into(), cx);
2298
2299 let thread_store = None;
2300 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
2301 acp::PromptCapabilities::default(),
2302 vec![
2303 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
2304 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
2305 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
2306 "<name>",
2307 )),
2308 ),
2309 ],
2310 )));
2311
2312 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2313 let workspace_handle = cx.weak_entity();
2314 let message_editor = cx.new(|cx| {
2315 MessageEditor::new(
2316 workspace_handle,
2317 project.downgrade(),
2318 thread_store.clone(),
2319 None,
2320 session_capabilities.clone(),
2321 "Test Agent".into(),
2322 "Test",
2323 EditorMode::AutoHeight {
2324 max_lines: None,
2325 min_lines: 1,
2326 },
2327 window,
2328 cx,
2329 )
2330 });
2331 workspace.active_pane().update(cx, |pane, cx| {
2332 pane.add_item(
2333 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2334 true,
2335 true,
2336 None,
2337 window,
2338 cx,
2339 );
2340 });
2341 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2342 message_editor.read(cx).editor().clone()
2343 });
2344
2345 cx.simulate_input("/");
2346
2347 editor.update_in(&mut cx, |editor, window, cx| {
2348 assert_eq!(editor.text(cx), "/");
2349 assert!(editor.has_visible_completions_menu());
2350
2351 assert_eq!(
2352 current_completion_labels_with_documentation(editor),
2353 &[
2354 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
2355 ("say-hello".into(), "Say hello to whoever you want".into())
2356 ]
2357 );
2358 editor.set_text("", window, cx);
2359 });
2360
2361 cx.simulate_input("/qui");
2362
2363 editor.update_in(&mut cx, |editor, window, cx| {
2364 assert_eq!(editor.text(cx), "/qui");
2365 assert!(editor.has_visible_completions_menu());
2366
2367 assert_eq!(
2368 current_completion_labels_with_documentation(editor),
2369 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
2370 );
2371 editor.set_text("", window, cx);
2372 });
2373
2374 editor.update_in(&mut cx, |editor, window, cx| {
2375 assert!(editor.has_visible_completions_menu());
2376 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2377 });
2378
2379 cx.run_until_parked();
2380
2381 editor.update_in(&mut cx, |editor, window, cx| {
2382 assert_eq!(editor.display_text(cx), "/quick-math ");
2383 assert!(!editor.has_visible_completions_menu());
2384 editor.set_text("", window, cx);
2385 });
2386
2387 cx.simulate_input("/say");
2388
2389 editor.update_in(&mut cx, |editor, _window, cx| {
2390 assert_eq!(editor.display_text(cx), "/say");
2391 assert!(editor.has_visible_completions_menu());
2392
2393 assert_eq!(
2394 current_completion_labels_with_documentation(editor),
2395 &[("say-hello".into(), "Say hello to whoever you want".into())]
2396 );
2397 });
2398
2399 editor.update_in(&mut cx, |editor, window, cx| {
2400 assert!(editor.has_visible_completions_menu());
2401 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2402 });
2403
2404 cx.run_until_parked();
2405
2406 editor.update_in(&mut cx, |editor, _window, cx| {
2407 assert_eq!(editor.text(cx), "/say-hello ");
2408 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2409 assert!(!editor.has_visible_completions_menu());
2410 });
2411
2412 cx.simulate_input("GPT5");
2413
2414 cx.run_until_parked();
2415
2416 editor.update_in(&mut cx, |editor, window, cx| {
2417 assert_eq!(editor.text(cx), "/say-hello GPT5");
2418 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2419 assert!(!editor.has_visible_completions_menu());
2420
2421 // Delete argument
2422 for _ in 0..5 {
2423 editor.backspace(&editor::actions::Backspace, window, cx);
2424 }
2425 });
2426
2427 cx.run_until_parked();
2428
2429 editor.update_in(&mut cx, |editor, window, cx| {
2430 assert_eq!(editor.text(cx), "/say-hello");
2431 // Hint is visible because argument was deleted
2432 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2433
2434 // Delete last command letter
2435 editor.backspace(&editor::actions::Backspace, window, cx);
2436 });
2437
2438 cx.run_until_parked();
2439
2440 editor.update_in(&mut cx, |editor, _window, cx| {
2441 // Hint goes away once command no longer matches an available one
2442 assert_eq!(editor.text(cx), "/say-hell");
2443 assert_eq!(editor.display_text(cx), "/say-hell");
2444 assert!(!editor.has_visible_completions_menu());
2445 });
2446 }
2447
2448 #[gpui::test]
2449 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2450 init_test(cx);
2451
2452 let app_state = cx.update(AppState::test);
2453
2454 cx.update(|cx| {
2455 editor::init(cx);
2456 workspace::init(app_state.clone(), cx);
2457 });
2458
2459 app_state
2460 .fs
2461 .as_fake()
2462 .insert_tree(
2463 path!("/dir"),
2464 json!({
2465 "editor": "",
2466 "a": {
2467 "one.txt": "1",
2468 "two.txt": "2",
2469 "three.txt": "3",
2470 "four.txt": "4"
2471 },
2472 "b": {
2473 "five.txt": "5",
2474 "six.txt": "6",
2475 "seven.txt": "7",
2476 "eight.txt": "8",
2477 },
2478 "x.png": "",
2479 }),
2480 )
2481 .await;
2482
2483 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2484 let window =
2485 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2486 let workspace = window
2487 .read_with(cx, |mw, _| mw.workspace().clone())
2488 .unwrap();
2489
2490 let worktree = project.update(cx, |project, cx| {
2491 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2492 assert_eq!(worktrees.len(), 1);
2493 worktrees.pop().unwrap()
2494 });
2495 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2496
2497 let mut cx = VisualTestContext::from_window(window.into(), cx);
2498
2499 let paths = vec![
2500 rel_path("a/one.txt"),
2501 rel_path("a/two.txt"),
2502 rel_path("a/three.txt"),
2503 rel_path("a/four.txt"),
2504 rel_path("b/five.txt"),
2505 rel_path("b/six.txt"),
2506 rel_path("b/seven.txt"),
2507 rel_path("b/eight.txt"),
2508 ];
2509
2510 let slash = PathStyle::local().primary_separator();
2511
2512 let mut opened_editors = Vec::new();
2513 for path in paths {
2514 let buffer = workspace
2515 .update_in(&mut cx, |workspace, window, cx| {
2516 workspace.open_path(
2517 ProjectPath {
2518 worktree_id,
2519 path: path.into(),
2520 },
2521 None,
2522 false,
2523 window,
2524 cx,
2525 )
2526 })
2527 .await
2528 .unwrap();
2529 opened_editors.push(buffer);
2530 }
2531
2532 let thread_store = cx.new(|cx| ThreadStore::new(cx));
2533 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
2534 acp::PromptCapabilities::default(),
2535 vec![],
2536 )));
2537
2538 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2539 let workspace_handle = cx.weak_entity();
2540 let message_editor = cx.new(|cx| {
2541 MessageEditor::new(
2542 workspace_handle,
2543 project.downgrade(),
2544 Some(thread_store),
2545 None,
2546 session_capabilities.clone(),
2547 "Test Agent".into(),
2548 "Test",
2549 EditorMode::AutoHeight {
2550 max_lines: None,
2551 min_lines: 1,
2552 },
2553 window,
2554 cx,
2555 )
2556 });
2557 workspace.active_pane().update(cx, |pane, cx| {
2558 pane.add_item(
2559 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2560 true,
2561 true,
2562 None,
2563 window,
2564 cx,
2565 );
2566 });
2567 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2568 let editor = message_editor.read(cx).editor().clone();
2569 (message_editor, editor)
2570 });
2571
2572 cx.simulate_input("Lorem @");
2573
2574 editor.update_in(&mut cx, |editor, window, cx| {
2575 assert_eq!(editor.text(cx), "Lorem @");
2576 assert!(editor.has_visible_completions_menu());
2577
2578 assert_eq!(
2579 current_completion_labels(editor),
2580 &[
2581 format!("eight.txt b{slash}"),
2582 format!("seven.txt b{slash}"),
2583 format!("six.txt b{slash}"),
2584 format!("five.txt b{slash}"),
2585 "Files & Directories".into(),
2586 "Symbols".into()
2587 ]
2588 );
2589 editor.set_text("", window, cx);
2590 });
2591
2592 message_editor.update(&mut cx, |editor, _cx| {
2593 editor.session_capabilities.write().set_prompt_capabilities(
2594 acp::PromptCapabilities::new()
2595 .image(true)
2596 .audio(true)
2597 .embedded_context(true),
2598 );
2599 });
2600
2601 cx.simulate_input("Lorem ");
2602
2603 editor.update(&mut cx, |editor, cx| {
2604 assert_eq!(editor.text(cx), "Lorem ");
2605 assert!(!editor.has_visible_completions_menu());
2606 });
2607
2608 cx.simulate_input("@");
2609
2610 editor.update(&mut cx, |editor, cx| {
2611 assert_eq!(editor.text(cx), "Lorem @");
2612 assert!(editor.has_visible_completions_menu());
2613 assert_eq!(
2614 current_completion_labels(editor),
2615 &[
2616 format!("eight.txt b{slash}"),
2617 format!("seven.txt b{slash}"),
2618 format!("six.txt b{slash}"),
2619 format!("five.txt b{slash}"),
2620 "Files & Directories".into(),
2621 "Symbols".into(),
2622 "Threads".into(),
2623 "Fetch".into()
2624 ]
2625 );
2626 });
2627
2628 // Select and confirm "File"
2629 editor.update_in(&mut cx, |editor, window, cx| {
2630 assert!(editor.has_visible_completions_menu());
2631 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2632 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2633 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2634 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2635 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2636 });
2637
2638 cx.run_until_parked();
2639
2640 editor.update(&mut cx, |editor, cx| {
2641 assert_eq!(editor.text(cx), "Lorem @file ");
2642 assert!(editor.has_visible_completions_menu());
2643 });
2644
2645 cx.simulate_input("one");
2646
2647 editor.update(&mut cx, |editor, cx| {
2648 assert_eq!(editor.text(cx), "Lorem @file one");
2649 assert!(editor.has_visible_completions_menu());
2650 assert_eq!(
2651 current_completion_labels(editor),
2652 vec![format!("one.txt a{slash}")]
2653 );
2654 });
2655
2656 editor.update_in(&mut cx, |editor, window, cx| {
2657 assert!(editor.has_visible_completions_menu());
2658 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2659 });
2660
2661 let url_one = MentionUri::File {
2662 abs_path: path!("/dir/a/one.txt").into(),
2663 }
2664 .to_uri()
2665 .to_string();
2666 editor.update(&mut cx, |editor, cx| {
2667 let text = editor.text(cx);
2668 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2669 assert!(!editor.has_visible_completions_menu());
2670 assert_eq!(fold_ranges(editor, cx).len(), 1);
2671 });
2672
2673 let contents = message_editor
2674 .update(&mut cx, |message_editor, cx| {
2675 message_editor
2676 .mention_set()
2677 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2678 })
2679 .await
2680 .unwrap()
2681 .into_values()
2682 .collect::<Vec<_>>();
2683
2684 {
2685 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2686 panic!("Unexpected mentions");
2687 };
2688 pretty_assertions::assert_eq!(content, "1");
2689 pretty_assertions::assert_eq!(
2690 uri,
2691 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2692 );
2693 }
2694
2695 cx.simulate_input(" ");
2696
2697 editor.update(&mut cx, |editor, cx| {
2698 let text = editor.text(cx);
2699 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2700 assert!(!editor.has_visible_completions_menu());
2701 assert_eq!(fold_ranges(editor, cx).len(), 1);
2702 });
2703
2704 cx.simulate_input("Ipsum ");
2705
2706 editor.update(&mut cx, |editor, cx| {
2707 let text = editor.text(cx);
2708 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2709 assert!(!editor.has_visible_completions_menu());
2710 assert_eq!(fold_ranges(editor, cx).len(), 1);
2711 });
2712
2713 cx.simulate_input("@file ");
2714
2715 editor.update(&mut cx, |editor, cx| {
2716 let text = editor.text(cx);
2717 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2718 assert!(editor.has_visible_completions_menu());
2719 assert_eq!(fold_ranges(editor, cx).len(), 1);
2720 });
2721
2722 editor.update_in(&mut cx, |editor, window, cx| {
2723 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2724 });
2725
2726 cx.run_until_parked();
2727
2728 let contents = message_editor
2729 .update(&mut cx, |message_editor, cx| {
2730 message_editor
2731 .mention_set()
2732 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2733 })
2734 .await
2735 .unwrap()
2736 .into_values()
2737 .collect::<Vec<_>>();
2738
2739 let url_eight = MentionUri::File {
2740 abs_path: path!("/dir/b/eight.txt").into(),
2741 }
2742 .to_uri()
2743 .to_string();
2744
2745 {
2746 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2747 panic!("Unexpected mentions");
2748 };
2749 pretty_assertions::assert_eq!(content, "8");
2750 pretty_assertions::assert_eq!(
2751 uri,
2752 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2753 );
2754 }
2755
2756 editor.update(&mut cx, |editor, cx| {
2757 assert_eq!(
2758 editor.text(cx),
2759 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2760 );
2761 assert!(!editor.has_visible_completions_menu());
2762 assert_eq!(fold_ranges(editor, cx).len(), 2);
2763 });
2764
2765 let plain_text_language = Arc::new(language::Language::new(
2766 language::LanguageConfig {
2767 name: "Plain Text".into(),
2768 matcher: language::LanguageMatcher {
2769 path_suffixes: vec!["txt".to_string()],
2770 ..Default::default()
2771 },
2772 ..Default::default()
2773 },
2774 None,
2775 ));
2776
2777 // Register the language and fake LSP
2778 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2779 language_registry.add(plain_text_language);
2780
2781 let mut fake_language_servers = language_registry.register_fake_lsp(
2782 "Plain Text",
2783 language::FakeLspAdapter {
2784 capabilities: lsp::ServerCapabilities {
2785 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2786 ..Default::default()
2787 },
2788 ..Default::default()
2789 },
2790 );
2791
2792 // Open the buffer to trigger LSP initialization
2793 let buffer = project
2794 .update(&mut cx, |project, cx| {
2795 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2796 })
2797 .await
2798 .unwrap();
2799
2800 // Register the buffer with language servers
2801 let _handle = project.update(&mut cx, |project, cx| {
2802 project.register_buffer_with_language_servers(&buffer, cx)
2803 });
2804
2805 cx.run_until_parked();
2806
2807 let fake_language_server = fake_language_servers.next().await.unwrap();
2808 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2809 move |_, _| async move {
2810 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2811 #[allow(deprecated)]
2812 lsp::SymbolInformation {
2813 name: "MySymbol".into(),
2814 location: lsp::Location {
2815 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2816 range: lsp::Range::new(
2817 lsp::Position::new(0, 0),
2818 lsp::Position::new(0, 1),
2819 ),
2820 },
2821 kind: lsp::SymbolKind::CONSTANT,
2822 tags: None,
2823 container_name: None,
2824 deprecated: None,
2825 },
2826 ])))
2827 },
2828 );
2829
2830 cx.simulate_input("@symbol ");
2831
2832 editor.update(&mut cx, |editor, cx| {
2833 assert_eq!(
2834 editor.text(cx),
2835 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2836 );
2837 assert!(editor.has_visible_completions_menu());
2838 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2839 });
2840
2841 editor.update_in(&mut cx, |editor, window, cx| {
2842 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2843 });
2844
2845 let symbol = MentionUri::Symbol {
2846 abs_path: path!("/dir/a/one.txt").into(),
2847 name: "MySymbol".into(),
2848 line_range: 0..=0,
2849 };
2850
2851 let contents = message_editor
2852 .update(&mut cx, |message_editor, cx| {
2853 message_editor
2854 .mention_set()
2855 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2856 })
2857 .await
2858 .unwrap()
2859 .into_values()
2860 .collect::<Vec<_>>();
2861
2862 {
2863 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2864 panic!("Unexpected mentions");
2865 };
2866 pretty_assertions::assert_eq!(content, "1");
2867 pretty_assertions::assert_eq!(uri, &symbol);
2868 }
2869
2870 cx.run_until_parked();
2871
2872 editor.read_with(&cx, |editor, cx| {
2873 assert_eq!(
2874 editor.text(cx),
2875 format!(
2876 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2877 symbol.to_uri(),
2878 )
2879 );
2880 });
2881
2882 // Try to mention an "image" file that will fail to load
2883 cx.simulate_input("@file x.png");
2884
2885 editor.update(&mut cx, |editor, cx| {
2886 assert_eq!(
2887 editor.text(cx),
2888 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2889 );
2890 assert!(editor.has_visible_completions_menu());
2891 assert_eq!(current_completion_labels(editor), &["x.png "]);
2892 });
2893
2894 editor.update_in(&mut cx, |editor, window, cx| {
2895 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2896 });
2897
2898 // Getting the message contents fails
2899 message_editor
2900 .update(&mut cx, |message_editor, cx| {
2901 message_editor
2902 .mention_set()
2903 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2904 })
2905 .await
2906 .expect_err("Should fail to load x.png");
2907
2908 cx.run_until_parked();
2909
2910 // Mention was removed
2911 editor.read_with(&cx, |editor, cx| {
2912 assert_eq!(
2913 editor.text(cx),
2914 format!(
2915 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2916 symbol.to_uri()
2917 )
2918 );
2919 });
2920
2921 // Once more
2922 cx.simulate_input("@file x.png");
2923
2924 editor.update(&mut cx, |editor, cx| {
2925 assert_eq!(
2926 editor.text(cx),
2927 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2928 );
2929 assert!(editor.has_visible_completions_menu());
2930 assert_eq!(current_completion_labels(editor), &["x.png "]);
2931 });
2932
2933 editor.update_in(&mut cx, |editor, window, cx| {
2934 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2935 });
2936
2937 // This time don't immediately get the contents, just let the confirmed completion settle
2938 cx.run_until_parked();
2939
2940 // Mention was removed
2941 editor.read_with(&cx, |editor, cx| {
2942 assert_eq!(
2943 editor.text(cx),
2944 format!(
2945 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2946 symbol.to_uri()
2947 )
2948 );
2949 });
2950
2951 // Now getting the contents succeeds, because the invalid mention was removed
2952 let contents = message_editor
2953 .update(&mut cx, |message_editor, cx| {
2954 message_editor
2955 .mention_set()
2956 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2957 })
2958 .await
2959 .unwrap();
2960 assert_eq!(contents.len(), 3);
2961 }
2962
2963 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2964 let snapshot = editor.buffer().read(cx).snapshot(cx);
2965 editor.display_map.update(cx, |display_map, cx| {
2966 display_map
2967 .snapshot(cx)
2968 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2969 .map(|fold| fold.range.to_point(&snapshot))
2970 .collect()
2971 })
2972 }
2973
2974 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2975 let completions = editor.current_completions().expect("Missing completions");
2976 completions
2977 .into_iter()
2978 .map(|completion| completion.label.text)
2979 .collect::<Vec<_>>()
2980 }
2981
2982 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2983 let completions = editor.current_completions().expect("Missing completions");
2984 completions
2985 .into_iter()
2986 .map(|completion| {
2987 (
2988 completion.label.text,
2989 completion
2990 .documentation
2991 .map(|d| d.text().to_string())
2992 .unwrap_or_default(),
2993 )
2994 })
2995 .collect::<Vec<_>>()
2996 }
2997
2998 #[gpui::test]
2999 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
3000 init_test(cx);
3001
3002 let fs = FakeFs::new(cx.executor());
3003
3004 // Create a large file that exceeds AUTO_OUTLINE_SIZE
3005 // Using plain text without a configured language, so no outline is available
3006 const LINE: &str = "This is a line of text in the file\n";
3007 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
3008 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
3009
3010 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
3011 let small_content = "fn small_function() { /* small */ }\n";
3012 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
3013
3014 fs.insert_tree(
3015 "/project",
3016 json!({
3017 "large_file.txt": large_content.clone(),
3018 "small_file.txt": small_content,
3019 }),
3020 )
3021 .await;
3022
3023 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3024
3025 let (multi_workspace, cx) =
3026 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3027 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3028
3029 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3030
3031 let message_editor = cx.update(|window, cx| {
3032 cx.new(|cx| {
3033 let editor = MessageEditor::new(
3034 workspace.downgrade(),
3035 project.downgrade(),
3036 thread_store.clone(),
3037 None,
3038 Default::default(),
3039 "Test Agent".into(),
3040 "Test",
3041 EditorMode::AutoHeight {
3042 min_lines: 1,
3043 max_lines: None,
3044 },
3045 window,
3046 cx,
3047 );
3048 // Enable embedded context so files are actually included
3049 editor
3050 .session_capabilities
3051 .write()
3052 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
3053 editor
3054 })
3055 });
3056
3057 // Test large file mention
3058 // Get the absolute path using the project's worktree
3059 let large_file_abs_path = project.read_with(cx, |project, cx| {
3060 let worktree = project.worktrees(cx).next().unwrap();
3061 let worktree_root = worktree.read(cx).abs_path();
3062 worktree_root.join("large_file.txt")
3063 });
3064 let large_file_task = message_editor.update(cx, |editor, cx| {
3065 editor.mention_set().update(cx, |set, cx| {
3066 set.confirm_mention_for_file(large_file_abs_path, true, cx)
3067 })
3068 });
3069
3070 let large_file_mention = large_file_task.await.unwrap();
3071 match large_file_mention {
3072 Mention::Text { content, .. } => {
3073 // Should contain some of the content but not all of it
3074 assert!(
3075 content.contains(LINE),
3076 "Should contain some of the file content"
3077 );
3078 assert!(
3079 !content.contains(&LINE.repeat(100)),
3080 "Should not contain the full file"
3081 );
3082 // Should be much smaller than original
3083 assert!(
3084 content.len() < large_content.len() / 10,
3085 "Should be significantly truncated"
3086 );
3087 }
3088 _ => panic!("Expected Text mention for large file"),
3089 }
3090
3091 // Test small file mention
3092 // Get the absolute path using the project's worktree
3093 let small_file_abs_path = project.read_with(cx, |project, cx| {
3094 let worktree = project.worktrees(cx).next().unwrap();
3095 let worktree_root = worktree.read(cx).abs_path();
3096 worktree_root.join("small_file.txt")
3097 });
3098 let small_file_task = message_editor.update(cx, |editor, cx| {
3099 editor.mention_set().update(cx, |set, cx| {
3100 set.confirm_mention_for_file(small_file_abs_path, true, cx)
3101 })
3102 });
3103
3104 let small_file_mention = small_file_task.await.unwrap();
3105 match small_file_mention {
3106 Mention::Text { content, .. } => {
3107 // Should contain the full actual content
3108 assert_eq!(content, small_content);
3109 }
3110 _ => panic!("Expected Text mention for small file"),
3111 }
3112 }
3113
3114 #[gpui::test]
3115 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
3116 init_test(cx);
3117 cx.update(LanguageModelRegistry::test);
3118
3119 let fs = FakeFs::new(cx.executor());
3120 fs.insert_tree("/project", json!({"file": ""})).await;
3121 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3122
3123 let (multi_workspace, cx) =
3124 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3125 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3126
3127 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3128
3129 let session_id = acp::SessionId::new("thread-123");
3130 let title = Some("Previous Conversation".into());
3131
3132 let message_editor = cx.update(|window, cx| {
3133 cx.new(|cx| {
3134 let mut editor = MessageEditor::new(
3135 workspace.downgrade(),
3136 project.downgrade(),
3137 thread_store.clone(),
3138 None,
3139 Default::default(),
3140 "Test Agent".into(),
3141 "Test",
3142 EditorMode::AutoHeight {
3143 min_lines: 1,
3144 max_lines: None,
3145 },
3146 window,
3147 cx,
3148 );
3149 editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx);
3150 editor
3151 })
3152 });
3153
3154 // Construct expected values for verification
3155 let expected_uri = MentionUri::Thread {
3156 id: session_id.clone(),
3157 name: title.as_ref().unwrap().to_string(),
3158 };
3159 let expected_title = title.as_ref().unwrap();
3160 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
3161
3162 message_editor.read_with(cx, |editor, cx| {
3163 let text = editor.text(cx);
3164
3165 assert!(
3166 text.contains(&expected_link),
3167 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
3168 expected_link,
3169 text
3170 );
3171
3172 let mentions = editor.mention_set().read(cx).mentions();
3173 assert_eq!(
3174 mentions.len(),
3175 1,
3176 "Expected exactly one mention after inserting thread summary"
3177 );
3178
3179 assert!(
3180 mentions.contains(&expected_uri),
3181 "Expected mentions to contain the thread URI"
3182 );
3183 });
3184 }
3185
3186 #[gpui::test]
3187 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
3188 init_test(cx);
3189 cx.update(LanguageModelRegistry::test);
3190
3191 let fs = FakeFs::new(cx.executor());
3192 fs.insert_tree("/project", json!({"file": ""})).await;
3193 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3194
3195 let (multi_workspace, cx) =
3196 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3197 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3198
3199 let thread_store = None;
3200
3201 let message_editor = cx.update(|window, cx| {
3202 cx.new(|cx| {
3203 let mut editor = MessageEditor::new(
3204 workspace.downgrade(),
3205 project.downgrade(),
3206 thread_store.clone(),
3207 None,
3208 Default::default(),
3209 "Test Agent".into(),
3210 "Test",
3211 EditorMode::AutoHeight {
3212 min_lines: 1,
3213 max_lines: None,
3214 },
3215 window,
3216 cx,
3217 );
3218 editor.insert_thread_summary(
3219 acp::SessionId::new("thread-123"),
3220 Some("Previous Conversation".into()),
3221 window,
3222 cx,
3223 );
3224 editor
3225 })
3226 });
3227
3228 message_editor.read_with(cx, |editor, cx| {
3229 assert!(
3230 editor.text(cx).is_empty(),
3231 "Expected thread summary to be skipped for external agents"
3232 );
3233 assert!(
3234 editor.mention_set().read(cx).mentions().is_empty(),
3235 "Expected no mentions when thread summary is skipped"
3236 );
3237 });
3238 }
3239
3240 #[gpui::test]
3241 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
3242 init_test(cx);
3243
3244 let fs = FakeFs::new(cx.executor());
3245 fs.insert_tree("/project", json!({"file": ""})).await;
3246 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3247
3248 let (multi_workspace, cx) =
3249 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3250 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3251
3252 let thread_store = None;
3253
3254 let message_editor = cx.update(|window, cx| {
3255 cx.new(|cx| {
3256 MessageEditor::new(
3257 workspace.downgrade(),
3258 project.downgrade(),
3259 thread_store.clone(),
3260 None,
3261 Default::default(),
3262 "Test Agent".into(),
3263 "Test",
3264 EditorMode::AutoHeight {
3265 min_lines: 1,
3266 max_lines: None,
3267 },
3268 window,
3269 cx,
3270 )
3271 })
3272 });
3273
3274 message_editor.update(cx, |editor, _cx| {
3275 editor
3276 .session_capabilities
3277 .write()
3278 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
3279 });
3280
3281 let supported_modes = {
3282 let app = cx.app.borrow();
3283 let _ = &app;
3284 message_editor
3285 .read(&app)
3286 .session_capabilities
3287 .read()
3288 .supported_modes(false)
3289 };
3290
3291 assert!(
3292 !supported_modes.contains(&PromptContextType::Thread),
3293 "Expected thread mode to be hidden when thread mentions are disabled"
3294 );
3295 }
3296
3297 #[gpui::test]
3298 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
3299 init_test(cx);
3300
3301 let fs = FakeFs::new(cx.executor());
3302 fs.insert_tree("/project", json!({"file": ""})).await;
3303 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3304
3305 let (multi_workspace, cx) =
3306 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3307 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3308
3309 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3310
3311 let message_editor = cx.update(|window, cx| {
3312 cx.new(|cx| {
3313 MessageEditor::new(
3314 workspace.downgrade(),
3315 project.downgrade(),
3316 thread_store.clone(),
3317 None,
3318 Default::default(),
3319 "Test Agent".into(),
3320 "Test",
3321 EditorMode::AutoHeight {
3322 min_lines: 1,
3323 max_lines: None,
3324 },
3325 window,
3326 cx,
3327 )
3328 })
3329 });
3330
3331 message_editor.update(cx, |editor, _cx| {
3332 editor
3333 .session_capabilities
3334 .write()
3335 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
3336 });
3337
3338 let supported_modes = {
3339 let app = cx.app.borrow();
3340 let _ = &app;
3341 message_editor
3342 .read(&app)
3343 .session_capabilities
3344 .read()
3345 .supported_modes(true)
3346 };
3347
3348 assert!(
3349 supported_modes.contains(&PromptContextType::Thread),
3350 "Expected thread mode to be visible when enabled"
3351 );
3352 }
3353
3354 #[gpui::test]
3355 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
3356 init_test(cx);
3357
3358 let fs = FakeFs::new(cx.executor());
3359 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
3360 .await;
3361 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3362
3363 let (multi_workspace, cx) =
3364 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3365 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3366
3367 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3368
3369 let message_editor = cx.update(|window, cx| {
3370 cx.new(|cx| {
3371 MessageEditor::new(
3372 workspace.downgrade(),
3373 project.downgrade(),
3374 thread_store.clone(),
3375 None,
3376 Default::default(),
3377 "Test Agent".into(),
3378 "Test",
3379 EditorMode::AutoHeight {
3380 min_lines: 1,
3381 max_lines: None,
3382 },
3383 window,
3384 cx,
3385 )
3386 })
3387 });
3388 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
3389
3390 cx.run_until_parked();
3391
3392 editor.update_in(cx, |editor, window, cx| {
3393 editor.set_text(" \u{A0}してhello world ", window, cx);
3394 });
3395
3396 let (content, _) = message_editor
3397 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
3398 .await
3399 .unwrap();
3400
3401 assert_eq!(content, vec!["してhello world".into()]);
3402 }
3403
3404 #[gpui::test]
3405 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
3406 init_test(cx);
3407
3408 let fs = FakeFs::new(cx.executor());
3409
3410 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
3411
3412 fs.insert_tree(
3413 "/project",
3414 json!({
3415 "src": {
3416 "main.rs": file_content,
3417 }
3418 }),
3419 )
3420 .await;
3421
3422 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3423
3424 let (multi_workspace, cx) =
3425 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3426 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3427
3428 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3429
3430 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3431 let workspace_handle = cx.weak_entity();
3432 let message_editor = cx.new(|cx| {
3433 MessageEditor::new(
3434 workspace_handle,
3435 project.downgrade(),
3436 thread_store.clone(),
3437 None,
3438 Default::default(),
3439 "Test Agent".into(),
3440 "Test",
3441 EditorMode::AutoHeight {
3442 min_lines: 1,
3443 max_lines: None,
3444 },
3445 window,
3446 cx,
3447 )
3448 });
3449 workspace.active_pane().update(cx, |pane, cx| {
3450 pane.add_item(
3451 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3452 true,
3453 true,
3454 None,
3455 window,
3456 cx,
3457 );
3458 });
3459 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3460 let editor = message_editor.read(cx).editor().clone();
3461 (message_editor, editor)
3462 });
3463
3464 cx.simulate_input("What is in @file main");
3465
3466 editor.update_in(cx, |editor, window, cx| {
3467 assert!(editor.has_visible_completions_menu());
3468 assert_eq!(editor.text(cx), "What is in @file main");
3469 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3470 });
3471
3472 let content = message_editor
3473 .update(cx, |editor, cx| editor.contents(false, cx))
3474 .await
3475 .unwrap()
3476 .0;
3477
3478 let main_rs_uri = if cfg!(windows) {
3479 "file:///C:/project/src/main.rs"
3480 } else {
3481 "file:///project/src/main.rs"
3482 };
3483
3484 // When embedded context is `false` we should get a resource link
3485 pretty_assertions::assert_eq!(
3486 content,
3487 vec![
3488 "What is in ".into(),
3489 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3490 ]
3491 );
3492
3493 message_editor.update(cx, |editor, _cx| {
3494 editor
3495 .session_capabilities
3496 .write()
3497 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true))
3498 });
3499
3500 let content = message_editor
3501 .update(cx, |editor, cx| editor.contents(false, cx))
3502 .await
3503 .unwrap()
3504 .0;
3505
3506 // When embedded context is `true` we should get a resource
3507 pretty_assertions::assert_eq!(
3508 content,
3509 vec![
3510 "What is in ".into(),
3511 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3512 acp::EmbeddedResourceResource::TextResourceContents(
3513 acp::TextResourceContents::new(file_content, main_rs_uri)
3514 )
3515 ))
3516 ]
3517 );
3518 }
3519
3520 #[gpui::test]
3521 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3522 init_test(cx);
3523
3524 let app_state = cx.update(AppState::test);
3525
3526 cx.update(|cx| {
3527 editor::init(cx);
3528 workspace::init(app_state.clone(), cx);
3529 });
3530
3531 app_state
3532 .fs
3533 .as_fake()
3534 .insert_tree(
3535 path!("/dir"),
3536 json!({
3537 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3538 }),
3539 )
3540 .await;
3541
3542 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3543 let window =
3544 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3545 let workspace = window
3546 .read_with(cx, |mw, _| mw.workspace().clone())
3547 .unwrap();
3548
3549 let worktree = project.update(cx, |project, cx| {
3550 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3551 assert_eq!(worktrees.len(), 1);
3552 worktrees.pop().unwrap()
3553 });
3554 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3555
3556 let mut cx = VisualTestContext::from_window(window.into(), cx);
3557
3558 // Open a regular editor with the created file, and select a portion of
3559 // the text that will be used for the selections that are meant to be
3560 // inserted in the agent panel.
3561 let editor = workspace
3562 .update_in(&mut cx, |workspace, window, cx| {
3563 workspace.open_path(
3564 ProjectPath {
3565 worktree_id,
3566 path: rel_path("test.txt").into(),
3567 },
3568 None,
3569 false,
3570 window,
3571 cx,
3572 )
3573 })
3574 .await
3575 .unwrap()
3576 .downcast::<Editor>()
3577 .unwrap();
3578
3579 editor.update_in(&mut cx, |editor, window, cx| {
3580 editor.change_selections(Default::default(), window, cx, |selections| {
3581 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3582 });
3583 });
3584
3585 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3586
3587 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3588 // to ensure we have a fixed viewport, so we can eventually actually
3589 // place the cursor outside of the visible area.
3590 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3591 let workspace_handle = cx.weak_entity();
3592 let message_editor = cx.new(|cx| {
3593 MessageEditor::new(
3594 workspace_handle,
3595 project.downgrade(),
3596 thread_store.clone(),
3597 None,
3598 Default::default(),
3599 "Test Agent".into(),
3600 "Test",
3601 EditorMode::full(),
3602 window,
3603 cx,
3604 )
3605 });
3606 workspace.active_pane().update(cx, |pane, cx| {
3607 pane.add_item(
3608 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3609 true,
3610 true,
3611 None,
3612 window,
3613 cx,
3614 );
3615 });
3616
3617 message_editor
3618 });
3619
3620 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3621 message_editor.editor.update(cx, |editor, cx| {
3622 // Update the Agent Panel's Message Editor text to have 100
3623 // lines, ensuring that the cursor is set at line 90 and that we
3624 // then scroll all the way to the top, so the cursor's position
3625 // remains off screen.
3626 let mut lines = String::new();
3627 for _ in 1..=100 {
3628 lines.push_str(&"Another line in the agent panel's message editor\n");
3629 }
3630 editor.set_text(lines.as_str(), window, cx);
3631 editor.change_selections(Default::default(), window, cx, |selections| {
3632 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3633 });
3634 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3635 });
3636 });
3637
3638 cx.run_until_parked();
3639
3640 // Before proceeding, let's assert that the cursor is indeed off screen,
3641 // otherwise the rest of the test doesn't make sense.
3642 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3643 message_editor.editor.update(cx, |editor, cx| {
3644 let snapshot = editor.snapshot(window, cx);
3645 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3646 let scroll_top = snapshot.scroll_position().y as u32;
3647 let visible_lines = editor.visible_line_count().unwrap() as u32;
3648 let visible_range = scroll_top..(scroll_top + visible_lines);
3649
3650 assert!(!visible_range.contains(&cursor_row));
3651 })
3652 });
3653
3654 // Now let's insert the selection in the Agent Panel's editor and
3655 // confirm that, after the insertion, the cursor is now in the visible
3656 // range.
3657 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3658 message_editor.insert_selections(window, cx);
3659 });
3660
3661 cx.run_until_parked();
3662
3663 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3664 message_editor.editor.update(cx, |editor, cx| {
3665 let snapshot = editor.snapshot(window, cx);
3666 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3667 let scroll_top = snapshot.scroll_position().y as u32;
3668 let visible_lines = editor.visible_line_count().unwrap() as u32;
3669 let visible_range = scroll_top..(scroll_top + visible_lines);
3670
3671 assert!(visible_range.contains(&cursor_row));
3672 })
3673 });
3674 }
3675
3676 #[gpui::test]
3677 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3678 init_test(cx);
3679
3680 let app_state = cx.update(AppState::test);
3681
3682 cx.update(|cx| {
3683 editor::init(cx);
3684 workspace::init(app_state.clone(), cx);
3685 });
3686
3687 app_state
3688 .fs
3689 .as_fake()
3690 .insert_tree(path!("/dir"), json!({}))
3691 .await;
3692
3693 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3694 let window =
3695 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3696 let workspace = window
3697 .read_with(cx, |mw, _| mw.workspace().clone())
3698 .unwrap();
3699
3700 let mut cx = VisualTestContext::from_window(window.into(), cx);
3701
3702 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3703
3704 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3705 let workspace_handle = cx.weak_entity();
3706 let message_editor = cx.new(|cx| {
3707 MessageEditor::new(
3708 workspace_handle,
3709 project.downgrade(),
3710 Some(thread_store.clone()),
3711 None,
3712 Default::default(),
3713 "Test Agent".into(),
3714 "Test",
3715 EditorMode::AutoHeight {
3716 max_lines: None,
3717 min_lines: 1,
3718 },
3719 window,
3720 cx,
3721 )
3722 });
3723 workspace.active_pane().update(cx, |pane, cx| {
3724 pane.add_item(
3725 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3726 true,
3727 true,
3728 None,
3729 window,
3730 cx,
3731 );
3732 });
3733 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3734 let editor = message_editor.read(cx).editor().clone();
3735 (message_editor, editor)
3736 });
3737
3738 editor.update_in(&mut cx, |editor, window, cx| {
3739 editor.set_text("😄😄", window, cx);
3740 });
3741
3742 cx.run_until_parked();
3743
3744 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3745 message_editor.insert_context_type("file", window, cx);
3746 });
3747
3748 cx.run_until_parked();
3749
3750 editor.update(&mut cx, |editor, cx| {
3751 assert_eq!(editor.text(cx), "😄😄@file");
3752 });
3753 }
3754
3755 #[gpui::test]
3756 async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) {
3757 init_test(cx);
3758
3759 let app_state = cx.update(AppState::test);
3760
3761 cx.update(|cx| {
3762 editor::init(cx);
3763 workspace::init(app_state.clone(), cx);
3764 });
3765
3766 app_state
3767 .fs
3768 .as_fake()
3769 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
3770 .await;
3771
3772 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
3773 let window =
3774 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3775 let workspace = window
3776 .read_with(cx, |mw, _| mw.workspace().clone())
3777 .unwrap();
3778
3779 let mut cx = VisualTestContext::from_window(window.into(), cx);
3780
3781 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3782
3783 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3784 let workspace_handle = cx.weak_entity();
3785 let message_editor = cx.new(|cx| {
3786 MessageEditor::new(
3787 workspace_handle,
3788 project.downgrade(),
3789 Some(thread_store),
3790 None,
3791 Default::default(),
3792 "Test Agent".into(),
3793 "Test",
3794 EditorMode::AutoHeight {
3795 max_lines: None,
3796 min_lines: 1,
3797 },
3798 window,
3799 cx,
3800 )
3801 });
3802 workspace.active_pane().update(cx, |pane, cx| {
3803 pane.add_item(
3804 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3805 true,
3806 true,
3807 None,
3808 window,
3809 cx,
3810 );
3811 });
3812 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3813 let editor = message_editor.read(cx).editor().clone();
3814 (message_editor, editor)
3815 });
3816
3817 editor.update_in(&mut cx, |editor, window, cx| {
3818 editor.set_text(
3819 "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA",
3820 window,
3821 cx,
3822 );
3823 });
3824
3825 cx.run_until_parked();
3826
3827 editor.update_in(&mut cx, |editor, window, cx| {
3828 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3829 s.select_ranges([
3830 MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large)
3831 MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest)
3832 ]);
3833 });
3834 });
3835
3836 let mention_link = "[@f](file:///test.txt)";
3837 cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into()));
3838
3839 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3840 message_editor.paste(&Paste, window, cx);
3841 });
3842
3843 let text = editor.update(&mut cx, |editor, cx| editor.text(cx));
3844 assert!(
3845 text.contains("[@f](file:///test.txt)"),
3846 "Expected mention link to be pasted, got: {}",
3847 text
3848 );
3849 }
3850
3851 #[gpui::test]
3852 async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
3853 cx: &mut TestAppContext,
3854 ) {
3855 init_test(cx);
3856
3857 let app_state = cx.update(AppState::test);
3858
3859 cx.update(|cx| {
3860 editor::init(cx);
3861 workspace::init(app_state.clone(), cx);
3862 });
3863
3864 app_state
3865 .fs
3866 .as_fake()
3867 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
3868 .await;
3869
3870 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
3871 let window =
3872 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3873 let workspace = window
3874 .read_with(cx, |mw, _| mw.workspace().clone())
3875 .unwrap();
3876
3877 let mut cx = VisualTestContext::from_window(window.into(), cx);
3878
3879 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3880
3881 let (_message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3882 let workspace_handle = cx.weak_entity();
3883 let message_editor = cx.new(|cx| {
3884 MessageEditor::new(
3885 workspace_handle,
3886 project.downgrade(),
3887 Some(thread_store),
3888 None,
3889 Default::default(),
3890 "Test Agent".into(),
3891 "Test",
3892 EditorMode::AutoHeight {
3893 max_lines: None,
3894 min_lines: 1,
3895 },
3896 window,
3897 cx,
3898 )
3899 });
3900 workspace.active_pane().update(cx, |pane, cx| {
3901 pane.add_item(
3902 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3903 true,
3904 true,
3905 None,
3906 window,
3907 cx,
3908 );
3909 });
3910 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3911 let editor = message_editor.read(cx).editor().clone();
3912 (message_editor, editor)
3913 });
3914
3915 cx.simulate_input("@");
3916
3917 editor.update(&mut cx, |editor, cx| {
3918 assert_eq!(editor.text(cx), "@");
3919 assert!(editor.has_visible_completions_menu());
3920 });
3921
3922 cx.write_to_clipboard(ClipboardItem::new_string("[@f](file:///test.txt) @".into()));
3923 cx.dispatch_action(Paste);
3924
3925 editor.update(&mut cx, |editor, cx| {
3926 assert!(editor.text(cx).contains("[@f](file:///test.txt)"));
3927 });
3928 }
3929
3930 #[gpui::test]
3931 async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) {
3932 init_test(cx);
3933 let (message_editor, editor, mut cx) =
3934 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
3935 paste_external_paths(
3936 &message_editor,
3937 vec![PathBuf::from(path!("/project/file.txt"))],
3938 &mut cx,
3939 );
3940
3941 let expected_uri = MentionUri::File {
3942 abs_path: path!("/project/file.txt").into(),
3943 }
3944 .to_uri()
3945 .to_string();
3946
3947 editor.update(&mut cx, |editor, cx| {
3948 assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) "));
3949 });
3950
3951 let contents = mention_contents(&message_editor, &mut cx).await;
3952
3953 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
3954 panic!("Unexpected mentions");
3955 };
3956 assert_eq!(content, "content");
3957 assert_eq!(
3958 uri,
3959 &MentionUri::File {
3960 abs_path: path!("/project/file.txt").into(),
3961 }
3962 );
3963 }
3964
3965 #[gpui::test]
3966 async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) {
3967 init_test(cx);
3968 let (message_editor, editor, mut cx) = setup_paste_test_message_editor(
3969 json!({
3970 "src": {
3971 "main.rs": "fn main() {}\n",
3972 }
3973 }),
3974 cx,
3975 )
3976 .await;
3977 paste_external_paths(
3978 &message_editor,
3979 vec![PathBuf::from(path!("/project/src"))],
3980 &mut cx,
3981 );
3982
3983 let expected_uri = MentionUri::Directory {
3984 abs_path: path!("/project/src").into(),
3985 }
3986 .to_uri()
3987 .to_string();
3988
3989 editor.update(&mut cx, |editor, cx| {
3990 assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) "));
3991 });
3992
3993 let contents = mention_contents(&message_editor, &mut cx).await;
3994
3995 let [(uri, Mention::Link)] = contents.as_slice() else {
3996 panic!("Unexpected mentions");
3997 };
3998 assert_eq!(
3999 uri,
4000 &MentionUri::Directory {
4001 abs_path: path!("/project/src").into(),
4002 }
4003 );
4004 }
4005
4006 #[gpui::test]
4007 async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) {
4008 init_test(cx);
4009 let (message_editor, editor, mut cx) =
4010 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
4011
4012 editor.update_in(&mut cx, |editor, window, cx| {
4013 editor.set_text("Hello world", window, cx);
4014 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
4015 selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
4016 });
4017 });
4018
4019 paste_external_paths(
4020 &message_editor,
4021 vec![PathBuf::from(path!("/project/file.txt"))],
4022 &mut cx,
4023 );
4024
4025 let expected_uri = MentionUri::File {
4026 abs_path: path!("/project/file.txt").into(),
4027 }
4028 .to_uri()
4029 .to_string();
4030
4031 editor.update(&mut cx, |editor, cx| {
4032 assert_eq!(
4033 editor.text(cx),
4034 format!("Hello [@file.txt]({expected_uri}) world")
4035 );
4036 });
4037 }
4038
4039 #[gpui::test]
4040 async fn test_paste_mixed_external_image_without_extension_and_file_path(
4041 cx: &mut TestAppContext,
4042 ) {
4043 init_test(cx);
4044 let (message_editor, editor, mut cx) =
4045 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
4046
4047 message_editor.update(&mut cx, |message_editor, _cx| {
4048 message_editor
4049 .session_capabilities
4050 .write()
4051 .set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
4052 });
4053
4054 let temporary_image_path = write_test_png_file(None);
4055 paste_external_paths(
4056 &message_editor,
4057 vec![
4058 temporary_image_path.clone(),
4059 PathBuf::from(path!("/project/file.txt")),
4060 ],
4061 &mut cx,
4062 );
4063
4064 let image_name = temporary_image_path
4065 .file_name()
4066 .and_then(|n| n.to_str())
4067 .unwrap_or("Image")
4068 .to_string();
4069 std::fs::remove_file(&temporary_image_path).expect("remove temp png");
4070
4071 let expected_file_uri = MentionUri::File {
4072 abs_path: path!("/project/file.txt").into(),
4073 }
4074 .to_uri()
4075 .to_string();
4076 let expected_image_uri = MentionUri::PastedImage {
4077 name: image_name.clone(),
4078 }
4079 .to_uri()
4080 .to_string();
4081
4082 editor.update(&mut cx, |editor, cx| {
4083 assert_eq!(
4084 editor.text(cx),
4085 format!("[@{image_name}]({expected_image_uri}) [@file.txt]({expected_file_uri}) ")
4086 );
4087 });
4088
4089 let contents = mention_contents(&message_editor, &mut cx).await;
4090
4091 assert_eq!(contents.len(), 2);
4092 assert!(contents.iter().any(|(uri, mention)| {
4093 matches!(uri, MentionUri::PastedImage { .. }) && matches!(mention, Mention::Image(_))
4094 }));
4095 assert!(contents.iter().any(|(uri, mention)| {
4096 *uri == MentionUri::File {
4097 abs_path: path!("/project/file.txt").into(),
4098 } && matches!(
4099 mention,
4100 Mention::Text {
4101 content,
4102 tracked_buffers: _,
4103 } if content == "content"
4104 )
4105 }));
4106 }
4107
4108 async fn setup_paste_test_message_editor(
4109 project_tree: Value,
4110 cx: &mut TestAppContext,
4111 ) -> (Entity<MessageEditor>, Entity<Editor>, VisualTestContext) {
4112 let app_state = cx.update(AppState::test);
4113
4114 cx.update(|cx| {
4115 editor::init(cx);
4116 workspace::init(app_state.clone(), cx);
4117 });
4118
4119 app_state
4120 .fs
4121 .as_fake()
4122 .insert_tree(path!("/project"), project_tree)
4123 .await;
4124
4125 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
4126 let window =
4127 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4128 let workspace = window
4129 .read_with(cx, |mw, _| mw.workspace().clone())
4130 .unwrap();
4131
4132 let mut cx = VisualTestContext::from_window(window.into(), cx);
4133
4134 let thread_store = cx.new(|cx| ThreadStore::new(cx));
4135
4136 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
4137 let workspace_handle = cx.weak_entity();
4138 let message_editor = cx.new(|cx| {
4139 MessageEditor::new(
4140 workspace_handle,
4141 project.downgrade(),
4142 Some(thread_store),
4143 None,
4144 Default::default(),
4145 "Test Agent".into(),
4146 "Test",
4147 EditorMode::AutoHeight {
4148 max_lines: None,
4149 min_lines: 1,
4150 },
4151 window,
4152 cx,
4153 )
4154 });
4155 workspace.active_pane().update(cx, |pane, cx| {
4156 pane.add_item(
4157 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4158 true,
4159 true,
4160 None,
4161 window,
4162 cx,
4163 );
4164 });
4165 message_editor.read(cx).focus_handle(cx).focus(window, cx);
4166 let editor = message_editor.read(cx).editor().clone();
4167 (message_editor, editor)
4168 });
4169
4170 (message_editor, editor, cx)
4171 }
4172
4173 fn paste_external_paths(
4174 message_editor: &Entity<MessageEditor>,
4175 paths: Vec<PathBuf>,
4176 cx: &mut VisualTestContext,
4177 ) {
4178 cx.write_to_clipboard(ClipboardItem {
4179 entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))],
4180 });
4181
4182 message_editor.update_in(cx, |message_editor, window, cx| {
4183 message_editor.paste(&Paste, window, cx);
4184 });
4185 cx.run_until_parked();
4186 }
4187
4188 async fn mention_contents(
4189 message_editor: &Entity<MessageEditor>,
4190 cx: &mut VisualTestContext,
4191 ) -> Vec<(MentionUri, Mention)> {
4192 message_editor
4193 .update(cx, |message_editor, cx| {
4194 message_editor
4195 .mention_set()
4196 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
4197 })
4198 .await
4199 .unwrap()
4200 .into_values()
4201 .collect::<Vec<_>>()
4202 }
4203
4204 fn write_test_png_file(extension: Option<&str>) -> PathBuf {
4205 let bytes = base64::prelude::BASE64_STANDARD
4206 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
4207 .expect("decode png");
4208 let file_name = match extension {
4209 Some(extension) => format!("zed-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension),
4210 None => format!("zed-agent-ui-test-{}", uuid::Uuid::new_v4()),
4211 };
4212 let path = std::env::temp_dir().join(file_name);
4213 std::fs::write(&path, bytes).expect("write temp png");
4214 path
4215 }
4216
4217 // Helper that creates a minimal MessageEditor inside a window, returning both
4218 // the entity and the underlying VisualTestContext so callers can drive updates.
4219 async fn setup_message_editor(
4220 cx: &mut TestAppContext,
4221 ) -> (Entity<MessageEditor>, &mut VisualTestContext) {
4222 let fs = FakeFs::new(cx.executor());
4223 fs.insert_tree("/project", json!({"file.txt": ""})).await;
4224 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
4225
4226 let (multi_workspace, cx) =
4227 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4228 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4229
4230 let message_editor = cx.update(|window, cx| {
4231 cx.new(|cx| {
4232 MessageEditor::new(
4233 workspace.downgrade(),
4234 project.downgrade(),
4235 None,
4236 None,
4237 Default::default(),
4238 "Test Agent".into(),
4239 "Test",
4240 EditorMode::AutoHeight {
4241 min_lines: 1,
4242 max_lines: None,
4243 },
4244 window,
4245 cx,
4246 )
4247 })
4248 });
4249
4250 cx.run_until_parked();
4251 (message_editor, cx)
4252 }
4253
4254 #[gpui::test]
4255 async fn test_set_message_plain_text(cx: &mut TestAppContext) {
4256 init_test(cx);
4257 let (message_editor, cx) = setup_message_editor(cx).await;
4258
4259 message_editor.update_in(cx, |editor, window, cx| {
4260 editor.set_message(
4261 vec![acp::ContentBlock::Text(acp::TextContent::new(
4262 "hello world".to_string(),
4263 ))],
4264 window,
4265 cx,
4266 );
4267 });
4268
4269 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
4270 assert_eq!(text, "hello world");
4271 assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx)));
4272 }
4273
4274 #[gpui::test]
4275 async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) {
4276 init_test(cx);
4277 let (message_editor, cx) = setup_message_editor(cx).await;
4278
4279 // Set initial content.
4280 message_editor.update_in(cx, |editor, window, cx| {
4281 editor.set_message(
4282 vec![acp::ContentBlock::Text(acp::TextContent::new(
4283 "old content".to_string(),
4284 ))],
4285 window,
4286 cx,
4287 );
4288 });
4289
4290 // Replace with new content.
4291 message_editor.update_in(cx, |editor, window, cx| {
4292 editor.set_message(
4293 vec![acp::ContentBlock::Text(acp::TextContent::new(
4294 "new content".to_string(),
4295 ))],
4296 window,
4297 cx,
4298 );
4299 });
4300
4301 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
4302 assert_eq!(
4303 text, "new content",
4304 "set_message should replace old content"
4305 );
4306 }
4307
4308 #[gpui::test]
4309 async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) {
4310 init_test(cx);
4311 let (message_editor, cx) = setup_message_editor(cx).await;
4312
4313 message_editor.update_in(cx, |editor, window, cx| {
4314 editor.append_message(
4315 vec![acp::ContentBlock::Text(acp::TextContent::new(
4316 "appended".to_string(),
4317 ))],
4318 Some("\n\n"),
4319 window,
4320 cx,
4321 );
4322 });
4323
4324 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
4325 assert_eq!(
4326 text, "appended",
4327 "No separator should be inserted when the editor is empty"
4328 );
4329 }
4330
4331 #[gpui::test]
4332 async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) {
4333 init_test(cx);
4334 let (message_editor, cx) = setup_message_editor(cx).await;
4335
4336 // Seed initial content.
4337 message_editor.update_in(cx, |editor, window, cx| {
4338 editor.set_message(
4339 vec![acp::ContentBlock::Text(acp::TextContent::new(
4340 "initial".to_string(),
4341 ))],
4342 window,
4343 cx,
4344 );
4345 });
4346
4347 // Append with separator.
4348 message_editor.update_in(cx, |editor, window, cx| {
4349 editor.append_message(
4350 vec![acp::ContentBlock::Text(acp::TextContent::new(
4351 "appended".to_string(),
4352 ))],
4353 Some("\n\n"),
4354 window,
4355 cx,
4356 );
4357 });
4358
4359 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
4360 assert_eq!(
4361 text, "initial\n\nappended",
4362 "Separator should appear between existing and appended content"
4363 );
4364 }
4365
4366 #[gpui::test]
4367 async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
4368 init_test(cx);
4369
4370 let fs = FakeFs::new(cx.executor());
4371 fs.insert_tree("/project", json!({"file.txt": "content"}))
4372 .await;
4373 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
4374
4375 let (multi_workspace, cx) =
4376 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4377 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4378
4379 let message_editor = cx.update(|window, cx| {
4380 cx.new(|cx| {
4381 MessageEditor::new(
4382 workspace.downgrade(),
4383 project.downgrade(),
4384 None,
4385 None,
4386 Default::default(),
4387 "Test Agent".into(),
4388 "Test",
4389 EditorMode::AutoHeight {
4390 min_lines: 1,
4391 max_lines: None,
4392 },
4393 window,
4394 cx,
4395 )
4396 })
4397 });
4398
4399 cx.run_until_parked();
4400
4401 // Seed plain-text prefix so the editor is non-empty before appending.
4402 message_editor.update_in(cx, |editor, window, cx| {
4403 editor.set_message(
4404 vec![acp::ContentBlock::Text(acp::TextContent::new(
4405 "prefix text".to_string(),
4406 ))],
4407 window,
4408 cx,
4409 );
4410 });
4411
4412 // Append a message that contains a ResourceLink mention.
4413 message_editor.update_in(cx, |editor, window, cx| {
4414 editor.append_message(
4415 vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
4416 "file.txt",
4417 "file:///project/file.txt",
4418 ))],
4419 Some("\n\n"),
4420 window,
4421 cx,
4422 );
4423 });
4424
4425 cx.run_until_parked();
4426
4427 // The mention should be registered in the mention_set so that contents()
4428 // will emit it as a structured block rather than plain text.
4429 let mention_uris =
4430 message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
4431 assert_eq!(
4432 mention_uris.len(),
4433 1,
4434 "Expected exactly one mention in the mention_set after append, got: {mention_uris:?}"
4435 );
4436
4437 // The editor text should start with the prefix, then the separator, then
4438 // the mention placeholder — confirming the offset was computed correctly.
4439 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
4440 assert!(
4441 text.starts_with("prefix text\n\n"),
4442 "Expected text to start with 'prefix text\\n\\n', got: {text:?}"
4443 );
4444 }
4445}