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