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