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