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