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