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