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