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