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