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