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