Skip to repository content5810 lines · 211.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:50:35.916Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
message_editor.rs
1use crate::DEFAULT_THREAD_TITLE;
2use crate::SendImmediately;
3use crate::{
4 ChatWithFollow,
5 completion_provider::{
6 AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider,
7 PromptCompletionProviderDelegate, PromptContextAction, PromptContextType,
8 PromptLocalCommand, SlashCommandCompletion,
9 },
10 mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
11};
12use acp_thread::MentionUri;
13use agent::ThreadStore;
14use agent_client_protocol::schema::v1 as acp;
15use anyhow::{Result, anyhow};
16use base64::Engine as _;
17use editor::{
18 Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
19 EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
20 actions::{Copy, Cut, Paste},
21 code_context_menus::CodeContextMenu,
22 display_map::{CreaseId, CreaseSnapshot},
23 scroll::Autoscroll,
24};
25use futures::{FutureExt as _, future::join_all};
26use gpui::{
27 AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
28 Focusable, Image, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt,
29 TextStyle, WeakEntity,
30};
31use language::{Buffer, language_settings::InlayHintKind};
32use parking_lot::RwLock;
33use project::AgentId;
34use project::{
35 CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
36};
37use rope::Point;
38use settings::Settings;
39use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
40use text::LineEnding;
41use theme_settings::ThemeSettings;
42use ui::{ContextMenu, prelude::*};
43use util::paths::PathStyle;
44use util::{ResultExt, debug_panic};
45use workspace::{CollaboratorId, Workspace};
46use zed_actions::agent::{Chat, PasteRaw};
47
48pub struct SessionCapabilities {
49 prompt_capabilities: acp::PromptCapabilities,
50 available_commands: Vec<acp::AvailableCommand>,
51 available_skills: Vec<AvailableSkill>,
52 omega_steer_capability: omega_front_door::SteerCapability,
53}
54
55impl Default for SessionCapabilities {
56 fn default() -> Self {
57 Self::new(Default::default(), Vec::new(), Vec::new())
58 }
59}
60
61impl SessionCapabilities {
62 pub fn new(
63 prompt_capabilities: acp::PromptCapabilities,
64 available_commands: Vec<acp::AvailableCommand>,
65 available_skills: Vec<AvailableSkill>,
66 ) -> Self {
67 Self {
68 prompt_capabilities,
69 available_commands,
70 available_skills,
71 omega_steer_capability: omega_front_door::SteerCapability::Unknown,
72 }
73 }
74
75 pub fn with_omega_steer_capability(
76 mut self,
77 capability: omega_front_door::SteerCapability,
78 ) -> Self {
79 self.omega_steer_capability = capability;
80 self
81 }
82
83 pub fn from_acp_commands(
84 prompt_capabilities: acp::PromptCapabilities,
85 available_commands: Vec<acp::AvailableCommand>,
86 ) -> Self {
87 Self::new(prompt_capabilities, available_commands, Vec::new())
88 }
89
90 pub fn supports_images(&self) -> bool {
91 self.prompt_capabilities.image
92 }
93
94 pub fn supports_embedded_context(&self) -> bool {
95 self.prompt_capabilities.embedded_context
96 }
97
98 pub fn available_commands(&self) -> &[acp::AvailableCommand] {
99 &self.available_commands
100 }
101
102 pub fn available_skills(&self) -> &[AvailableSkill] {
103 &self.available_skills
104 }
105
106 /// `OMEGA-DELTA-0032`. Whether this peer said it can take a message while a
107 /// turn is running.
108 ///
109 /// ACP has no standard prompt capability for mid-turn delivery. A peer is
110 /// `Unknown` unless its integration supplies an exact answer. The Exo ACP
111 /// transport supplies `CannotSteer`: cancellation is supported, but a new
112 /// prompt is accepted only after the active turn ends.
113 pub fn omega_steer_capability(&self) -> omega_front_door::SteerCapability {
114 self.omega_steer_capability
115 }
116
117 pub fn has_slash_completions(&self) -> bool {
118 !self.available_commands.is_empty() || !self.available_skills.is_empty()
119 }
120
121 fn supported_modes(&self, has_thread_store: bool) -> Vec<PromptContextType> {
122 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
123 if self.prompt_capabilities.embedded_context {
124 if has_thread_store {
125 supported.push(PromptContextType::Thread);
126 }
127 supported.extend(&[
128 PromptContextType::Diagnostics,
129 PromptContextType::Fetch,
130 PromptContextType::Skill,
131 PromptContextType::BranchDiff,
132 ]);
133 }
134 supported
135 }
136
137 pub fn completion_commands(&self) -> Vec<AvailableCommand> {
138 self.available_commands
139 .iter()
140 .map(|command| AvailableCommand {
141 name: command.name.clone().into(),
142 description: command.description.clone().into(),
143 requires_argument: command.input.is_some(),
144 source: None,
145 category: acp_thread::command_category_from_meta(&command.meta),
146 })
147 .collect()
148 }
149
150 pub fn completion_skills(&self) -> Vec<AvailableSkill> {
151 self.available_skills.clone()
152 }
153
154 pub fn set_prompt_capabilities(&mut self, prompt_capabilities: acp::PromptCapabilities) {
155 self.prompt_capabilities = prompt_capabilities;
156 }
157
158 pub fn set_available_commands(&mut self, available_commands: Vec<acp::AvailableCommand>) {
159 self.available_commands = available_commands;
160 }
161
162 pub fn set_available_skills(&mut self, available_skills: Vec<AvailableSkill>) {
163 self.available_skills = available_skills;
164 }
165}
166
167pub type SharedSessionCapabilities = Arc<RwLock<SessionCapabilities>>;
168
169pub type SharedLocalCommands = Arc<RwLock<Vec<PromptLocalCommand>>>;
170
171struct MessageEditorCompletionDelegate {
172 session_capabilities: SharedSessionCapabilities,
173 has_thread_store: bool,
174 message_editor: WeakEntity<MessageEditor>,
175 local_commands: SharedLocalCommands,
176}
177
178impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate {
179 fn supports_images(&self, _cx: &App) -> bool {
180 self.session_capabilities.read().supports_images()
181 }
182
183 fn supported_modes(&self, _cx: &App) -> Vec<PromptContextType> {
184 self.session_capabilities
185 .read()
186 .supported_modes(self.has_thread_store)
187 }
188
189 fn available_commands(&self, _cx: &App) -> Vec<AvailableCommand> {
190 self.session_capabilities.read().completion_commands()
191 }
192
193 fn available_skills(&self, _cx: &App) -> Vec<AvailableSkill> {
194 self.session_capabilities.read().completion_skills()
195 }
196
197 fn available_local_commands(&self, _cx: &App) -> Vec<PromptLocalCommand> {
198 self.local_commands.read().clone()
199 }
200
201 fn run_local_command(&self, command: PromptLocalCommand, cx: &mut App) {
202 self.message_editor
203 .update(cx, |_this, cx| {
204 cx.emit(MessageEditorEvent::LocalCommandInvoked(command));
205 })
206 .ok();
207 }
208
209 fn slash_autocomplete_invoked(&self, cx: &mut App) {
210 // This may be called synchronously from inside a `MessageEditor`
211 // update (e.g. when pasting a slash command triggers completions),
212 // so we defer the emit to avoid a reentrant update panic.
213 let Some(editor) = self.message_editor.upgrade() else {
214 return;
215 };
216 cx.defer(move |cx| {
217 editor.update(cx, |_editor, cx| {
218 cx.emit(MessageEditorEvent::SlashAutocompleteOpened);
219 });
220 });
221 }
222
223 fn confirm_command(&self, cx: &mut App) {
224 let _ = self.message_editor.update(cx, |this, cx| this.send(cx));
225 }
226}
227
228pub struct MessageEditor {
229 mention_set: Entity<MentionSet>,
230 editor: Entity<Editor>,
231 workspace: WeakEntity<Workspace>,
232 session_capabilities: SharedSessionCapabilities,
233 local_commands: SharedLocalCommands,
234 agent_id: AgentId,
235 thread_store: Option<Entity<ThreadStore>>,
236 _subscriptions: Vec<Subscription>,
237 _parse_slash_command_task: Task<()>,
238}
239
240#[derive(Clone, Debug)]
241pub enum InputAttempt {
242 Text(Arc<str>),
243 Paste(ClipboardItem),
244}
245
246#[derive(Clone, Debug)]
247pub enum MessageEditorEvent {
248 Send,
249 SendImmediately,
250 Cancel,
251 Focus,
252 LostFocus,
253 Edited,
254 /// Emitted when the user opens slash-command autocomplete in this
255 /// editor. Used by `ThreadView` to fire the global-skills scan
256 /// trigger; see `NativeAgent::ensure_skills_scan_started`.
257 SlashAutocompleteOpened,
258 /// Emitted when the user confirms a local slash command (scrolling,
259 /// exporting, feedback) in this editor's completion popup. `ThreadView`
260 /// handles it by running the corresponding action; see
261 /// `handle_message_editor_event`.
262 LocalCommandInvoked(PromptLocalCommand),
263 InputAttempted {
264 attempt: InputAttempt,
265 cursor_offset: usize,
266 },
267}
268
269impl EventEmitter<MessageEditorEvent> for MessageEditor {}
270
271const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
272
273fn insert_mention_for_project_path(
274 project_path: &ProjectPath,
275 editor: &Entity<Editor>,
276 mention_set: &Entity<MentionSet>,
277 project: &Entity<Project>,
278 workspace: &Entity<Workspace>,
279 supports_images: bool,
280 window: &mut Window,
281 cx: &mut App,
282) -> Option<Task<()>> {
283 let (file_name, mention_uri) = {
284 let project = project.read(cx);
285 let path_style = project.path_style(cx);
286 let entry = project.entry_for_path(project_path, cx)?;
287 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
288 let abs_path = worktree.read(cx).absolutize(&project_path.path);
289 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
290 &project_path.path,
291 worktree.read(cx).root_name(),
292 path_style,
293 );
294 let mention_uri = if entry.is_dir() {
295 MentionUri::Directory { abs_path }
296 } else {
297 MentionUri::File { abs_path }
298 };
299 (file_name, mention_uri)
300 };
301
302 let mention_text = mention_uri.as_link().to_string();
303 let content_len = mention_text.len();
304
305 let text_anchor = editor.update(cx, |editor, cx| {
306 let buffer = editor.buffer().read(cx);
307 let snapshot = buffer.snapshot(cx);
308 let buffer_snapshot = snapshot.as_singleton()?;
309 let text_anchor = snapshot
310 .anchor_to_buffer_anchor(editor.selections.newest_anchor().start)?
311 .0
312 .bias_left(&buffer_snapshot);
313
314 editor.insert(&mention_text, window, cx);
315 editor.insert(" ", window, cx);
316
317 Some(text_anchor)
318 })?;
319
320 Some(mention_set.update(cx, |mention_set, cx| {
321 mention_set.confirm_mention_completion(
322 file_name,
323 text_anchor,
324 content_len,
325 mention_uri,
326 supports_images,
327 editor.clone(),
328 workspace,
329 window,
330 cx,
331 )
332 }))
333}
334
335enum ResolvedPastedContextItem {
336 Image(gpui::Image, gpui::SharedString),
337 ProjectPath(ProjectPath),
338}
339
340async fn resolve_pasted_context_items(
341 project: Entity<Project>,
342 project_is_local: bool,
343 supports_images: bool,
344 entries: Vec<ClipboardEntry>,
345 cx: &mut gpui::AsyncWindowContext,
346) -> (Vec<ResolvedPastedContextItem>, Vec<Entity<Worktree>>) {
347 let mut items = Vec::new();
348 let mut added_worktrees = Vec::new();
349 let default_image_name: SharedString = "Image".into();
350
351 for entry in entries {
352 match entry {
353 ClipboardEntry::String(_) => {}
354 ClipboardEntry::Image(image) => {
355 if supports_images {
356 items.push(ResolvedPastedContextItem::Image(
357 image,
358 default_image_name.clone(),
359 ));
360 }
361 }
362 ClipboardEntry::ExternalPaths(paths) => {
363 for path in paths.paths().iter() {
364 if let Some((image, name)) = cx
365 .background_spawn({
366 let path = path.clone();
367 let default_image_name = default_image_name.clone();
368 async move {
369 crate::mention_set::load_external_image_from_path(
370 &path,
371 &default_image_name,
372 )
373 }
374 })
375 .await
376 {
377 if supports_images {
378 items.push(ResolvedPastedContextItem::Image(image, name));
379 }
380 continue;
381 }
382
383 if !project_is_local {
384 continue;
385 }
386
387 let path = path.clone();
388 let Ok(resolve_task) = cx.update({
389 let project = project.clone();
390 move |_, cx| Workspace::project_path_for_path(project, &path, false, cx)
391 }) else {
392 continue;
393 };
394
395 if let Some((worktree, project_path)) = resolve_task.await.log_err() {
396 added_worktrees.push(worktree);
397 items.push(ResolvedPastedContextItem::ProjectPath(project_path));
398 }
399 }
400 }
401 }
402 }
403
404 (items, added_worktrees)
405}
406
407fn insert_project_path_as_context(
408 project_path: ProjectPath,
409 editor: Entity<Editor>,
410 mention_set: Entity<MentionSet>,
411 workspace: WeakEntity<Workspace>,
412 supports_images: bool,
413 cx: &mut gpui::AsyncWindowContext,
414) -> Option<Task<()>> {
415 let workspace = workspace.upgrade()?;
416
417 cx.update(move |window, cx| {
418 let project = workspace.read(cx).project().clone();
419 insert_mention_for_project_path(
420 &project_path,
421 &editor,
422 &mention_set,
423 &project,
424 &workspace,
425 supports_images,
426 window,
427 cx,
428 )
429 })
430 .ok()
431 .flatten()
432}
433
434async fn insert_resolved_pasted_context_items(
435 items: Vec<ResolvedPastedContextItem>,
436 added_worktrees: Vec<Entity<Worktree>>,
437 editor: Entity<Editor>,
438 mention_set: Entity<MentionSet>,
439 workspace: WeakEntity<Workspace>,
440 supports_images: bool,
441 cx: &mut gpui::AsyncWindowContext,
442) {
443 let mut path_mention_tasks = Vec::new();
444
445 for item in items {
446 match item {
447 ResolvedPastedContextItem::Image(image, name) => {
448 crate::mention_set::insert_images_as_context(
449 vec![(image, name)],
450 editor.clone(),
451 mention_set.clone(),
452 workspace.clone(),
453 cx,
454 )
455 .await;
456 }
457 ResolvedPastedContextItem::ProjectPath(project_path) => {
458 if let Some(task) = insert_project_path_as_context(
459 project_path,
460 editor.clone(),
461 mention_set.clone(),
462 workspace.clone(),
463 supports_images,
464 cx,
465 ) {
466 path_mention_tasks.push(task);
467 }
468 }
469 }
470 }
471
472 join_all(path_mention_tasks).await;
473 drop(added_worktrees);
474}
475
476impl MessageEditor {
477 pub fn new(
478 workspace: WeakEntity<Workspace>,
479 project: WeakEntity<Project>,
480 thread_store: Option<Entity<ThreadStore>>,
481 session_capabilities: SharedSessionCapabilities,
482 agent_id: AgentId,
483 placeholder: &str,
484 mode: EditorMode,
485 window: &mut Window,
486 cx: &mut Context<Self>,
487 ) -> Self {
488 let language_registry = project
489 .upgrade()
490 .map(|project| project.read(cx).languages().clone());
491
492 let editor = cx.new(|cx| {
493 let buffer = cx.new(|cx| {
494 let buffer = Buffer::local("", cx);
495 if let Some(language_registry) = language_registry.as_ref() {
496 buffer.set_language_registry(language_registry.clone());
497 }
498 buffer
499 });
500 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
501
502 let mut editor = Editor::new(mode, buffer, None, window, cx);
503 editor.set_placeholder_text(placeholder, window, cx);
504 editor.set_show_indent_guides(false, cx);
505 editor.set_show_completions_on_input(Some(true));
506 editor.set_soft_wrap();
507 editor.disable_mouse_wheel_zoom();
508 editor.set_use_modal_editing(true);
509 editor.set_context_menu_options(ContextMenuOptions {
510 min_entries_visible: 12,
511 max_entries_visible: 12,
512 placement: None,
513 });
514 editor.register_addon(MessageEditorAddon::new());
515
516 editor.set_custom_context_menu(|editor, _point, window, cx| {
517 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
518
519 Some(ContextMenu::build(window, cx, |menu, _, _| {
520 menu.action("Cut", Box::new(editor::actions::Cut))
521 .action_disabled_when(
522 !has_selection,
523 "Copy",
524 Box::new(editor::actions::Copy),
525 )
526 .action("Paste", Box::new(editor::actions::Paste))
527 .action("Paste as Plain Text", Box::new(PasteRaw))
528 }))
529 });
530
531 editor
532 });
533 let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone()));
534 let local_commands: SharedLocalCommands = Arc::new(RwLock::new(Vec::new()));
535 let completion_provider = Rc::new(PromptCompletionProvider::new(
536 MessageEditorCompletionDelegate {
537 session_capabilities: session_capabilities.clone(),
538 has_thread_store: thread_store.is_some(),
539 message_editor: cx.weak_entity(),
540 local_commands: local_commands.clone(),
541 },
542 editor.downgrade(),
543 mention_set.clone(),
544 workspace.clone(),
545 ));
546 editor.update(cx, |editor, _cx| {
547 editor.set_completion_provider(Some(completion_provider.clone()))
548 });
549
550 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
551 cx.emit(MessageEditorEvent::Focus)
552 })
553 .detach();
554 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
555 cx.emit(MessageEditorEvent::LostFocus)
556 })
557 .detach();
558
559 let mut has_hint = false;
560 let mut subscriptions = Vec::new();
561
562 subscriptions.push(cx.subscribe_in(&editor, window, {
563 move |this, editor, event, window, cx| {
564 let input_attempted_text = match event {
565 EditorEvent::InputHandled { text, .. } => Some(text),
566 EditorEvent::InputIgnored { text } => Some(text),
567 _ => None,
568 };
569 if let Some(text) = input_attempted_text
570 && editor.read(cx).read_only(cx)
571 && !text.is_empty()
572 {
573 let editor = editor.read(cx);
574 let cursor_anchor = editor.selections.newest_anchor().head();
575 let cursor_offset = cursor_anchor
576 .to_offset(&editor.buffer().read(cx).snapshot(cx))
577 .0;
578 cx.emit(MessageEditorEvent::InputAttempted {
579 attempt: InputAttempt::Text(text.clone()),
580 cursor_offset,
581 });
582 }
583
584 if let EditorEvent::Edited { .. } = event
585 && !editor.read(cx).read_only(cx)
586 {
587 cx.emit(MessageEditorEvent::Edited);
588 editor.update(cx, |editor, cx| {
589 let snapshot = editor.snapshot(window, cx);
590 this.mention_set
591 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
592
593 let new_hints = this
594 .command_hint(snapshot.buffer())
595 .into_iter()
596 .collect::<Vec<_>>();
597 let has_new_hint = !new_hints.is_empty();
598 editor.splice_inlays(
599 if has_hint {
600 &[COMMAND_HINT_INLAY_ID]
601 } else {
602 &[]
603 },
604 new_hints,
605 cx,
606 );
607 has_hint = has_new_hint;
608 });
609 cx.notify();
610 }
611 }
612 }));
613
614 if let Some(language_registry) = language_registry {
615 let editor = editor.clone();
616 cx.spawn(async move |_, cx| {
617 let markdown = language_registry.language_for_name("Markdown").await?;
618 editor.update(cx, |editor, cx| {
619 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
620 buffer.update(cx, |buffer, cx| {
621 buffer.set_language(Some(markdown), cx);
622 });
623 }
624 });
625 anyhow::Ok(())
626 })
627 .detach_and_log_err(cx);
628 }
629
630 Self {
631 editor,
632 mention_set,
633 workspace,
634 session_capabilities,
635 local_commands,
636 agent_id,
637 thread_store,
638 _subscriptions: subscriptions,
639 _parse_slash_command_task: Task::ready(()),
640 }
641 }
642
643 pub fn set_local_commands(&self, commands: Vec<PromptLocalCommand>) {
644 *self.local_commands.write() = commands;
645 }
646
647 pub fn set_session_capabilities(
648 &mut self,
649 session_capabilities: SharedSessionCapabilities,
650 _cx: &mut Context<Self>,
651 ) {
652 self.session_capabilities = session_capabilities;
653 }
654
655 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
656 let session_capabilities = self.session_capabilities.read();
657 let available_commands = session_capabilities.available_commands();
658 if available_commands.is_empty() {
659 return None;
660 }
661
662 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
663 if parsed_command.argument.is_some() {
664 return None;
665 }
666
667 let command_name = parsed_command.command?;
668 let available_command = available_commands
669 .iter()
670 .find(|available_command| available_command.name == command_name)?;
671
672 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
673 mut hint,
674 ..
675 }) = available_command.input.clone()?
676 else {
677 return None;
678 };
679
680 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
681 if hint_pos > snapshot.len() {
682 hint_pos = snapshot.len();
683 hint.insert(0, ' ');
684 }
685
686 let hint_pos = snapshot.anchor_after(hint_pos);
687
688 Some(Inlay::hint(
689 COMMAND_HINT_INLAY_ID,
690 hint_pos,
691 &InlayHint {
692 position: snapshot.anchor_to_buffer_anchor(hint_pos)?.0,
693 label: InlayHintLabel::String(hint),
694 kind: Some(InlayHintKind::Parameter),
695 padding_left: false,
696 padding_right: false,
697 tooltip: None,
698 resolve_state: project::ResolveState::Resolved,
699 },
700 ))
701 }
702
703 pub fn insert_thread_summary(
704 &mut self,
705 session_id: acp::SessionId,
706 title: Option<SharedString>,
707 window: &mut Window,
708 cx: &mut Context<Self>,
709 ) {
710 if self.thread_store.is_none() {
711 return;
712 }
713 let Some(workspace) = self.workspace.upgrade() else {
714 return;
715 };
716 let thread_title = title
717 .filter(|title| !title.is_empty())
718 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
719 let uri = MentionUri::Thread {
720 id: session_id,
721 name: thread_title.to_string(),
722 };
723 let content = format!("{}\n", uri.as_link());
724
725 let content_len = content.len() - 1;
726
727 let start = self.editor.update(cx, |editor, cx| {
728 editor.set_text(content, window, cx);
729 let snapshot = editor.buffer().read(cx).snapshot(cx);
730 snapshot
731 .anchor_to_buffer_anchor(snapshot.anchor_before(Point::zero()))
732 .unwrap()
733 .0
734 });
735
736 let supports_images = self.session_capabilities.read().supports_images();
737
738 self.mention_set
739 .update(cx, |mention_set, cx| {
740 mention_set.confirm_mention_completion(
741 thread_title,
742 start,
743 content_len,
744 uri,
745 supports_images,
746 self.editor.clone(),
747 &workspace,
748 window,
749 cx,
750 )
751 })
752 .detach();
753 }
754
755 pub(crate) fn editor(&self) -> &Entity<Editor> {
756 &self.editor
757 }
758
759 pub fn is_empty(&self, cx: &App) -> bool {
760 self.editor.read(cx).text(cx).trim().is_empty()
761 }
762
763 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
764 self.editor
765 .read(cx)
766 .context_menu()
767 .borrow()
768 .as_ref()
769 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
770 }
771
772 #[cfg(test)]
773 pub fn mention_set(&self) -> &Entity<MentionSet> {
774 &self.mention_set
775 }
776
777 fn validate_slash_commands(
778 text: &str,
779 available_commands: &[acp::AvailableCommand],
780 available_skills: &[AvailableSkill],
781 agent_id: &AgentId,
782 ) -> Result<()> {
783 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
784 if parsed_command.source_range.start != 0 {
785 return Ok(());
786 }
787 if let Some(command_name) = parsed_command.command {
788 // Two acceptance paths:
789 //
790 // 1. Direct name match. Covers bare slash commands
791 // (`/help`), MCP prompts that were prefixed at the
792 // agent because of a server-name collision
793 // (`/github.create_pr`), and skills (whose bare name
794 // is registered for the unqualified `/<name>` form).
795 //
796 // 2. Trusted native skill scope qualifier `/<scope>:<name>`. The popup
797 // inserts this colon-separated form to disambiguate
798 // same-named skills, so the validator splits on the
799 // LAST `:` to recover scope + bare name. Skill
800 // names are restricted to `[a-z0-9-]+` (no colons),
801 // so the rightmost colon is always the scope/name
802 // boundary — this lets scope labels (e.g. worktree
803 // root names) themselves contain colons. The
804 // scope is allowed to be empty: `/:<name>` is the
805 // qualified form for a global skill (see
806 // `SkillSource::scope_prefix`). The validator then
807 // checks the `available_skills` slice for an entry
808 // whose `skill.name` matches the bare name and
809 // whose `skill.source` equals the typed scope
810 // (including empty for globals). Without this
811 // branch, every autocomplete pick of a same-named
812 // skill would be rejected as "not supported"
813 // before reaching the resolver.
814 let direct_match = available_commands
815 .iter()
816 .any(|available_command| available_command.name == command_name)
817 || available_skills
818 .iter()
819 .any(|skill| skill.name.as_ref() == command_name);
820 let scope_match = !direct_match
821 && command_name.rsplit_once(':').is_some_and(|(scope, bare)| {
822 !bare.is_empty()
823 && available_skills.iter().any(|skill| {
824 skill.name.as_ref() == bare && skill.source.as_ref() == scope
825 })
826 });
827
828 if !direct_match && !scope_match {
829 return Err(anyhow!(indoc::formatdoc!(
830 "/{command_name} is not a recognized command in {agent_id}. \
831 Messages that start with `/` are interpreted as commands.
832
833 If you are trying to send a message and not run a command, \
834 try preceding the `/` with a space.
835
836 Available commands for {agent_id}: {commands}",
837 commands =
838 Self::format_available_commands(available_commands, available_skills),
839 )));
840 }
841 }
842 }
843 Ok(())
844 }
845
846 /// Render the available-commands list for error messages. Trusted native skills
847 /// are shown in their qualified `/<scope>:<name>` form so users
848 /// see the exact text the popup would insert — otherwise the
849 /// listing would contain confusing duplicates like `/foo, /foo`
850 /// when both a global and a project-local skill share a name.
851 /// Globals carry an empty scope and so render as `/:<name>`.
852 fn format_available_commands(
853 commands: &[acp::AvailableCommand],
854 skills: &[AvailableSkill],
855 ) -> String {
856 if commands.is_empty() && skills.is_empty() {
857 return "none".to_string();
858 }
859 skills
860 .iter()
861 .map(|skill| format!("/{}:{}", skill.source, skill.name))
862 .chain(commands.iter().map(|command| format!("/{}", command.name)))
863 .collect::<Vec<_>>()
864 .join(", ")
865 }
866
867 pub fn contents(
868 &self,
869 full_mention_content: bool,
870 cx: &mut Context<Self>,
871 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
872 let text = self.editor.read(cx).text(cx);
873 let (available_commands, available_skills) = {
874 let session_capabilities = self.session_capabilities.read();
875 (
876 session_capabilities.available_commands().to_vec(),
877 session_capabilities.available_skills().to_vec(),
878 )
879 };
880 let agent_id = self.agent_id.clone();
881 let build_task = self.build_content_blocks(full_mention_content, cx);
882
883 cx.spawn(async move |_, _cx| {
884 Self::validate_slash_commands(
885 &text,
886 &available_commands,
887 &available_skills,
888 &agent_id,
889 )?;
890 build_task.await
891 })
892 }
893
894 pub fn draft_contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
895 let build_task = self.build_content_blocks(false, cx);
896 cx.spawn(async move |_, _cx| {
897 let (blocks, _tracked_buffers) = build_task.await?;
898 Ok(blocks)
899 })
900 }
901
902 fn build_content_blocks(
903 &self,
904 full_mention_content: bool,
905 cx: &mut Context<Self>,
906 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
907 let contents = self
908 .mention_set
909 .update(cx, |store, cx| store.contents(full_mention_content, cx));
910 let editor = self.editor.clone();
911 let supports_embedded_context =
912 self.session_capabilities.read().supports_embedded_context();
913
914 cx.spawn(async move |_, cx| {
915 let mut contents = contents.await?;
916 Ok(editor.update(cx, |editor, cx| {
917 let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
918 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
919 let text = editor.text(cx);
920 build_chunks_from_creases(
921 &text,
922 &crease_snapshot,
923 &buffer_snapshot,
924 supports_embedded_context,
925 |crease_id| {
926 contents
927 .remove(crease_id)
928 .map(|(uri, mention)| (uri, Some(mention)))
929 },
930 )
931 }))
932 })
933 }
934
935 /// Snapshots the editor's current draft into a list of `ContentBlock`s
936 /// without awaiting any pending mention resolution.
937 pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec<acp::ContentBlock> {
938 let editor = self.editor.read(cx);
939 let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
940 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
941 let text = editor.text(cx);
942 let mention_set = self.mention_set.read(cx);
943 let supports_embedded_context =
944 self.session_capabilities.read().supports_embedded_context();
945 let (chunks, _tracked_buffers) = build_chunks_from_creases(
946 &text,
947 &crease_snapshot,
948 &buffer_snapshot,
949 supports_embedded_context,
950 |crease_id| mention_set.resolved_mention_for_crease(crease_id),
951 );
952 chunks
953 }
954
955 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
956 self.editor.update(cx, |editor, cx| {
957 editor.clear(window, cx);
958 editor.remove_creases(
959 self.mention_set.update(cx, |mention_set, _cx| {
960 mention_set
961 .clear()
962 .map(|(crease_id, _)| crease_id)
963 .collect::<Vec<_>>()
964 }),
965 cx,
966 )
967 });
968 }
969
970 pub fn send(&mut self, cx: &mut Context<Self>) {
971 if !self.is_empty(cx) {
972 self.editor.update(cx, |editor, cx| {
973 editor.clear_inlay_hints(cx);
974 });
975 }
976 cx.emit(MessageEditorEvent::Send)
977 }
978
979 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
980 self.insert_context_prefix("@", window, cx);
981 }
982
983 pub fn insert_context_type(
984 &mut self,
985 context_keyword: &str,
986 window: &mut Window,
987 cx: &mut Context<Self>,
988 ) {
989 let prefix = format!("@{}", context_keyword);
990 self.insert_context_prefix(&prefix, window, cx);
991 }
992
993 fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
994 let editor = self.editor.clone();
995 let prefix = prefix.to_string();
996
997 cx.spawn_in(window, async move |_, cx| {
998 editor
999 .update_in(cx, |editor, window, cx| {
1000 let menu_is_open =
1001 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
1002 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
1003 });
1004
1005 let has_prefix = {
1006 let snapshot = editor.display_snapshot(cx);
1007 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
1008 let offset = cursor.to_offset(&snapshot);
1009 let buffer_snapshot = snapshot.buffer_snapshot();
1010 let prefix_char_count = prefix.chars().count();
1011 buffer_snapshot
1012 .reversed_chars_at(offset)
1013 .take(prefix_char_count)
1014 .eq(prefix.chars().rev())
1015 };
1016
1017 if menu_is_open && has_prefix {
1018 return;
1019 }
1020
1021 editor.insert(&prefix, window, cx);
1022 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
1023 })
1024 .log_err();
1025 })
1026 .detach();
1027 }
1028
1029 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
1030 self.send(cx);
1031 }
1032
1033 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
1034 if self.is_empty(cx) {
1035 return;
1036 }
1037
1038 self.editor.update(cx, |editor, cx| {
1039 editor.clear_inlay_hints(cx);
1040 });
1041
1042 cx.emit(MessageEditorEvent::SendImmediately)
1043 }
1044
1045 fn chat_with_follow(
1046 &mut self,
1047 _: &ChatWithFollow,
1048 window: &mut Window,
1049 cx: &mut Context<Self>,
1050 ) {
1051 self.workspace
1052 .update(cx, |this, cx| {
1053 this.follow(CollaboratorId::Agent, window, cx)
1054 })
1055 .log_err();
1056
1057 self.send(cx);
1058 }
1059
1060 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
1061 cx.emit(MessageEditorEvent::Cancel)
1062 }
1063
1064 pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
1065 let Some(clipboard) = cx.read_from_clipboard() else {
1066 return;
1067 };
1068
1069 if self.editor.read(cx).read_only(cx) {
1070 let editor = self.editor.read(cx);
1071 let cursor_offset = editor
1072 .selections
1073 .newest_anchor()
1074 .head()
1075 .to_offset(&editor.buffer().read(cx).snapshot(cx))
1076 .0;
1077 cx.emit(MessageEditorEvent::InputAttempted {
1078 attempt: InputAttempt::Paste(clipboard),
1079 cursor_offset,
1080 });
1081 cx.stop_propagation();
1082 return;
1083 }
1084
1085 cx.stop_propagation();
1086 self.paste_item(&clipboard, window, cx);
1087 }
1088
1089 pub fn paste_item(
1090 &mut self,
1091 clipboard: &ClipboardItem,
1092 window: &mut Window,
1093 cx: &mut Context<Self>,
1094 ) {
1095 let Some(workspace) = self.workspace.upgrade() else {
1096 return;
1097 };
1098 let editor_clipboard_selections =
1099 clipboard.entries().iter().find_map(|entry| match entry {
1100 ClipboardEntry::String(text) => {
1101 text.metadata_json::<Vec<editor::ClipboardSelection>>()
1102 }
1103 _ => None,
1104 });
1105
1106 // Insert creases for pasted clipboard selections that:
1107 // 1. Contain exactly one selection
1108 // 2. Have an associated file path
1109 // 3. Span multiple lines (not single-line selections)
1110 // 4. Belong to a file that exists in the current project
1111 let should_insert_creases = util::maybe!({
1112 let selections = editor_clipboard_selections.as_ref()?;
1113 if selections.len() > 1 {
1114 return Some(false);
1115 }
1116 let selection = selections.first()?;
1117 let file_path = selection.file_path.as_ref()?;
1118 let line_range = selection.line_range.as_ref()?;
1119
1120 if line_range.start() == line_range.end() {
1121 return Some(false);
1122 }
1123
1124 Some(
1125 workspace
1126 .read(cx)
1127 .project()
1128 .read(cx)
1129 .project_path_for_absolute_path(file_path, cx)
1130 .is_some(),
1131 )
1132 })
1133 .unwrap_or(false);
1134
1135 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
1136 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
1137 let (insertion_target, _) = snapshot
1138 .anchor_to_buffer_anchor(self.editor.read(cx).selections.newest_anchor().start)
1139 .unwrap();
1140
1141 let project = workspace.read(cx).project().clone();
1142 for selection in selections {
1143 if let (Some(file_path), Some(line_range)) =
1144 (selection.file_path, selection.line_range)
1145 {
1146 let crease_text =
1147 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
1148
1149 let mention_uri = MentionUri::Selection {
1150 abs_path: Some(file_path.clone()),
1151 line_range: line_range.clone(),
1152 column: None,
1153 };
1154
1155 let mention_text = mention_uri.as_link().to_string();
1156 let (text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
1157 let buffer = editor.buffer().read(cx);
1158 let snapshot = buffer.snapshot(cx);
1159 let buffer_snapshot = snapshot.as_singleton().unwrap();
1160 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
1161
1162 editor.insert(&mention_text, window, cx);
1163 editor.insert(" ", window, cx);
1164
1165 (text_anchor, mention_text.len())
1166 });
1167
1168 let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
1169 text_anchor,
1170 content_len,
1171 crease_text.into(),
1172 mention_uri.icon_path(cx),
1173 mention_uri.tooltip_text(),
1174 Some(mention_uri.clone()),
1175 Some(self.workspace.clone()),
1176 None,
1177 self.editor.clone(),
1178 window,
1179 cx,
1180 ) else {
1181 continue;
1182 };
1183 drop(tx);
1184
1185 let mention_task = cx
1186 .spawn({
1187 let project = project.clone();
1188 async move |_, cx| {
1189 let project_path = project
1190 .update(cx, |project, cx| {
1191 project.project_path_for_absolute_path(&file_path, cx)
1192 })
1193 .ok_or_else(|| {
1194 format!(
1195 "project path not found for pasted selection {file_path:?}"
1196 )
1197 })?;
1198
1199 let buffer = project
1200 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1201 .await
1202 .map_err(|e| e.to_string())?;
1203
1204 Ok(buffer.update(cx, |buffer, cx| {
1205 let start =
1206 Point::new(*line_range.start(), 0).min(buffer.max_point());
1207 let end = Point::new(*line_range.end() + 1, 0)
1208 .min(buffer.max_point());
1209 let content = buffer.text_for_range(start..end).collect();
1210 Mention::Text {
1211 content,
1212 tracked_buffers: vec![cx.entity()],
1213 }
1214 }))
1215 }
1216 })
1217 .shared();
1218
1219 self.mention_set.update(cx, |mention_set, cx| {
1220 mention_set.insert_mention(
1221 crease_id,
1222 mention_uri.clone(),
1223 mention_task,
1224 crease_entity,
1225 cx,
1226 )
1227 });
1228 }
1229 }
1230 return;
1231 }
1232 // Handle text paste with potential markdown mention links before
1233 // clipboard context entries so markdown text still pastes as text.
1234 let clipboard_text = clipboard.entries().iter().find_map(|entry| match entry {
1235 ClipboardEntry::String(text) => Some(text.text().to_string()),
1236 _ => None,
1237 });
1238 if let Some(clipboard_text) = clipboard_text.as_deref() {
1239 if clipboard_text.contains("[@") {
1240 let selections_before = self.editor.update(cx, |editor, cx| {
1241 let snapshot = editor.buffer().read(cx).snapshot(cx);
1242 editor
1243 .selections
1244 .disjoint_anchors()
1245 .iter()
1246 .map(|selection| {
1247 (
1248 selection.start.bias_left(&snapshot),
1249 selection.end.bias_right(&snapshot),
1250 )
1251 })
1252 .collect::<Vec<_>>()
1253 });
1254
1255 self.editor.update(cx, |editor, cx| {
1256 editor.insert(clipboard_text, window, cx);
1257 });
1258
1259 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
1260 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1261
1262 let mut all_mentions = Vec::new();
1263 for (start_anchor, end_anchor) in selections_before {
1264 let start_offset = start_anchor.to_offset(&snapshot);
1265 let end_offset = end_anchor.to_offset(&snapshot);
1266
1267 // Get the actual inserted text from the buffer (may differ due to auto-indent)
1268 let inserted_text: String =
1269 snapshot.text_for_range(start_offset..end_offset).collect();
1270
1271 let parsed_mentions = parse_mention_links(&inserted_text, path_style);
1272 for (range, mention_uri) in parsed_mentions {
1273 let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start);
1274 let anchor = snapshot.anchor_before(mention_start_offset);
1275 let content_len = range.end - range.start;
1276 all_mentions.push((anchor, content_len, mention_uri));
1277 }
1278 }
1279
1280 if !all_mentions.is_empty() {
1281 let supports_images = self.session_capabilities.read().supports_images();
1282 let http_client = workspace.read(cx).client().http_client();
1283
1284 for (anchor, content_len, mention_uri) in all_mentions {
1285 let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
1286 snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
1287 content_len,
1288 mention_uri.name().into(),
1289 mention_uri.icon_path(cx),
1290 mention_uri.tooltip_text(),
1291 Some(mention_uri.clone()),
1292 Some(self.workspace.clone()),
1293 None,
1294 self.editor.clone(),
1295 window,
1296 cx,
1297 ) else {
1298 continue;
1299 };
1300
1301 // Create the confirmation task based on the mention URI type.
1302 // This properly loads file content, fetches URLs, etc.
1303 let task = self.mention_set.update(cx, |mention_set, cx| {
1304 mention_set.confirm_mention_for_uri(
1305 mention_uri.clone(),
1306 supports_images,
1307 http_client.clone(),
1308 cx,
1309 )
1310 });
1311 let task = cx
1312 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
1313 .shared();
1314
1315 self.mention_set.update(cx, |mention_set, cx| {
1316 mention_set.insert_mention(
1317 crease_id,
1318 mention_uri.clone(),
1319 task.clone(),
1320 crease_entity,
1321 cx,
1322 )
1323 });
1324
1325 // Drop the tx after inserting to signal the crease is ready
1326 drop(tx);
1327 }
1328 return;
1329 }
1330 }
1331 }
1332
1333 if self.handle_pasted_context(clipboard, window, cx) {
1334 return;
1335 }
1336
1337 self.editor.update(cx, |editor, cx| {
1338 editor.paste_item(clipboard, window, cx);
1339 });
1340 }
1341
1342 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
1343 let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else {
1344 cx.propagate();
1345 return;
1346 };
1347
1348 cx.stop_propagation();
1349 cx.write_to_clipboard(ClipboardItem::new_string(text));
1350 }
1351
1352 fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
1353 let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else {
1354 cx.propagate();
1355 return;
1356 };
1357
1358 cx.stop_propagation();
1359 self.editor.update(cx, |editor, cx| {
1360 editor.transact(window, cx, |editor, window, cx| {
1361 editor.change_selections(Default::default(), window, cx, |selections| {
1362 selections.select_ranges(ranges);
1363 });
1364 editor.insert("", window, cx);
1365 });
1366 });
1367 cx.write_to_clipboard(ClipboardItem::new_string(text));
1368 }
1369
1370 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
1371 let editor = self.editor.clone();
1372 window.defer(cx, move |window, cx| {
1373 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
1374 });
1375 }
1376
1377 fn handle_pasted_context(
1378 &mut self,
1379 clipboard: &ClipboardItem,
1380 window: &mut Window,
1381 cx: &mut Context<Self>,
1382 ) -> bool {
1383 if matches!(
1384 clipboard.entries().first(),
1385 Some(ClipboardEntry::String(_)) | None
1386 ) {
1387 return false;
1388 }
1389
1390 let Some(workspace) = self.workspace.upgrade() else {
1391 return false;
1392 };
1393 let project = workspace.read(cx).project().clone();
1394 let project_is_local = project.read(cx).is_local();
1395 let supports_images = self.session_capabilities.read().supports_images();
1396 if !project_is_local && !supports_images {
1397 return false;
1398 }
1399 let editor = self.editor.clone();
1400 let mention_set = self.mention_set.clone();
1401 let workspace = self.workspace.clone();
1402 let entries = clipboard.clone().into_entries().collect::<Vec<_>>();
1403
1404 window
1405 .spawn(cx, async move |mut cx| {
1406 let (items, added_worktrees) = resolve_pasted_context_items(
1407 project,
1408 project_is_local,
1409 supports_images,
1410 entries,
1411 &mut cx,
1412 )
1413 .await;
1414 insert_resolved_pasted_context_items(
1415 items,
1416 added_worktrees,
1417 editor,
1418 mention_set,
1419 workspace,
1420 supports_images,
1421 &mut cx,
1422 )
1423 .await;
1424 Ok::<(), anyhow::Error>(())
1425 })
1426 .detach_and_log_err(cx);
1427
1428 true
1429 }
1430
1431 pub fn insert_dragged_files(
1432 &mut self,
1433 paths: Vec<project::ProjectPath>,
1434 added_worktrees: Vec<Entity<Worktree>>,
1435 window: &mut Window,
1436 cx: &mut Context<Self>,
1437 ) {
1438 let Some(workspace) = self.workspace.upgrade() else {
1439 return;
1440 };
1441 let project = workspace.read(cx).project().clone();
1442 let supports_images = self.session_capabilities.read().supports_images();
1443 let mut tasks = Vec::new();
1444 for path in paths {
1445 if let Some(task) = insert_mention_for_project_path(
1446 &path,
1447 &self.editor,
1448 &self.mention_set,
1449 &project,
1450 &workspace,
1451 supports_images,
1452 window,
1453 cx,
1454 ) {
1455 tasks.push(task);
1456 }
1457 }
1458 cx.spawn(async move |_, _| {
1459 join_all(tasks).await;
1460 drop(added_worktrees);
1461 })
1462 .detach();
1463 }
1464
1465 pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1466 let Some(workspace) = self.workspace.upgrade() else {
1467 return;
1468 };
1469
1470 let project = workspace.read(cx).project().clone();
1471
1472 let Some(repo) = project.read(cx).active_repository(cx) else {
1473 return;
1474 };
1475
1476 let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
1477 let editor = self.editor.clone();
1478 let mention_set = self.mention_set.clone();
1479 let weak_workspace = self.workspace.clone();
1480
1481 window
1482 .spawn(cx, async move |cx| {
1483 let base_ref: SharedString = default_branch_receiver
1484 .await
1485 .ok()
1486 .and_then(|r| r.ok())
1487 .flatten()
1488 .ok_or_else(|| anyhow!("Could not determine default branch"))?;
1489
1490 cx.update(|window, cx| {
1491 let mention_uri = MentionUri::GitDiff {
1492 base_ref: base_ref.to_string(),
1493 };
1494 let mention_text = mention_uri.as_link().to_string();
1495
1496 let (text_anchor, content_len) = editor.update(cx, |editor, cx| {
1497 let buffer = editor.buffer().read(cx);
1498 let snapshot = buffer.snapshot(cx);
1499 let buffer_snapshot = snapshot.as_singleton().unwrap();
1500 let text_anchor = snapshot
1501 .anchor_to_buffer_anchor(editor.selections.newest_anchor().start)
1502 .unwrap()
1503 .0
1504 .bias_left(&buffer_snapshot);
1505
1506 editor.insert(&mention_text, window, cx);
1507 editor.insert(" ", window, cx);
1508
1509 (text_anchor, mention_text.len())
1510 });
1511
1512 let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
1513 text_anchor,
1514 content_len,
1515 mention_uri.name().into(),
1516 mention_uri.icon_path(cx),
1517 mention_uri.tooltip_text(),
1518 Some(mention_uri.clone()),
1519 Some(weak_workspace),
1520 None,
1521 editor,
1522 window,
1523 cx,
1524 ) else {
1525 return;
1526 };
1527 drop(tx);
1528
1529 let confirm_task = mention_set.update(cx, |mention_set, cx| {
1530 mention_set.confirm_mention_for_git_diff(base_ref, cx)
1531 });
1532
1533 let mention_task = cx
1534 .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
1535 .shared();
1536
1537 mention_set.update(cx, |mention_set, cx| {
1538 mention_set.insert_mention(
1539 crease_id,
1540 mention_uri,
1541 mention_task,
1542 crease_entity,
1543 cx,
1544 );
1545 });
1546 })
1547 })
1548 .detach_and_log_err(cx);
1549 }
1550
1551 pub fn insert_skill_crease(
1552 &mut self,
1553 skill: &AvailableSkill,
1554 window: &mut Window,
1555 cx: &mut Context<Self>,
1556 ) {
1557 let Some(workspace) = self.workspace.upgrade() else {
1558 return;
1559 };
1560
1561 let mention_uri = MentionUri::Skill {
1562 name: skill.name.to_string(),
1563 source: skill.source.to_string(),
1564 skill_file_path: skill.skill_file_path.clone(),
1565 };
1566
1567 let link_text = mention_uri.as_link().to_string();
1568 let content_len = link_text.len();
1569 let mention_text = format!("{} ", link_text);
1570 let crease_text: SharedString = mention_uri.name().into();
1571
1572 let start_anchor = self.editor.update(cx, |editor, cx| {
1573 let snapshot = editor.buffer().read(cx).snapshot(cx);
1574 let buffer_snapshot = snapshot.as_singleton()?;
1575 let cursor = editor.selections.newest_anchor().start;
1576 let text_anchor = snapshot
1577 .anchor_to_buffer_anchor(cursor)?
1578 .0
1579 .bias_left(buffer_snapshot);
1580
1581 editor.insert(&mention_text, window, cx);
1582 Some(text_anchor)
1583 });
1584
1585 let Some(start_anchor) = start_anchor else {
1586 return;
1587 };
1588
1589 self.mention_set
1590 .update(cx, |mention_set, cx| {
1591 mention_set.confirm_mention_completion(
1592 crease_text,
1593 start_anchor,
1594 content_len,
1595 mention_uri,
1596 false,
1597 self.editor.clone(),
1598 &workspace,
1599 window,
1600 cx,
1601 )
1602 })
1603 .detach();
1604 }
1605
1606 pub(crate) fn insert_selections(
1607 &mut self,
1608 selection: AgentContextSelection,
1609 window: &mut Window,
1610 cx: &mut Context<Self>,
1611 ) {
1612 let editor = self.editor.read(cx);
1613 let editor_buffer = editor.buffer().read(cx);
1614 let Some(buffer) = editor_buffer.as_singleton() else {
1615 return;
1616 };
1617 let cursor_anchor = editor.selections.newest_anchor().head();
1618 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1619 let anchor = buffer.update(cx, |buffer, _cx| {
1620 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1621 });
1622 let Some(completion) =
1623 PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
1624 PromptContextAction::AddSelections,
1625 anchor..anchor,
1626 self.editor.downgrade(),
1627 self.mention_set.downgrade(),
1628 Some(selection),
1629 )
1630 else {
1631 return;
1632 };
1633
1634 self.editor.update(cx, |message_editor, cx| {
1635 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1636 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1637 });
1638 if let Some(confirm) = completion.confirm {
1639 confirm(CompletionIntent::Complete, window, cx);
1640 }
1641 }
1642
1643 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1644 if !self.session_capabilities.read().supports_images() {
1645 return;
1646 }
1647
1648 let editor = self.editor.clone();
1649 let mention_set = self.mention_set.clone();
1650 let workspace = self.workspace.clone();
1651
1652 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1653 files: true,
1654 directories: false,
1655 multiple: true,
1656 prompt: Some("Select Images".into()),
1657 });
1658
1659 window
1660 .spawn(cx, async move |cx| {
1661 let paths = match paths_receiver.await {
1662 Ok(Ok(Some(paths))) => paths,
1663 _ => return Ok::<(), anyhow::Error>(()),
1664 };
1665
1666 let default_image_name: SharedString = "Image".into();
1667 let images = cx
1668 .background_spawn(async move {
1669 paths
1670 .into_iter()
1671 .filter_map(|path| {
1672 crate::mention_set::load_external_image_from_path(
1673 &path,
1674 &default_image_name,
1675 )
1676 })
1677 .collect::<Vec<_>>()
1678 })
1679 .await;
1680
1681 crate::mention_set::insert_images_as_context(
1682 images,
1683 editor,
1684 mention_set,
1685 workspace,
1686 cx,
1687 )
1688 .await;
1689 Ok(())
1690 })
1691 .detach_and_log_err(cx);
1692 }
1693
1694 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1695 self.editor.update(cx, |message_editor, cx| {
1696 message_editor.set_read_only(read_only);
1697 cx.notify()
1698 })
1699 }
1700
1701 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1702 self.editor.update(cx, |editor, cx| {
1703 if *editor.mode() != mode {
1704 editor.set_mode(mode);
1705 cx.notify()
1706 }
1707 });
1708 }
1709
1710 pub fn set_message(
1711 &mut self,
1712 message: Vec<acp::ContentBlock>,
1713 window: &mut Window,
1714 cx: &mut Context<Self>,
1715 ) {
1716 self.clear(window, cx);
1717 self.insert_message_blocks(message, false, window, cx);
1718 }
1719
1720 pub fn append_message(
1721 &mut self,
1722 message: Vec<acp::ContentBlock>,
1723 separator: Option<&str>,
1724 window: &mut Window,
1725 cx: &mut Context<Self>,
1726 ) {
1727 if message.is_empty() {
1728 return;
1729 }
1730
1731 if let Some(separator) = separator
1732 && !separator.is_empty()
1733 && !self.is_empty(cx)
1734 {
1735 self.editor.update(cx, |editor, cx| {
1736 editor.insert(separator, window, cx);
1737 });
1738 }
1739
1740 self.insert_message_blocks(message, true, window, cx);
1741 }
1742
1743 fn insert_message_blocks(
1744 &mut self,
1745 message: Vec<acp::ContentBlock>,
1746 append_to_existing: bool,
1747 window: &mut Window,
1748 cx: &mut Context<Self>,
1749 ) {
1750 let Some(workspace) = self.workspace.upgrade() else {
1751 return;
1752 };
1753
1754 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1755 let mut text = String::new();
1756 let mut mentions = Vec::new();
1757 let append_normalized = |text: &mut String, mut segment: String| {
1758 LineEnding::normalize(&mut segment);
1759 text.push_str(&segment);
1760 };
1761
1762 for chunk in message {
1763 match chunk {
1764 acp::ContentBlock::Text(text_content) => {
1765 append_normalized(&mut text, text_content.text);
1766 }
1767 acp::ContentBlock::Resource(acp::EmbeddedResource {
1768 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1769 ..
1770 }) => {
1771 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1772 else {
1773 continue;
1774 };
1775 let start = text.len();
1776 append_normalized(&mut text, mention_uri.as_link().to_string());
1777 let end = text.len();
1778 mentions.push((
1779 start..end,
1780 mention_uri,
1781 Mention::Text {
1782 content: resource.text,
1783 tracked_buffers: Vec::new(),
1784 },
1785 ));
1786 }
1787 acp::ContentBlock::ResourceLink(resource) => {
1788 if let Some(mention_uri) =
1789 MentionUri::parse(&resource.uri, path_style).log_err()
1790 {
1791 let start = text.len();
1792 append_normalized(&mut text, mention_uri.as_link().to_string());
1793 let end = text.len();
1794 mentions.push((start..end, mention_uri, Mention::Link));
1795 }
1796 }
1797 acp::ContentBlock::Image(acp::ImageContent {
1798 uri,
1799 data,
1800 mime_type,
1801 ..
1802 }) => {
1803 let mention_uri = if let Some(uri) = uri {
1804 MentionUri::parse(&uri, path_style)
1805 } else {
1806 Ok(MentionUri::PastedImage {
1807 name: "Image".to_string(),
1808 })
1809 };
1810 let Some(mention_uri) = mention_uri.log_err() else {
1811 continue;
1812 };
1813 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1814 log::error!("failed to parse MIME type for image: {mime_type:?}");
1815 continue;
1816 };
1817 let start = text.len();
1818 append_normalized(&mut text, mention_uri.as_link().to_string());
1819 let end = text.len();
1820 mentions.push((
1821 start..end,
1822 mention_uri,
1823 Mention::Image(MentionImage {
1824 data: data.into(),
1825 format,
1826 }),
1827 ));
1828 }
1829 _ => {}
1830 }
1831 }
1832
1833 if text.is_empty() && mentions.is_empty() {
1834 return;
1835 }
1836
1837 let insertion_start = if append_to_existing {
1838 self.editor.read(cx).text(cx).len()
1839 } else {
1840 0
1841 };
1842
1843 let snapshot = if append_to_existing {
1844 self.editor.update(cx, |editor, cx| {
1845 editor.insert(&text, window, cx);
1846 editor.buffer().read(cx).snapshot(cx)
1847 })
1848 } else {
1849 self.editor.update(cx, |editor, cx| {
1850 editor.set_text(text, window, cx);
1851 editor.buffer().read(cx).snapshot(cx)
1852 })
1853 };
1854
1855 for (range, mention_uri, mention) in mentions {
1856 let adjusted_start = insertion_start + range.start;
1857 let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
1858 let image_preview = image_preview_task_for_mention(&mention);
1859 let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
1860 snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
1861 range.end - range.start,
1862 mention_uri.name().into(),
1863 mention_uri.icon_path(cx),
1864 mention_uri.tooltip_text(),
1865 Some(mention_uri.clone()),
1866 Some(self.workspace.clone()),
1867 image_preview,
1868 self.editor.clone(),
1869 window,
1870 cx,
1871 ) else {
1872 continue;
1873 };
1874 drop(tx);
1875
1876 self.mention_set.update(cx, |mention_set, cx| {
1877 mention_set.insert_mention(
1878 crease_id,
1879 mention_uri.clone(),
1880 Task::ready(Ok(mention)).shared(),
1881 crease_entity,
1882 cx,
1883 )
1884 });
1885 }
1886
1887 cx.notify();
1888 }
1889
1890 pub fn text(&self, cx: &App) -> String {
1891 self.editor.read(cx).text(cx)
1892 }
1893
1894 pub fn set_cursor_offset(
1895 &mut self,
1896 offset: usize,
1897 window: &mut Window,
1898 cx: &mut Context<Self>,
1899 ) {
1900 self.editor.update(cx, |editor, cx| {
1901 let snapshot = editor.buffer().read(cx).snapshot(cx);
1902 let offset = snapshot.clip_offset(MultiBufferOffset(offset), text::Bias::Left);
1903 editor.change_selections(Default::default(), window, cx, |selections| {
1904 selections.select_ranges([offset..offset]);
1905 });
1906 });
1907 }
1908
1909 pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1910 if text.is_empty() {
1911 return;
1912 }
1913
1914 self.editor.update(cx, |editor, cx| {
1915 editor.insert(text, window, cx);
1916 });
1917 }
1918
1919 pub fn set_placeholder_text(
1920 &mut self,
1921 placeholder: &str,
1922 window: &mut Window,
1923 cx: &mut Context<Self>,
1924 ) {
1925 self.editor.update(cx, |editor, cx| {
1926 editor.set_placeholder_text(placeholder, window, cx);
1927 });
1928 }
1929
1930 #[cfg(any(test, feature = "test-support"))]
1931 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1932 self.editor.update(cx, |editor, cx| {
1933 editor.set_text(text, window, cx);
1934 });
1935 }
1936
1937 fn serialize_selection_with_mentions(
1938 &self,
1939 expand_empty_to_line: bool,
1940 cx: &mut App,
1941 ) -> Option<(String, Vec<Range<MultiBufferOffset>>)> {
1942 if self.mention_set.read(cx).is_empty() {
1943 return None;
1944 }
1945
1946 let display_snapshot = self
1947 .editor
1948 .update(cx, |editor, cx| editor.display_snapshot(cx));
1949 let editor = self.editor.read(cx);
1950 if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) {
1951 return None;
1952 }
1953
1954 let snapshot = editor.buffer().read(cx).snapshot(cx);
1955 let mention_set = self.mention_set.read(cx);
1956 let mention_ranges = display_snapshot
1957 .crease_snapshot
1958 .crease_items_with_offsets(&snapshot)
1959 .into_iter()
1960 .filter_map(|(crease_id, range)| {
1961 mention_set.mention_uri_for_crease(&crease_id).map(|uri| {
1962 (
1963 range.start.to_offset(&snapshot),
1964 range.end.to_offset(&snapshot),
1965 uri,
1966 )
1967 })
1968 })
1969 .collect::<Vec<_>>();
1970
1971 let line_mode = editor.selections.line_mode();
1972 let max_point = snapshot.max_point();
1973 let point_selections = editor.selections.all::<Point>(&display_snapshot);
1974
1975 let mut text = String::new();
1976 let mut ranges = Vec::with_capacity(point_selections.len());
1977 let mut has_mentions = false;
1978 let mut is_first = true;
1979 let mut prev_was_entire_line = false;
1980
1981 for mut selection in point_selections {
1982 let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode;
1983 if is_entire_line {
1984 selection.start = Point::new(selection.start.row, 0);
1985 if !selection.is_empty() && selection.end.column == 0 {
1986 selection.end = min(max_point, selection.end);
1987 } else {
1988 selection.end = min(max_point, Point::new(selection.end.row + 1, 0));
1989 }
1990 }
1991 let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
1992
1993 if is_first {
1994 is_first = false;
1995 } else if !prev_was_entire_line {
1996 text.push('\n');
1997 }
1998 prev_was_entire_line = is_entire_line;
1999
2000 let mut cursor = range.start;
2001 for (start, end, uri) in mention_ranges
2002 .iter()
2003 .filter(|(start, end, _)| *start < range.end && range.start < *end)
2004 {
2005 if cursor < *start {
2006 text.extend(snapshot.text_for_range(cursor..*start));
2007 }
2008 write!(text, "{}", uri.as_link()).unwrap();
2009 cursor = *end;
2010 has_mentions = true;
2011 }
2012 if cursor < range.end {
2013 text.extend(snapshot.text_for_range(cursor..range.end));
2014 }
2015
2016 ranges.push(range);
2017 }
2018
2019 has_mentions.then_some((text, ranges))
2020 }
2021}
2022
2023impl Focusable for MessageEditor {
2024 fn focus_handle(&self, cx: &App) -> FocusHandle {
2025 self.editor.focus_handle(cx)
2026 }
2027}
2028
2029impl Render for MessageEditor {
2030 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2031 div()
2032 .key_context("MessageEditor")
2033 .on_action(cx.listener(Self::chat))
2034 .on_action(cx.listener(Self::send_immediately))
2035 .on_action(cx.listener(Self::chat_with_follow))
2036 .on_action(cx.listener(Self::cancel))
2037 .capture_action(cx.listener(Self::copy))
2038 .capture_action(cx.listener(Self::cut))
2039 .on_action(cx.listener(Self::paste_raw))
2040 .capture_action(cx.listener(Self::paste))
2041 .flex_1()
2042 .child({
2043 let settings = ThemeSettings::get_global(cx);
2044
2045 let text_style = TextStyle {
2046 color: cx.theme().colors().text,
2047 font_family: settings.buffer_font.family.clone(),
2048 font_fallbacks: settings.buffer_font.fallbacks.clone(),
2049 font_features: settings.buffer_font.features.clone(),
2050 font_size: settings.agent_buffer_font_size(cx).into(),
2051 font_weight: settings.buffer_font.weight,
2052 line_height: relative(settings.buffer_line_height.value()),
2053 ..Default::default()
2054 };
2055
2056 EditorElement::new(
2057 &self.editor,
2058 EditorStyle {
2059 background: cx.theme().colors().editor_background,
2060 local_player: cx.theme().players().local(),
2061 text: text_style,
2062 syntax: cx.theme().syntax().clone(),
2063 inlay_hints_style: editor::make_inlay_hints_style(cx),
2064 ..Default::default()
2065 },
2066 )
2067 })
2068 }
2069}
2070
2071pub struct MessageEditorAddon {}
2072
2073impl MessageEditorAddon {
2074 pub fn new() -> Self {
2075 Self {}
2076 }
2077}
2078
2079impl Addon for MessageEditorAddon {
2080 fn to_any(&self) -> &dyn std::any::Any {
2081 self
2082 }
2083
2084 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
2085 Some(self)
2086 }
2087
2088 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
2089 let settings = agent_settings::AgentSettings::get_global(cx);
2090 if settings.use_modifier_to_send {
2091 key_context.add("use_modifier_to_send");
2092 }
2093 }
2094}
2095
2096/// Walks the editor's creases in order, interleaving plain-text chunks from
2097/// `text` with mention blocks produced from `resolve`.
2098fn build_chunks_from_creases(
2099 text: &str,
2100 crease_snapshot: &CreaseSnapshot,
2101 buffer_snapshot: &MultiBufferSnapshot,
2102 supports_embedded_context: bool,
2103 mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option<Mention>)>,
2104) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
2105 let mut ix = text
2106 .char_indices()
2107 .find(|(_, c)| !c.is_whitespace())
2108 .map_or(text.len(), |(i, _)| i);
2109 let mut chunks = Vec::new();
2110 let mut tracked_buffers = Vec::new();
2111
2112 for (crease_id, crease) in crease_snapshot.creases() {
2113 let Some((uri, mention)) = resolve(&crease_id) else {
2114 continue;
2115 };
2116 let crease_range = crease.range().to_offset(buffer_snapshot);
2117 if crease_range.start.0 > ix {
2118 chunks.push(text[ix..crease_range.start.0].into());
2119 }
2120 chunks.push(mention_to_content_block(
2121 &uri,
2122 mention.as_ref(),
2123 supports_embedded_context,
2124 &mut tracked_buffers,
2125 ));
2126 ix = crease_range.end.0;
2127 }
2128
2129 if ix < text.len() {
2130 let last_chunk = text[ix..].trim_end().to_owned();
2131 if !last_chunk.is_empty() {
2132 chunks.push(last_chunk.into());
2133 }
2134 }
2135 (chunks, tracked_buffers)
2136}
2137
2138fn image_preview_task_for_mention(
2139 mention: &Mention,
2140) -> Option<futures::future::Shared<Task<Result<Arc<Image>, String>>>> {
2141 let Mention::Image(mention_image) = mention else {
2142 return None;
2143 };
2144
2145 let bytes =
2146 match base64::engine::general_purpose::STANDARD.decode(mention_image.data.as_bytes()) {
2147 Ok(bytes) => bytes,
2148 Err(error) => {
2149 log::error!("failed to decode image mention: {error}");
2150 return None;
2151 }
2152 };
2153
2154 Some(
2155 Task::ready(Ok::<Arc<Image>, String>(Arc::new(Image::from_bytes(
2156 mention_image.format,
2157 bytes,
2158 ))))
2159 .shared(),
2160 )
2161}
2162
2163fn mention_to_content_block(
2164 uri: &MentionUri,
2165 mention: Option<&Mention>,
2166 supports_embedded_context: bool,
2167 tracked_buffers: &mut Vec<Entity<Buffer>>,
2168) -> acp::ContentBlock {
2169 match mention {
2170 Some(Mention::Text {
2171 content,
2172 tracked_buffers: mention_tracked_buffers,
2173 }) => {
2174 tracked_buffers.extend(mention_tracked_buffers.iter().cloned());
2175 if supports_embedded_context {
2176 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2177 acp::EmbeddedResourceResource::TextResourceContents(
2178 acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()),
2179 ),
2180 ))
2181 } else {
2182 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
2183 uri.name(),
2184 uri.to_uri().to_string(),
2185 ))
2186 }
2187 }
2188 Some(Mention::Image(mention_image)) => acp::ContentBlock::Image(
2189 acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type())
2190 .uri(match uri {
2191 MentionUri::File { .. } | MentionUri::PastedImage { .. } => {
2192 Some(uri.to_uri().to_string())
2193 }
2194 other => {
2195 debug_panic!("unexpected mention uri for image: {:?}", other);
2196 None
2197 }
2198 }),
2199 ),
2200 _ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
2201 uri.name(),
2202 uri.to_uri().to_string(),
2203 )),
2204 }
2205}
2206
2207/// Parses markdown mention links in the format `[@name](uri)` from text.
2208/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
2209fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
2210 let mut mentions = Vec::new();
2211 let mut search_start = 0;
2212
2213 while let Some(link_start) = text[search_start..].find("[@") {
2214 let absolute_start = search_start + link_start;
2215
2216 // Find the matching closing bracket for the name, handling nested brackets.
2217 // Start at the '[' character so find_matching_bracket can track depth correctly.
2218 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
2219 search_start = absolute_start + 2;
2220 continue;
2221 };
2222 let name_end = absolute_start + name_end;
2223
2224 // Check for opening parenthesis immediately after
2225 if text.get(name_end + 1..name_end + 2) != Some("(") {
2226 search_start = name_end + 1;
2227 continue;
2228 }
2229
2230 // Find the matching closing parenthesis for the URI, handling nested parens
2231 let uri_start = name_end + 2;
2232 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
2233 search_start = uri_start;
2234 continue;
2235 };
2236 let uri_end = name_end + 1 + uri_end_relative;
2237 let link_end = uri_end + 1;
2238
2239 let uri_str = &text[uri_start..uri_end];
2240
2241 // Try to parse the URI as a MentionUri
2242 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
2243 mentions.push((absolute_start..link_end, mention_uri));
2244 }
2245
2246 search_start = link_end;
2247 }
2248
2249 mentions
2250}
2251
2252/// Finds the position of the matching closing bracket, handling nested brackets.
2253/// The input `text` should start with the opening bracket.
2254/// Returns the index of the matching closing bracket relative to `text`.
2255fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
2256 let mut depth = 0;
2257 for (index, character) in text.char_indices() {
2258 if character == open {
2259 depth += 1;
2260 } else if character == close {
2261 depth -= 1;
2262 if depth == 0 {
2263 return Some(index);
2264 }
2265 }
2266 }
2267 None
2268}
2269
2270#[cfg(test)]
2271mod tests {
2272 use std::{ops::Range, path::Path, path::PathBuf, rc::Rc, sync::Arc};
2273
2274 use super::PromptLocalCommand;
2275 use acp_thread::MentionUri;
2276 use agent::{ThreadStore, outline};
2277 use agent_client_protocol::schema::v1 as acp;
2278 use base64::Engine as _;
2279 use editor::{
2280 AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
2281 actions::{Cut, Paste},
2282 };
2283
2284 use fs::FakeFs;
2285 use futures::{FutureExt as _, StreamExt as _};
2286 use gpui::{
2287 AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths,
2288 FocusHandle, Focusable, Task, TestAppContext, VisualTestContext,
2289 };
2290 use language_model::LanguageModelRegistry;
2291 use lsp::{CompletionContext, CompletionTriggerKind};
2292 use parking_lot::RwLock;
2293 use project::{AgentId, CompletionIntent, Project, ProjectPath};
2294 use serde_json::{Value, json};
2295
2296 #[test]
2297 fn exo_can_declare_that_it_cannot_steer_mid_turn() {
2298 let capabilities = super::SessionCapabilities::default()
2299 .with_omega_steer_capability(omega_front_door::SteerCapability::CannotSteer);
2300 assert_eq!(
2301 capabilities.omega_steer_capability(),
2302 omega_front_door::SteerCapability::CannotSteer
2303 );
2304 }
2305
2306 use text::Point;
2307 use ui::{App, Context, IntoElement, Render, SharedString, Window};
2308 use util::{path, paths::PathStyle, rel_path::rel_path};
2309 use workspace::{AppState, Item, MultiWorkspace, Workspace};
2310
2311 use crate::completion_provider::{AgentContextSelection, AvailableSkill, PromptContextType};
2312 use crate::{
2313 conversation_view::tests::init_test,
2314 mention_set::insert_crease_for_mention,
2315 message_editor::{
2316 Mention, MessageEditor, MessageEditorEvent, SessionCapabilities, parse_mention_links,
2317 },
2318 };
2319
2320 #[test]
2321 fn test_session_capabilities_keep_commands_and_skills_separate() {
2322 let skill_file_path = PathBuf::from("/tmp/SKILL.md");
2323 let skill = AvailableSkill {
2324 name: "deploy".into(),
2325 description: "Deploy the app".into(),
2326 source: "".into(),
2327 skill_file_path: skill_file_path.clone(),
2328 warning: None,
2329 };
2330 let session_capabilities = SessionCapabilities::new(
2331 acp::PromptCapabilities::default(),
2332 vec![acp::AvailableCommand::new("help", "Get help")],
2333 vec![skill],
2334 );
2335
2336 assert_eq!(session_capabilities.completion_commands().len(), 1);
2337 let skills = session_capabilities.completion_skills();
2338 assert_eq!(skills.len(), 1);
2339 assert_eq!(skills[0].name.as_ref(), "deploy");
2340 assert_eq!(skills[0].skill_file_path, skill_file_path);
2341 }
2342
2343 #[test]
2344 fn test_completion_commands_derive_category_from_meta() {
2345 let session_capabilities = SessionCapabilities::new(
2346 acp::PromptCapabilities::default(),
2347 vec![
2348 acp::AvailableCommand::new("compact", "Built-in").meta(
2349 acp_thread::meta_with_command_category(acp_thread::CommandCategory::Native),
2350 ),
2351 acp::AvailableCommand::new("deploy", "MCP").meta(
2352 acp_thread::meta_with_command_category(acp_thread::CommandCategory::Mcp),
2353 ),
2354 // No category meta: this is how external ACP agents' commands
2355 // arrive, and they should group on their own.
2356 acp::AvailableCommand::new("help", "External"),
2357 ],
2358 Vec::new(),
2359 );
2360
2361 let commands = session_capabilities.completion_commands();
2362 let category = |name: &str| {
2363 commands
2364 .iter()
2365 .find(|command| command.name.as_ref() == name)
2366 .unwrap()
2367 .category
2368 };
2369 assert_eq!(
2370 category("compact"),
2371 Some(acp_thread::CommandCategory::Native)
2372 );
2373 assert_eq!(category("deploy"), Some(acp_thread::CommandCategory::Mcp));
2374 assert_eq!(category("help"), None);
2375 }
2376
2377 #[test]
2378 fn test_validate_slash_commands_accepts_scope_qualified_skill() {
2379 let agent_id = AgentId::from("Zed");
2380 let make_skill = |name: &str, source: &str| AvailableSkill {
2381 name: name.into(),
2382 description: "desc".into(),
2383 source: source.into(),
2384 skill_file_path: PathBuf::from(format!("/tmp/{source}-{name}/SKILL.md")),
2385 warning: None,
2386 };
2387
2388 // Global skills carry an empty scope (so the popup inserts
2389 // `/:<name>`); project-local skills carry their worktree root
2390 // name. The empty-scope encoding means a worktree literally
2391 // named `global` no longer collides with the global source.
2392 let commands = vec![acp::AvailableCommand::new("help", "Get help")];
2393 let skills = vec![make_skill("deploy", ""), make_skill("deploy", "zed")];
2394 let no_skills = Vec::new();
2395
2396 // Bare name still works (current behavior — the resolver
2397 // applies project-overrides-global for unqualified commands).
2398 MessageEditor::validate_slash_commands("/deploy", &commands, &skills, &agent_id)
2399 .expect("bare /deploy should validate when a skill named `deploy` exists");
2400 MessageEditor::validate_slash_commands("/zed:deploy", &commands, &no_skills, &agent_id)
2401 .expect_err("scope-qualified skills should require a first-class available skill");
2402
2403 // Scope-qualified forms both validate, each pointing at the
2404 // matching source. `/:<name>` is the qualified form for a
2405 // global skill; `/<worktree>:<name>` is the qualified form
2406 // for a project-local skill.
2407 MessageEditor::validate_slash_commands("/:deploy", &commands, &skills, &agent_id)
2408 .expect("/:deploy should validate when a global skill named `deploy` exists");
2409 MessageEditor::validate_slash_commands("/zed:deploy", &commands, &skills, &agent_id).expect(
2410 "/zed:deploy should validate when a project skill named `deploy` exists in the `zed` worktree",
2411 );
2412
2413 // Hand-typed `/global:<name>` is NOT an alias for `/:<name>`.
2414 // It looks for a project-local skill from a worktree named
2415 // `global`, and fails when no such worktree skill exists.
2416 MessageEditor::validate_slash_commands("/global:deploy", &commands, &skills, &agent_id)
2417 .expect_err(
2418 "/global:deploy should fail when no worktree named `global` has a `deploy` skill",
2419 );
2420
2421 // The `:` separator is what distinguishes a skill scope from
2422 // an MCP server prefix — the dotted form `/zed.deploy` is an
2423 // MCP-style lookup, which doesn't match here.
2424 MessageEditor::validate_slash_commands("/zed.deploy", &commands, &skills, &agent_id)
2425 .expect_err("/zed.deploy (dotted) should be treated as an MCP-style prefix and fail");
2426
2427 // Wrong scope is rejected so the resolver doesn't silently
2428 // fall through when the user meant a skill. `zed:help` looks
2429 // like a skill scope qualifier but no skill named `help`
2430 // exists in the `zed` worktree (it's an MCP command).
2431 let err =
2432 MessageEditor::validate_slash_commands("/zed:help", &commands, &skills, &agent_id)
2433 .expect_err(
2434 "/zed:help should fail — `help` is an MCP command, not a worktree skill",
2435 );
2436 let err_message = err.to_string();
2437 assert!(
2438 err_message.contains("/zed:help"),
2439 "error should mention the typed command: {err_message}"
2440 );
2441 // Error listing shows qualified forms for skills so users see
2442 // the exact text the popup would have inserted. Globals
2443 // render with an empty scope as `/:<name>`.
2444 assert!(
2445 err_message.contains("/:deploy"),
2446 "error listing should show qualified global form: {err_message}"
2447 );
2448 assert!(
2449 err_message.contains("/zed:deploy"),
2450 "error listing should show qualified worktree form: {err_message}"
2451 );
2452 assert!(
2453 err_message.contains("/help"),
2454 "error listing should still show bare MCP commands: {err_message}"
2455 );
2456
2457 // Slashes that appear mid-text (paths, URLs, pasted logs)
2458 // should NOT be validated as commands.
2459 MessageEditor::validate_slash_commands(
2460 "check /docs for info",
2461 &commands,
2462 &skills,
2463 &agent_id,
2464 )
2465 .expect("mid-text /docs should not be treated as a slash command");
2466
2467 MessageEditor::validate_slash_commands(
2468 "see /usr/local/bin/foo",
2469 &commands,
2470 &skills,
2471 &agent_id,
2472 )
2473 .expect("file paths containing slashes should not trigger validation");
2474 }
2475
2476 #[test]
2477 fn test_parse_mention_links() {
2478 // Single file mention
2479 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
2480 let mentions = parse_mention_links(text, PathStyle::local());
2481 assert_eq!(mentions.len(), 1);
2482 assert_eq!(mentions[0].0, 0..text.len());
2483 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
2484
2485 // Multiple mentions
2486 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
2487 let mentions = parse_mention_links(text, PathStyle::local());
2488 assert_eq!(mentions.len(), 2);
2489
2490 // Text without mentions
2491 let text = "Just some regular text without mentions";
2492 let mentions = parse_mention_links(text, PathStyle::local());
2493 assert_eq!(mentions.len(), 0);
2494
2495 // Malformed mentions (should be skipped)
2496 let text = "[@incomplete](invalid://uri) and [@missing](";
2497 let mentions = parse_mention_links(text, PathStyle::local());
2498 assert_eq!(mentions.len(), 0);
2499
2500 // Mixed content with valid mention
2501 let text = "Before [@valid](file:///path/to/file) after";
2502 let mentions = parse_mention_links(text, PathStyle::local());
2503 assert_eq!(mentions.len(), 1);
2504 assert_eq!(mentions[0].0.start, 7);
2505
2506 // HTTP URL mention (Fetch)
2507 let text = "Check out [@docs](https://example.com/docs) for more info";
2508 let mentions = parse_mention_links(text, PathStyle::local());
2509 assert_eq!(mentions.len(), 1);
2510 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
2511
2512 // Directory mention (trailing slash)
2513 let text = "[@src](file:///path/to/src/)";
2514 let mentions = parse_mention_links(text, PathStyle::local());
2515 assert_eq!(mentions.len(), 1);
2516 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
2517
2518 // Multiple different mention types
2519 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
2520 let mentions = parse_mention_links(text, PathStyle::local());
2521 assert_eq!(mentions.len(), 3);
2522 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
2523 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
2524 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
2525
2526 // Adjacent mentions without separator
2527 let text = "[@a](file:///a)[@b](file:///b)";
2528 let mentions = parse_mention_links(text, PathStyle::local());
2529 assert_eq!(mentions.len(), 2);
2530
2531 // Regular markdown link (not a mention) should be ignored
2532 let text = "[regular link](https://example.com)";
2533 let mentions = parse_mention_links(text, PathStyle::local());
2534 assert_eq!(mentions.len(), 0);
2535
2536 // Incomplete mention link patterns
2537 let text = "[@name] without url and [@name( malformed";
2538 let mentions = parse_mention_links(text, PathStyle::local());
2539 assert_eq!(mentions.len(), 0);
2540
2541 // Nested brackets in name portion
2542 let text = "[@name [with brackets]](file:///path/to/file)";
2543 let mentions = parse_mention_links(text, PathStyle::local());
2544 assert_eq!(mentions.len(), 1);
2545 assert_eq!(mentions[0].0, 0..text.len());
2546
2547 // Deeply nested brackets
2548 let text = "[@outer [inner [deep]]](file:///path)";
2549 let mentions = parse_mention_links(text, PathStyle::local());
2550 assert_eq!(mentions.len(), 1);
2551
2552 // Unbalanced brackets should fail gracefully
2553 let text = "[@unbalanced [bracket](file:///path)";
2554 let mentions = parse_mention_links(text, PathStyle::local());
2555 assert_eq!(mentions.len(), 0);
2556
2557 // Nested parentheses in URI (common in URLs with query params)
2558 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
2559 let mentions = parse_mention_links(text, PathStyle::local());
2560 assert_eq!(mentions.len(), 1);
2561 if let MentionUri::Fetch { url } = &mentions[0].1 {
2562 assert!(url.as_str().contains("Rust_(programming_language)"));
2563 } else {
2564 panic!("Expected Fetch URI");
2565 }
2566 }
2567
2568 #[gpui::test]
2569 async fn test_at_mention_removal(cx: &mut TestAppContext) {
2570 init_test(cx);
2571
2572 let fs = FakeFs::new(cx.executor());
2573 fs.insert_tree("/project", json!({"file": ""})).await;
2574 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2575
2576 let (multi_workspace, cx) =
2577 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2578 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2579
2580 let thread_store = None;
2581
2582 let message_editor = cx.update(|window, cx| {
2583 cx.new(|cx| {
2584 MessageEditor::new(
2585 workspace.downgrade(),
2586 project.downgrade(),
2587 thread_store.clone(),
2588 Default::default(),
2589 "Test Agent".into(),
2590 "Test",
2591 EditorMode::AutoHeight {
2592 min_lines: 1,
2593 max_lines: None,
2594 },
2595 window,
2596 cx,
2597 )
2598 })
2599 });
2600 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2601
2602 cx.run_until_parked();
2603
2604 let completions = editor.update_in(cx, |editor, window, cx| {
2605 editor.set_text("Hello @file ", window, cx);
2606 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
2607 let completion_provider = editor.completion_provider().unwrap();
2608 completion_provider.completions(
2609 &buffer,
2610 text::Anchor::max_for_buffer(buffer.read(cx).remote_id()),
2611 CompletionContext {
2612 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
2613 trigger_character: Some("@".into()),
2614 },
2615 window,
2616 cx,
2617 )
2618 });
2619 let [_, completion]: [_; 2] = completions
2620 .await
2621 .unwrap()
2622 .into_iter()
2623 .flat_map(|response| response.completions)
2624 .collect::<Vec<_>>()
2625 .try_into()
2626 .unwrap();
2627
2628 editor.update_in(cx, |editor, window, cx| {
2629 let snapshot = editor.buffer().read(cx).snapshot(cx);
2630 let range = snapshot
2631 .buffer_anchor_range_to_anchor_range(completion.replace_range)
2632 .unwrap();
2633 editor.edit([(range, completion.new_text)], cx);
2634 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
2635 });
2636
2637 cx.run_until_parked();
2638
2639 // Backspace over the inserted crease (and the following space).
2640 editor.update_in(cx, |editor, window, cx| {
2641 editor.backspace(&Default::default(), window, cx);
2642 editor.backspace(&Default::default(), window, cx);
2643 });
2644
2645 let (content, _) = message_editor
2646 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2647 .await
2648 .unwrap();
2649
2650 // We don't send a resource link for the deleted crease.
2651 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
2652 }
2653
2654 #[gpui::test]
2655 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
2656 init_test(cx);
2657 let fs = FakeFs::new(cx.executor());
2658 fs.insert_tree(
2659 "/test",
2660 json!({
2661 ".zed": {
2662 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
2663 },
2664 "src": {
2665 "main.rs": "fn main() {}",
2666 },
2667 }),
2668 )
2669 .await;
2670
2671 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
2672 let thread_store = None;
2673 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
2674 acp::PromptCapabilities::default(),
2675 vec![],
2676 )));
2677
2678 let (multi_workspace, cx) =
2679 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2680 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2681 let workspace_handle = workspace.downgrade();
2682 let message_editor = workspace.update_in(cx, |_, window, cx| {
2683 cx.new(|cx| {
2684 MessageEditor::new(
2685 workspace_handle.clone(),
2686 project.downgrade(),
2687 thread_store.clone(),
2688 session_capabilities.clone(),
2689 "Claude Agent".into(),
2690 "Test",
2691 EditorMode::AutoHeight {
2692 min_lines: 1,
2693 max_lines: None,
2694 },
2695 window,
2696 cx,
2697 )
2698 })
2699 });
2700 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2701
2702 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
2703 editor.update_in(cx, |editor, window, cx| {
2704 editor.set_text("/file test.txt", window, cx);
2705 });
2706
2707 let contents_result = message_editor
2708 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2709 .await;
2710
2711 // Should fail because available_commands is empty (no commands supported)
2712 assert!(contents_result.is_err());
2713 let error_message = contents_result.unwrap_err().to_string();
2714 assert!(error_message.contains("is not a recognized command in Claude Agent"));
2715 assert!(error_message.contains("Available commands for Claude Agent: none"));
2716
2717 // Now simulate Claude providing its list of available commands (which doesn't include file)
2718 session_capabilities
2719 .write()
2720 .set_available_commands(vec![acp::AvailableCommand::new("help", "Get help")]);
2721
2722 // Test that unsupported slash commands trigger an error when we have a list of available commands
2723 editor.update_in(cx, |editor, window, cx| {
2724 editor.set_text("/file test.txt", window, cx);
2725 });
2726
2727 let contents_result = message_editor
2728 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2729 .await;
2730
2731 assert!(contents_result.is_err());
2732 let error_message = contents_result.unwrap_err().to_string();
2733 assert!(error_message.contains("is not a recognized command in Claude Agent"));
2734 assert!(error_message.contains("/file"));
2735 assert!(error_message.contains("Available commands for Claude Agent: /help"));
2736
2737 // Test that supported commands work fine
2738 editor.update_in(cx, |editor, window, cx| {
2739 editor.set_text("/help", window, cx);
2740 });
2741
2742 let contents_result = message_editor
2743 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2744 .await;
2745
2746 // Should succeed because /help is in available_commands
2747 assert!(contents_result.is_ok());
2748
2749 // Test that regular text works fine
2750 editor.update_in(cx, |editor, window, cx| {
2751 editor.set_text("Hello Claude!", window, cx);
2752 });
2753
2754 let (content, _) = message_editor
2755 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2756 .await
2757 .unwrap();
2758
2759 assert_eq!(content.len(), 1);
2760 if let acp::ContentBlock::Text(text) = &content[0] {
2761 assert_eq!(text.text, "Hello Claude!");
2762 } else {
2763 panic!("Expected ContentBlock::Text");
2764 }
2765
2766 // Test that @ mentions still work
2767 editor.update_in(cx, |editor, window, cx| {
2768 editor.set_text("Check this @", window, cx);
2769 });
2770
2771 // The @ mention functionality should not be affected
2772 let (content, _) = message_editor
2773 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2774 .await
2775 .unwrap();
2776
2777 assert_eq!(content.len(), 1);
2778 if let acp::ContentBlock::Text(text) = &content[0] {
2779 assert_eq!(text.text, "Check this @");
2780 } else {
2781 panic!("Expected ContentBlock::Text");
2782 }
2783 }
2784
2785 struct MessageEditorItem(Entity<MessageEditor>);
2786
2787 impl Item for MessageEditorItem {
2788 type Event = ();
2789
2790 fn include_in_nav_history() -> bool {
2791 false
2792 }
2793
2794 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
2795 "Test".into()
2796 }
2797 }
2798
2799 impl EventEmitter<()> for MessageEditorItem {}
2800
2801 impl Focusable for MessageEditorItem {
2802 fn focus_handle(&self, cx: &App) -> FocusHandle {
2803 self.0.read(cx).focus_handle(cx)
2804 }
2805 }
2806
2807 impl Render for MessageEditorItem {
2808 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2809 self.0.clone().into_any_element()
2810 }
2811 }
2812
2813 #[gpui::test]
2814 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
2815 init_test(cx);
2816
2817 let app_state = cx.update(AppState::test);
2818
2819 cx.update(|cx| {
2820 editor::init(cx);
2821 workspace::init(app_state.clone(), cx);
2822 });
2823
2824 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2825 let window =
2826 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2827 let workspace = window
2828 .read_with(cx, |mw, _| mw.workspace().clone())
2829 .unwrap();
2830
2831 let mut cx = VisualTestContext::from_window(window.into(), cx);
2832
2833 let thread_store = None;
2834 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
2835 acp::PromptCapabilities::default(),
2836 vec![
2837 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
2838 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
2839 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
2840 "<name>",
2841 )),
2842 ),
2843 ],
2844 )));
2845
2846 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2847 let workspace_handle = cx.weak_entity();
2848 let message_editor = cx.new(|cx| {
2849 MessageEditor::new(
2850 workspace_handle,
2851 project.downgrade(),
2852 thread_store.clone(),
2853 session_capabilities.clone(),
2854 "Test Agent".into(),
2855 "Test",
2856 EditorMode::AutoHeight {
2857 max_lines: None,
2858 min_lines: 1,
2859 },
2860 window,
2861 cx,
2862 )
2863 });
2864 workspace.active_pane().update(cx, |pane, cx| {
2865 pane.add_item(
2866 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2867 true,
2868 true,
2869 None,
2870 window,
2871 cx,
2872 );
2873 });
2874 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2875 message_editor.read(cx).editor().clone()
2876 });
2877
2878 cx.simulate_input("/");
2879
2880 editor.update_in(&mut cx, |editor, window, cx| {
2881 assert_eq!(editor.text(cx), "/");
2882 assert!(editor.has_visible_completions_menu());
2883
2884 assert_eq!(
2885 current_completion_labels_with_documentation(editor),
2886 &[
2887 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
2888 ("say-hello".into(), "Say hello to whoever you want".into())
2889 ]
2890 );
2891 editor.set_text("", window, cx);
2892 });
2893
2894 cx.simulate_input("/qui");
2895
2896 editor.update_in(&mut cx, |editor, window, cx| {
2897 assert_eq!(editor.text(cx), "/qui");
2898 assert!(editor.has_visible_completions_menu());
2899
2900 assert_eq!(
2901 current_completion_labels_with_documentation(editor),
2902 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
2903 );
2904 editor.set_text("", window, cx);
2905 });
2906
2907 editor.update_in(&mut cx, |editor, window, cx| {
2908 assert!(editor.has_visible_completions_menu());
2909 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2910 });
2911
2912 cx.run_until_parked();
2913
2914 editor.update_in(&mut cx, |editor, window, cx| {
2915 assert_eq!(editor.display_text(cx), "/quick-math ");
2916 assert!(!editor.has_visible_completions_menu());
2917 editor.set_text("", window, cx);
2918 });
2919
2920 cx.simulate_input("/say");
2921
2922 editor.update_in(&mut cx, |editor, _window, cx| {
2923 assert_eq!(editor.display_text(cx), "/say");
2924 assert!(editor.has_visible_completions_menu());
2925
2926 assert_eq!(
2927 current_completion_labels_with_documentation(editor),
2928 &[("say-hello".into(), "Say hello to whoever you want".into())]
2929 );
2930 });
2931
2932 editor.update_in(&mut cx, |editor, window, cx| {
2933 assert!(editor.has_visible_completions_menu());
2934 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2935 });
2936
2937 cx.run_until_parked();
2938
2939 editor.update_in(&mut cx, |editor, _window, cx| {
2940 assert_eq!(editor.text(cx), "/say-hello ");
2941 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2942 assert!(!editor.has_visible_completions_menu());
2943 });
2944
2945 cx.simulate_input("GPT5");
2946
2947 cx.run_until_parked();
2948
2949 editor.update_in(&mut cx, |editor, window, cx| {
2950 assert_eq!(editor.text(cx), "/say-hello GPT5");
2951 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2952 assert!(!editor.has_visible_completions_menu());
2953
2954 // Delete argument
2955 for _ in 0..5 {
2956 editor.backspace(&editor::actions::Backspace, window, cx);
2957 }
2958 });
2959
2960 cx.run_until_parked();
2961
2962 editor.update_in(&mut cx, |editor, window, cx| {
2963 assert_eq!(editor.text(cx), "/say-hello");
2964 // Hint is visible because argument was deleted
2965 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2966
2967 // Delete last command letter
2968 editor.backspace(&editor::actions::Backspace, window, cx);
2969 });
2970
2971 cx.run_until_parked();
2972
2973 editor.update_in(&mut cx, |editor, _window, cx| {
2974 // Hint goes away once command no longer matches an available one
2975 assert_eq!(editor.text(cx), "/say-hell");
2976 assert_eq!(editor.display_text(cx), "/say-hell");
2977 assert!(!editor.has_visible_completions_menu());
2978 });
2979 }
2980
2981 /// Local commands set via `set_local_commands` must surface in the
2982 /// slash-command popup, and confirming one must emit
2983 /// `MessageEditorEvent::LocalCommandInvoked` (so `ThreadView` can run the
2984 /// corresponding action) without leaving the `/keyword` in the editor.
2985 #[gpui::test]
2986 async fn test_local_commands_complete_and_emit_event(cx: &mut TestAppContext) {
2987 init_test(cx);
2988
2989 let app_state = cx.update(AppState::test);
2990
2991 cx.update(|cx| {
2992 editor::init(cx);
2993 workspace::init(app_state.clone(), cx);
2994 });
2995
2996 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2997 let window =
2998 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2999 let workspace = window
3000 .read_with(cx, |mw, _| mw.workspace().clone())
3001 .unwrap();
3002
3003 let mut cx = VisualTestContext::from_window(window.into(), cx);
3004
3005 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
3006 acp::PromptCapabilities::default(),
3007 Vec::new(),
3008 )));
3009
3010 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3011 let workspace_handle = cx.weak_entity();
3012 let message_editor = cx.new(|cx| {
3013 MessageEditor::new(
3014 workspace_handle,
3015 project.downgrade(),
3016 None,
3017 session_capabilities.clone(),
3018 "Test Agent".into(),
3019 "Test",
3020 EditorMode::AutoHeight {
3021 max_lines: None,
3022 min_lines: 1,
3023 },
3024 window,
3025 cx,
3026 )
3027 });
3028 workspace.active_pane().update(cx, |pane, cx| {
3029 pane.add_item(
3030 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3031 true,
3032 true,
3033 None,
3034 window,
3035 cx,
3036 );
3037 });
3038 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3039 let editor = message_editor.read(cx).editor().clone();
3040 (message_editor, editor)
3041 });
3042
3043 message_editor.read_with(&cx, |message_editor, _| {
3044 message_editor.set_local_commands(vec![
3045 PromptLocalCommand::ThumbsUp,
3046 PromptLocalCommand::ThumbsDown,
3047 ]);
3048 });
3049
3050 let invoked = Rc::new(std::cell::RefCell::new(Vec::new()));
3051 let _subscription = cx.update(|_, cx| {
3052 cx.subscribe(&message_editor, {
3053 let invoked = invoked.clone();
3054 move |_editor, event, _cx| {
3055 if let MessageEditorEvent::LocalCommandInvoked(command) = event {
3056 invoked.borrow_mut().push(*command);
3057 }
3058 }
3059 })
3060 });
3061
3062 // `/helpful` would fuzzy-match both commands ("helpful" is a
3063 // subsequence of "not-helpful"), so drive the unambiguous keyword.
3064 cx.simulate_input("/not-helpful");
3065 cx.run_until_parked();
3066
3067 editor.read_with(&cx, |editor, _| {
3068 assert!(editor.has_visible_completions_menu());
3069 assert_eq!(
3070 current_completion_labels(editor),
3071 &[PromptLocalCommand::ThumbsDown.label().to_string()],
3072 );
3073 });
3074
3075 editor.update_in(&mut cx, |editor, window, cx| {
3076 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3077 });
3078
3079 cx.run_until_parked();
3080
3081 editor.read_with(&cx, |editor, cx| {
3082 // The `/keyword` text is removed when a local command is confirmed.
3083 assert_eq!(editor.text(cx), "");
3084 assert!(!editor.has_visible_completions_menu());
3085 });
3086
3087 assert_eq!(
3088 invoked.borrow().as_slice(),
3089 &[PromptLocalCommand::ThumbsDown],
3090 );
3091 }
3092
3093 /// Opening slash-command autocomplete must emit
3094 /// [`MessageEditorEvent::SlashAutocompleteOpened`]. `ThreadView`
3095 /// subscribes to that event to fire the global-skills scan trigger
3096 /// (see `NativeAgent::ensure_skills_scan_started`); without the
3097 /// event the trigger never runs and lazily-discovered skills never
3098 /// appear in autocomplete.
3099 #[gpui::test]
3100 async fn test_slash_autocomplete_emits_opened_event(cx: &mut TestAppContext) {
3101 init_test(cx);
3102
3103 let app_state = cx.update(AppState::test);
3104
3105 cx.update(|cx| {
3106 editor::init(cx);
3107 workspace::init(app_state.clone(), cx);
3108 });
3109
3110 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3111 let window =
3112 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3113 let workspace = window
3114 .read_with(cx, |mw, _| mw.workspace().clone())
3115 .unwrap();
3116
3117 let mut cx = VisualTestContext::from_window(window.into(), cx);
3118
3119 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
3120 acp::PromptCapabilities::default(),
3121 vec![acp::AvailableCommand::new("hello", "Say hello")],
3122 )));
3123
3124 // Track every event emitted by the message editor across the
3125 // lifetime of the test. We expect to see Focus (from the focus
3126 // call below) and SlashAutocompleteOpened (from typing "/").
3127 let received_events: Arc<parking_lot::Mutex<Vec<MessageEditorEvent>>> =
3128 Arc::new(parking_lot::Mutex::new(Vec::new()));
3129
3130 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3131 let workspace_handle = cx.weak_entity();
3132 let message_editor = cx.new(|cx| {
3133 MessageEditor::new(
3134 workspace_handle,
3135 project.downgrade(),
3136 None,
3137 session_capabilities.clone(),
3138 "Test Agent".into(),
3139 "Test",
3140 EditorMode::AutoHeight {
3141 max_lines: None,
3142 min_lines: 1,
3143 },
3144 window,
3145 cx,
3146 )
3147 });
3148 workspace.active_pane().update(cx, |pane, cx| {
3149 pane.add_item(
3150 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3151 true,
3152 true,
3153 None,
3154 window,
3155 cx,
3156 );
3157 });
3158
3159 let received_events = received_events.clone();
3160 cx.subscribe(
3161 &message_editor,
3162 move |_editor: &mut Workspace, _, event: &MessageEditorEvent, _cx| {
3163 received_events.lock().push(event.clone());
3164 },
3165 )
3166 .detach();
3167
3168 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3169 message_editor.read(cx).editor().clone()
3170 });
3171
3172 cx.simulate_input("/");
3173
3174 editor.update_in(&mut cx, |editor, _window, cx| {
3175 assert_eq!(editor.text(cx), "/");
3176 assert!(editor.has_visible_completions_menu());
3177 });
3178
3179 let events = received_events.lock();
3180 assert!(
3181 events
3182 .iter()
3183 .any(|e| matches!(e, MessageEditorEvent::SlashAutocompleteOpened)),
3184 "expected SlashAutocompleteOpened to have been emitted; saw events: {events:?}",
3185 );
3186 }
3187
3188 #[gpui::test]
3189 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
3190 init_test(cx);
3191
3192 let app_state = cx.update(AppState::test);
3193
3194 cx.update(|cx| {
3195 editor::init(cx);
3196 workspace::init(app_state.clone(), cx);
3197 });
3198
3199 app_state
3200 .fs
3201 .as_fake()
3202 .insert_tree(
3203 path!("/dir"),
3204 json!({
3205 "editor": "",
3206 "a": {
3207 "one.txt": "1",
3208 "two.txt": "2",
3209 "three.txt": "3",
3210 "four.txt": "4"
3211 },
3212 "b": {
3213 "five.txt": "5",
3214 "six.txt": "6",
3215 "seven.txt": "7",
3216 "eight.txt": "8",
3217 },
3218 "x.png": "",
3219 }),
3220 )
3221 .await;
3222
3223 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3224 let window =
3225 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3226 let workspace = window
3227 .read_with(cx, |mw, _| mw.workspace().clone())
3228 .unwrap();
3229
3230 let worktree = project.update(cx, |project, cx| {
3231 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3232 assert_eq!(worktrees.len(), 1);
3233 worktrees.pop().unwrap()
3234 });
3235 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3236
3237 let mut cx = VisualTestContext::from_window(window.into(), cx);
3238
3239 let paths = vec![
3240 rel_path("a/one.txt"),
3241 rel_path("a/two.txt"),
3242 rel_path("a/three.txt"),
3243 rel_path("a/four.txt"),
3244 rel_path("b/five.txt"),
3245 rel_path("b/six.txt"),
3246 rel_path("b/seven.txt"),
3247 rel_path("b/eight.txt"),
3248 ];
3249
3250 let slash = PathStyle::local().primary_separator();
3251
3252 let mut opened_editors = Vec::new();
3253 for path in paths {
3254 let buffer = workspace
3255 .update_in(&mut cx, |workspace, window, cx| {
3256 workspace.open_path(
3257 ProjectPath {
3258 worktree_id,
3259 path: path.into(),
3260 },
3261 None,
3262 false,
3263 window,
3264 cx,
3265 )
3266 })
3267 .await
3268 .unwrap();
3269 opened_editors.push(buffer);
3270 }
3271
3272 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3273 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
3274 acp::PromptCapabilities::default(),
3275 vec![],
3276 )));
3277
3278 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3279 let workspace_handle = cx.weak_entity();
3280 let message_editor = cx.new(|cx| {
3281 MessageEditor::new(
3282 workspace_handle,
3283 project.downgrade(),
3284 Some(thread_store),
3285 session_capabilities.clone(),
3286 "Test Agent".into(),
3287 "Test",
3288 EditorMode::AutoHeight {
3289 max_lines: None,
3290 min_lines: 1,
3291 },
3292 window,
3293 cx,
3294 )
3295 });
3296 workspace.active_pane().update(cx, |pane, cx| {
3297 pane.add_item(
3298 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3299 true,
3300 true,
3301 None,
3302 window,
3303 cx,
3304 );
3305 });
3306 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3307 let editor = message_editor.read(cx).editor().clone();
3308 (message_editor, editor)
3309 });
3310
3311 cx.simulate_input("Lorem @");
3312
3313 editor.update_in(&mut cx, |editor, window, cx| {
3314 assert_eq!(editor.text(cx), "Lorem @");
3315 assert!(editor.has_visible_completions_menu());
3316
3317 assert_eq!(
3318 current_completion_labels(editor),
3319 &[
3320 format!("eight.txt b{slash}"),
3321 format!("seven.txt b{slash}"),
3322 format!("six.txt b{slash}"),
3323 format!("five.txt b{slash}"),
3324 "Files & Directories".into(),
3325 "Symbols".into()
3326 ]
3327 );
3328 editor.set_text("", window, cx);
3329 });
3330
3331 message_editor.update(&mut cx, |editor, _cx| {
3332 editor.session_capabilities.write().set_prompt_capabilities(
3333 acp::PromptCapabilities::new()
3334 .image(true)
3335 .audio(true)
3336 .embedded_context(true),
3337 );
3338 });
3339
3340 cx.simulate_input("Lorem ");
3341
3342 editor.update(&mut cx, |editor, cx| {
3343 assert_eq!(editor.text(cx), "Lorem ");
3344 assert!(!editor.has_visible_completions_menu());
3345 });
3346
3347 cx.simulate_input("@");
3348
3349 editor.update(&mut cx, |editor, cx| {
3350 assert_eq!(editor.text(cx), "Lorem @");
3351 assert!(editor.has_visible_completions_menu());
3352 assert_eq!(
3353 current_completion_labels(editor),
3354 &[
3355 format!("eight.txt b{slash}"),
3356 format!("seven.txt b{slash}"),
3357 format!("six.txt b{slash}"),
3358 format!("five.txt b{slash}"),
3359 "Files & Directories".into(),
3360 "Symbols".into(),
3361 "Threads".into(),
3362 "Fetch".into()
3363 ]
3364 );
3365 });
3366
3367 // Select and confirm "File"
3368 editor.update_in(&mut cx, |editor, window, cx| {
3369 assert!(editor.has_visible_completions_menu());
3370 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
3371 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
3372 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
3373 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
3374 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3375 });
3376
3377 cx.run_until_parked();
3378
3379 editor.update(&mut cx, |editor, cx| {
3380 assert_eq!(editor.text(cx), "Lorem @file ");
3381 assert!(editor.has_visible_completions_menu());
3382 });
3383
3384 cx.simulate_input("one");
3385
3386 editor.update(&mut cx, |editor, cx| {
3387 assert_eq!(editor.text(cx), "Lorem @file one");
3388 assert!(editor.has_visible_completions_menu());
3389 assert_eq!(
3390 current_completion_labels(editor),
3391 vec![format!("one.txt a{slash}")]
3392 );
3393 });
3394
3395 editor.update_in(&mut cx, |editor, window, cx| {
3396 assert!(editor.has_visible_completions_menu());
3397 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3398 });
3399
3400 let url_one = MentionUri::File {
3401 abs_path: path!("/dir/a/one.txt").into(),
3402 }
3403 .to_uri()
3404 .to_string();
3405 editor.update(&mut cx, |editor, cx| {
3406 let text = editor.text(cx);
3407 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
3408 assert!(!editor.has_visible_completions_menu());
3409 assert_eq!(fold_ranges(editor, cx).len(), 1);
3410 });
3411
3412 let contents = message_editor
3413 .update(&mut cx, |message_editor, cx| {
3414 message_editor
3415 .mention_set()
3416 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
3417 })
3418 .await
3419 .unwrap()
3420 .into_values()
3421 .collect::<Vec<_>>();
3422
3423 {
3424 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
3425 panic!("Unexpected mentions");
3426 };
3427 pretty_assertions::assert_eq!(content, "1");
3428 pretty_assertions::assert_eq!(
3429 uri,
3430 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
3431 );
3432 }
3433
3434 cx.simulate_input(" ");
3435
3436 editor.update(&mut cx, |editor, cx| {
3437 let text = editor.text(cx);
3438 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
3439 assert!(!editor.has_visible_completions_menu());
3440 assert_eq!(fold_ranges(editor, cx).len(), 1);
3441 });
3442
3443 cx.simulate_input("Ipsum ");
3444
3445 editor.update(&mut cx, |editor, cx| {
3446 let text = editor.text(cx);
3447 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
3448 assert!(!editor.has_visible_completions_menu());
3449 assert_eq!(fold_ranges(editor, cx).len(), 1);
3450 });
3451
3452 cx.simulate_input("@file ");
3453
3454 editor.update(&mut cx, |editor, cx| {
3455 let text = editor.text(cx);
3456 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
3457 assert!(editor.has_visible_completions_menu());
3458 assert_eq!(fold_ranges(editor, cx).len(), 1);
3459 });
3460
3461 editor.update_in(&mut cx, |editor, window, cx| {
3462 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3463 });
3464
3465 cx.run_until_parked();
3466
3467 let contents = message_editor
3468 .update(&mut cx, |message_editor, cx| {
3469 message_editor
3470 .mention_set()
3471 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
3472 })
3473 .await
3474 .unwrap()
3475 .into_values()
3476 .collect::<Vec<_>>();
3477
3478 let url_eight = MentionUri::File {
3479 abs_path: path!("/dir/b/eight.txt").into(),
3480 }
3481 .to_uri()
3482 .to_string();
3483
3484 {
3485 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
3486 panic!("Unexpected mentions");
3487 };
3488 pretty_assertions::assert_eq!(content, "8");
3489 pretty_assertions::assert_eq!(
3490 uri,
3491 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
3492 );
3493 }
3494
3495 editor.update(&mut cx, |editor, cx| {
3496 assert_eq!(
3497 editor.text(cx),
3498 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
3499 );
3500 assert!(!editor.has_visible_completions_menu());
3501 assert_eq!(fold_ranges(editor, cx).len(), 2);
3502 });
3503
3504 let plain_text_language = Arc::new(language::Language::new(
3505 language::LanguageConfig {
3506 name: "Plain Text".into(),
3507 matcher: (language::LanguageMatcher {
3508 path_suffixes: vec!["txt".to_string()],
3509 ..Default::default()
3510 })
3511 .into(),
3512 ..Default::default()
3513 },
3514 None,
3515 ));
3516
3517 // Register the language and fake LSP
3518 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
3519 language_registry.add(plain_text_language);
3520
3521 let mut fake_language_servers = language_registry.register_fake_lsp(
3522 "Plain Text",
3523 language::FakeLspAdapter {
3524 capabilities: lsp::ServerCapabilities {
3525 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
3526 ..Default::default()
3527 },
3528 ..Default::default()
3529 },
3530 );
3531
3532 // Open the buffer to trigger LSP initialization
3533 let buffer = project
3534 .update(&mut cx, |project, cx| {
3535 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
3536 })
3537 .await
3538 .unwrap();
3539
3540 // Register the buffer with language servers
3541 let _handle = project.update(&mut cx, |project, cx| {
3542 project.register_buffer_with_language_servers(&buffer, cx)
3543 });
3544
3545 cx.run_until_parked();
3546
3547 let fake_language_server = fake_language_servers.next().await.unwrap();
3548 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
3549 move |_, _| async move {
3550 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
3551 #[allow(deprecated)]
3552 lsp::SymbolInformation {
3553 name: "MySymbol".into(),
3554 location: lsp::Location {
3555 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
3556 range: lsp::Range::new(
3557 lsp::Position::new(0, 0),
3558 lsp::Position::new(0, 1),
3559 ),
3560 },
3561 kind: lsp::SymbolKind::CONSTANT,
3562 tags: None,
3563 container_name: None,
3564 deprecated: None,
3565 },
3566 ])))
3567 },
3568 );
3569
3570 cx.simulate_input("@symbol ");
3571
3572 editor.update(&mut cx, |editor, cx| {
3573 assert_eq!(
3574 editor.text(cx),
3575 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
3576 );
3577 assert!(editor.has_visible_completions_menu());
3578 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
3579 });
3580
3581 editor.update_in(&mut cx, |editor, window, cx| {
3582 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3583 });
3584
3585 let symbol = MentionUri::Symbol {
3586 abs_path: path!("/dir/a/one.txt").into(),
3587 name: "MySymbol".into(),
3588 line_range: 0..=0,
3589 };
3590
3591 let contents = message_editor
3592 .update(&mut cx, |message_editor, cx| {
3593 message_editor
3594 .mention_set()
3595 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
3596 })
3597 .await
3598 .unwrap()
3599 .into_values()
3600 .collect::<Vec<_>>();
3601
3602 {
3603 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
3604 panic!("Unexpected mentions");
3605 };
3606 pretty_assertions::assert_eq!(content, "1");
3607 pretty_assertions::assert_eq!(uri, &symbol);
3608 }
3609
3610 cx.run_until_parked();
3611
3612 editor.read_with(&cx, |editor, cx| {
3613 assert_eq!(
3614 editor.text(cx),
3615 format!(
3616 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
3617 symbol.to_uri(),
3618 )
3619 );
3620 });
3621
3622 // Try to mention an "image" file that will fail to load
3623 cx.simulate_input("@file x.png");
3624
3625 editor.update(&mut cx, |editor, cx| {
3626 assert_eq!(
3627 editor.text(cx),
3628 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
3629 );
3630 assert!(editor.has_visible_completions_menu());
3631 assert_eq!(current_completion_labels(editor), &["x.png "]);
3632 });
3633
3634 editor.update_in(&mut cx, |editor, window, cx| {
3635 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3636 });
3637
3638 // Getting the message contents fails
3639 message_editor
3640 .update(&mut cx, |message_editor, cx| {
3641 message_editor
3642 .mention_set()
3643 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
3644 })
3645 .await
3646 .expect_err("Should fail to load x.png");
3647
3648 cx.run_until_parked();
3649
3650 // Mention was removed
3651 editor.read_with(&cx, |editor, cx| {
3652 assert_eq!(
3653 editor.text(cx),
3654 format!(
3655 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
3656 symbol.to_uri()
3657 )
3658 );
3659 });
3660
3661 // Once more
3662 cx.simulate_input("@file x.png");
3663
3664 editor.update(&mut cx, |editor, cx| {
3665 assert_eq!(
3666 editor.text(cx),
3667 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
3668 );
3669 assert!(editor.has_visible_completions_menu());
3670 assert_eq!(current_completion_labels(editor), &["x.png "]);
3671 });
3672
3673 editor.update_in(&mut cx, |editor, window, cx| {
3674 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3675 });
3676
3677 // This time don't immediately get the contents, just let the confirmed completion settle
3678 cx.run_until_parked();
3679
3680 // Mention was removed
3681 editor.read_with(&cx, |editor, cx| {
3682 assert_eq!(
3683 editor.text(cx),
3684 format!(
3685 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
3686 symbol.to_uri()
3687 )
3688 );
3689 });
3690
3691 // Now getting the contents succeeds, because the invalid mention was removed
3692 let contents = message_editor
3693 .update(&mut cx, |message_editor, cx| {
3694 message_editor
3695 .mention_set()
3696 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
3697 })
3698 .await
3699 .unwrap();
3700 assert_eq!(contents.len(), 3);
3701 }
3702
3703 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
3704 let snapshot = editor.buffer().read(cx).snapshot(cx);
3705 editor.display_map.update(cx, |display_map, cx| {
3706 display_map
3707 .snapshot(cx)
3708 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
3709 .map(|fold| fold.range.to_point(&snapshot))
3710 .collect()
3711 })
3712 }
3713
3714 fn current_completion_labels(editor: &Editor) -> Vec<String> {
3715 let completions = editor.current_completions().expect("Missing completions");
3716 completions
3717 .into_iter()
3718 .map(|completion| completion.label.text)
3719 .collect::<Vec<_>>()
3720 }
3721
3722 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
3723 let completions = editor.current_completions().expect("Missing completions");
3724 completions
3725 .into_iter()
3726 .map(|completion| {
3727 (
3728 completion.label.text,
3729 completion
3730 .documentation
3731 .map(|d| d.text().to_string())
3732 .unwrap_or_default(),
3733 )
3734 })
3735 .collect::<Vec<_>>()
3736 }
3737
3738 #[gpui::test]
3739 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
3740 init_test(cx);
3741
3742 let fs = FakeFs::new(cx.executor());
3743
3744 // Create a large file that exceeds AUTO_OUTLINE_SIZE
3745 // Using plain text without a configured language, so no outline is available
3746 const LINE: &str = "This is a line of text in the file\n";
3747 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
3748 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
3749
3750 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
3751 let small_content = "fn small_function() { /* small */ }\n";
3752 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
3753
3754 fs.insert_tree(
3755 "/project",
3756 json!({
3757 "large_file.txt": large_content.clone(),
3758 "small_file.txt": small_content,
3759 }),
3760 )
3761 .await;
3762
3763 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3764
3765 let (multi_workspace, cx) =
3766 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3767 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3768
3769 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3770
3771 let message_editor = cx.update(|window, cx| {
3772 cx.new(|cx| {
3773 let editor = MessageEditor::new(
3774 workspace.downgrade(),
3775 project.downgrade(),
3776 thread_store.clone(),
3777 Default::default(),
3778 "Test Agent".into(),
3779 "Test",
3780 EditorMode::AutoHeight {
3781 min_lines: 1,
3782 max_lines: None,
3783 },
3784 window,
3785 cx,
3786 );
3787 // Enable embedded context so files are actually included
3788 editor
3789 .session_capabilities
3790 .write()
3791 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
3792 editor
3793 })
3794 });
3795
3796 // Test large file mention
3797 // Get the absolute path using the project's worktree
3798 let large_file_abs_path = project.read_with(cx, |project, cx| {
3799 let worktree = project.worktrees(cx).next().unwrap();
3800 let worktree_root = worktree.read(cx).abs_path();
3801 worktree_root.join("large_file.txt")
3802 });
3803 let large_file_task = message_editor.update(cx, |editor, cx| {
3804 editor.mention_set().update(cx, |set, cx| {
3805 set.confirm_mention_for_file(large_file_abs_path, true, cx)
3806 })
3807 });
3808
3809 let large_file_mention = large_file_task.await.unwrap();
3810 match large_file_mention {
3811 Mention::Text { content, .. } => {
3812 // Should contain some of the content but not all of it
3813 assert!(
3814 content.contains(LINE),
3815 "Should contain some of the file content"
3816 );
3817 assert!(
3818 !content.contains(&LINE.repeat(100)),
3819 "Should not contain the full file"
3820 );
3821 // Should be much smaller than original
3822 assert!(
3823 content.len() < large_content.len() / 10,
3824 "Should be significantly truncated"
3825 );
3826 }
3827 _ => panic!("Expected Text mention for large file"),
3828 }
3829
3830 // Test small file mention
3831 // Get the absolute path using the project's worktree
3832 let small_file_abs_path = project.read_with(cx, |project, cx| {
3833 let worktree = project.worktrees(cx).next().unwrap();
3834 let worktree_root = worktree.read(cx).abs_path();
3835 worktree_root.join("small_file.txt")
3836 });
3837 let small_file_task = message_editor.update(cx, |editor, cx| {
3838 editor.mention_set().update(cx, |set, cx| {
3839 set.confirm_mention_for_file(small_file_abs_path, true, cx)
3840 })
3841 });
3842
3843 let small_file_mention = small_file_task.await.unwrap();
3844 match small_file_mention {
3845 Mention::Text { content, .. } => {
3846 // Should contain the full actual content
3847 assert_eq!(content, small_content);
3848 }
3849 _ => panic!("Expected Text mention for small file"),
3850 }
3851 }
3852
3853 #[gpui::test]
3854 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
3855 init_test(cx);
3856 cx.update(LanguageModelRegistry::test);
3857
3858 let fs = FakeFs::new(cx.executor());
3859 fs.insert_tree("/project", json!({"file": ""})).await;
3860 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3861
3862 let (multi_workspace, cx) =
3863 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3864 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3865
3866 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3867
3868 let session_id = acp::SessionId::new("thread-123");
3869 let title = Some("Previous Conversation".into());
3870
3871 let message_editor = cx.update(|window, cx| {
3872 cx.new(|cx| {
3873 let mut editor = MessageEditor::new(
3874 workspace.downgrade(),
3875 project.downgrade(),
3876 thread_store.clone(),
3877 Default::default(),
3878 "Test Agent".into(),
3879 "Test",
3880 EditorMode::AutoHeight {
3881 min_lines: 1,
3882 max_lines: None,
3883 },
3884 window,
3885 cx,
3886 );
3887 editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx);
3888 editor
3889 })
3890 });
3891
3892 // Construct expected values for verification
3893 let expected_uri = MentionUri::Thread {
3894 id: session_id.clone(),
3895 name: title.as_ref().unwrap().to_string(),
3896 };
3897 let expected_title = title.as_ref().unwrap();
3898 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
3899
3900 message_editor.read_with(cx, |editor, cx| {
3901 let text = editor.text(cx);
3902
3903 assert!(
3904 text.contains(&expected_link),
3905 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
3906 expected_link,
3907 text
3908 );
3909
3910 let mentions = editor.mention_set().read(cx).mentions();
3911 assert_eq!(
3912 mentions.len(),
3913 1,
3914 "Expected exactly one mention after inserting thread summary"
3915 );
3916
3917 assert!(
3918 mentions.contains(&expected_uri),
3919 "Expected mentions to contain the thread URI"
3920 );
3921 });
3922 }
3923
3924 #[gpui::test]
3925 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
3926 init_test(cx);
3927 cx.update(LanguageModelRegistry::test);
3928
3929 let fs = FakeFs::new(cx.executor());
3930 fs.insert_tree("/project", json!({"file": ""})).await;
3931 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3932
3933 let (multi_workspace, cx) =
3934 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3935 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3936
3937 let thread_store = None;
3938
3939 let message_editor = cx.update(|window, cx| {
3940 cx.new(|cx| {
3941 let mut editor = MessageEditor::new(
3942 workspace.downgrade(),
3943 project.downgrade(),
3944 thread_store.clone(),
3945 Default::default(),
3946 "Test Agent".into(),
3947 "Test",
3948 EditorMode::AutoHeight {
3949 min_lines: 1,
3950 max_lines: None,
3951 },
3952 window,
3953 cx,
3954 );
3955 editor.insert_thread_summary(
3956 acp::SessionId::new("thread-123"),
3957 Some("Previous Conversation".into()),
3958 window,
3959 cx,
3960 );
3961 editor
3962 })
3963 });
3964
3965 message_editor.read_with(cx, |editor, cx| {
3966 assert!(
3967 editor.text(cx).is_empty(),
3968 "Expected thread summary to be skipped for external agents"
3969 );
3970 assert!(
3971 editor.mention_set().read(cx).mentions().is_empty(),
3972 "Expected no mentions when thread summary is skipped"
3973 );
3974 });
3975 }
3976
3977 #[gpui::test]
3978 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
3979 init_test(cx);
3980
3981 let fs = FakeFs::new(cx.executor());
3982 fs.insert_tree("/project", json!({"file": ""})).await;
3983 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3984
3985 let (multi_workspace, cx) =
3986 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3987 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3988
3989 let thread_store = None;
3990
3991 let message_editor = cx.update(|window, cx| {
3992 cx.new(|cx| {
3993 MessageEditor::new(
3994 workspace.downgrade(),
3995 project.downgrade(),
3996 thread_store.clone(),
3997 Default::default(),
3998 "Test Agent".into(),
3999 "Test",
4000 EditorMode::AutoHeight {
4001 min_lines: 1,
4002 max_lines: None,
4003 },
4004 window,
4005 cx,
4006 )
4007 })
4008 });
4009
4010 message_editor.update(cx, |editor, _cx| {
4011 editor
4012 .session_capabilities
4013 .write()
4014 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
4015 });
4016
4017 let supported_modes = {
4018 let app = cx.app.borrow();
4019 let _ = &app;
4020 message_editor
4021 .read(&app)
4022 .session_capabilities
4023 .read()
4024 .supported_modes(false)
4025 };
4026
4027 assert!(
4028 !supported_modes.contains(&PromptContextType::Thread),
4029 "Expected thread mode to be hidden when thread mentions are disabled"
4030 );
4031 }
4032
4033 #[gpui::test]
4034 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
4035 init_test(cx);
4036
4037 let fs = FakeFs::new(cx.executor());
4038 fs.insert_tree("/project", json!({"file": ""})).await;
4039 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
4040
4041 let (multi_workspace, cx) =
4042 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4043 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4044
4045 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
4046
4047 let message_editor = cx.update(|window, cx| {
4048 cx.new(|cx| {
4049 MessageEditor::new(
4050 workspace.downgrade(),
4051 project.downgrade(),
4052 thread_store.clone(),
4053 Default::default(),
4054 "Test Agent".into(),
4055 "Test",
4056 EditorMode::AutoHeight {
4057 min_lines: 1,
4058 max_lines: None,
4059 },
4060 window,
4061 cx,
4062 )
4063 })
4064 });
4065
4066 message_editor.update(cx, |editor, _cx| {
4067 editor
4068 .session_capabilities
4069 .write()
4070 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
4071 });
4072
4073 let supported_modes = {
4074 let app = cx.app.borrow();
4075 let _ = &app;
4076 message_editor
4077 .read(&app)
4078 .session_capabilities
4079 .read()
4080 .supported_modes(true)
4081 };
4082
4083 assert!(
4084 supported_modes.contains(&PromptContextType::Thread),
4085 "Expected thread mode to be visible when enabled"
4086 );
4087 }
4088
4089 #[gpui::test]
4090 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
4091 init_test(cx);
4092
4093 let fs = FakeFs::new(cx.executor());
4094 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
4095 .await;
4096 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
4097
4098 let (multi_workspace, cx) =
4099 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4100 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4101
4102 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
4103
4104 let message_editor = cx.update(|window, cx| {
4105 cx.new(|cx| {
4106 MessageEditor::new(
4107 workspace.downgrade(),
4108 project.downgrade(),
4109 thread_store.clone(),
4110 Default::default(),
4111 "Test Agent".into(),
4112 "Test",
4113 EditorMode::AutoHeight {
4114 min_lines: 1,
4115 max_lines: None,
4116 },
4117 window,
4118 cx,
4119 )
4120 })
4121 });
4122 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
4123
4124 cx.run_until_parked();
4125
4126 editor.update_in(cx, |editor, window, cx| {
4127 editor.set_text(" \u{A0}してhello world ", window, cx);
4128 });
4129
4130 let (content, _) = message_editor
4131 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
4132 .await
4133 .unwrap();
4134
4135 assert_eq!(content, vec!["してhello world".into()]);
4136 }
4137
4138 #[gpui::test]
4139 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
4140 init_test(cx);
4141
4142 let fs = FakeFs::new(cx.executor());
4143
4144 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
4145
4146 fs.insert_tree(
4147 "/project",
4148 json!({
4149 "src": {
4150 "main.rs": file_content,
4151 }
4152 }),
4153 )
4154 .await;
4155
4156 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
4157
4158 let (multi_workspace, cx) =
4159 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4160 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4161
4162 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
4163
4164 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
4165 let workspace_handle = cx.weak_entity();
4166 let message_editor = cx.new(|cx| {
4167 MessageEditor::new(
4168 workspace_handle,
4169 project.downgrade(),
4170 thread_store.clone(),
4171 Default::default(),
4172 "Test Agent".into(),
4173 "Test",
4174 EditorMode::AutoHeight {
4175 min_lines: 1,
4176 max_lines: None,
4177 },
4178 window,
4179 cx,
4180 )
4181 });
4182 workspace.active_pane().update(cx, |pane, cx| {
4183 pane.add_item(
4184 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4185 true,
4186 true,
4187 None,
4188 window,
4189 cx,
4190 );
4191 });
4192 message_editor.read(cx).focus_handle(cx).focus(window, cx);
4193 let editor = message_editor.read(cx).editor().clone();
4194 (message_editor, editor)
4195 });
4196
4197 cx.simulate_input("What is in @file main");
4198
4199 editor.update_in(cx, |editor, window, cx| {
4200 assert!(editor.has_visible_completions_menu());
4201 assert_eq!(editor.text(cx), "What is in @file main");
4202 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
4203 });
4204
4205 let content = message_editor
4206 .update(cx, |editor, cx| editor.contents(false, cx))
4207 .await
4208 .unwrap()
4209 .0;
4210
4211 let main_rs_uri = if cfg!(windows) {
4212 "file:///C:/project/src/main.rs"
4213 } else {
4214 "file:///project/src/main.rs"
4215 };
4216
4217 // When embedded context is `false` we should get a resource link
4218 pretty_assertions::assert_eq!(
4219 content,
4220 vec![
4221 "What is in ".into(),
4222 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
4223 ]
4224 );
4225
4226 message_editor.update(cx, |editor, _cx| {
4227 editor
4228 .session_capabilities
4229 .write()
4230 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true))
4231 });
4232
4233 let content = message_editor
4234 .update(cx, |editor, cx| editor.contents(false, cx))
4235 .await
4236 .unwrap()
4237 .0;
4238
4239 // When embedded context is `true` we should get a resource
4240 pretty_assertions::assert_eq!(
4241 content,
4242 vec![
4243 "What is in ".into(),
4244 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
4245 acp::EmbeddedResourceResource::TextResourceContents(
4246 acp::TextResourceContents::new(file_content, main_rs_uri)
4247 )
4248 ))
4249 ]
4250 );
4251 }
4252
4253 #[gpui::test]
4254 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
4255 init_test(cx);
4256
4257 let app_state = cx.update(AppState::test);
4258
4259 cx.update(|cx| {
4260 editor::init(cx);
4261 workspace::init(app_state.clone(), cx);
4262 });
4263
4264 app_state
4265 .fs
4266 .as_fake()
4267 .insert_tree(
4268 path!("/dir"),
4269 json!({
4270 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
4271 }),
4272 )
4273 .await;
4274
4275 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
4276 let window =
4277 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4278 let workspace = window
4279 .read_with(cx, |mw, _| mw.workspace().clone())
4280 .unwrap();
4281
4282 let worktree = project.update(cx, |project, cx| {
4283 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
4284 assert_eq!(worktrees.len(), 1);
4285 worktrees.pop().unwrap()
4286 });
4287 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
4288
4289 let mut cx = VisualTestContext::from_window(window.into(), cx);
4290
4291 // Open a regular editor with the created file, and select a portion of
4292 // the text that will be used for the selections that are meant to be
4293 // inserted in the agent panel.
4294 let editor = workspace
4295 .update_in(&mut cx, |workspace, window, cx| {
4296 workspace.open_path(
4297 ProjectPath {
4298 worktree_id,
4299 path: rel_path("test.txt").into(),
4300 },
4301 None,
4302 false,
4303 window,
4304 cx,
4305 )
4306 })
4307 .await
4308 .unwrap()
4309 .downcast::<Editor>()
4310 .unwrap();
4311
4312 editor.update_in(&mut cx, |editor, window, cx| {
4313 editor.change_selections(Default::default(), window, cx, |selections| {
4314 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
4315 });
4316 });
4317
4318 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
4319
4320 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
4321 // to ensure we have a fixed viewport, so we can eventually actually
4322 // place the cursor outside of the visible area.
4323 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
4324 let workspace_handle = cx.weak_entity();
4325 let message_editor = cx.new(|cx| {
4326 MessageEditor::new(
4327 workspace_handle,
4328 project.downgrade(),
4329 thread_store.clone(),
4330 Default::default(),
4331 "Test Agent".into(),
4332 "Test",
4333 EditorMode::full(),
4334 window,
4335 cx,
4336 )
4337 });
4338 workspace.active_pane().update(cx, |pane, cx| {
4339 pane.add_item(
4340 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4341 true,
4342 true,
4343 None,
4344 window,
4345 cx,
4346 );
4347 });
4348
4349 message_editor
4350 });
4351
4352 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4353 message_editor.editor.update(cx, |editor, cx| {
4354 // Update the Agent Panel's Message Editor text to have 100
4355 // lines, ensuring that the cursor is set at line 90 and that we
4356 // then scroll all the way to the top, so the cursor's position
4357 // remains off screen.
4358 let mut lines = String::new();
4359 for _ in 1..=100 {
4360 lines.push_str(&"Another line in the agent panel's message editor\n");
4361 }
4362 editor.set_text(lines.as_str(), window, cx);
4363 editor.change_selections(Default::default(), window, cx, |selections| {
4364 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
4365 });
4366 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
4367 });
4368 });
4369
4370 cx.run_until_parked();
4371
4372 // Before proceeding, let's assert that the cursor is indeed off screen,
4373 // otherwise the rest of the test doesn't make sense.
4374 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4375 message_editor.editor.update(cx, |editor, cx| {
4376 let snapshot = editor.snapshot(window, cx);
4377 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
4378 let scroll_top = snapshot.scroll_position().y as u32;
4379 let visible_lines = editor.visible_line_count().unwrap() as u32;
4380 let visible_range = scroll_top..(scroll_top + visible_lines);
4381
4382 assert!(!visible_range.contains(&cursor_row));
4383 })
4384 });
4385
4386 let text_editor_selection = editor.update(&mut cx, |editor, cx| {
4387 let multibuffer = editor.buffer().read(cx);
4388 let buffer = multibuffer.as_singleton().unwrap();
4389 let buffer_snapshot = buffer.read(cx).snapshot();
4390 let start = buffer_snapshot.anchor_before(0);
4391 let end = buffer_snapshot.anchor_after(5);
4392 AgentContextSelection::Editor(vec![(buffer, start..end)])
4393 });
4394
4395 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4396 message_editor.insert_selections(text_editor_selection, window, cx);
4397 });
4398
4399 cx.run_until_parked();
4400
4401 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4402 message_editor.editor.update(cx, |editor, cx| {
4403 let snapshot = editor.snapshot(window, cx);
4404 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
4405 let scroll_top = snapshot.scroll_position().y as u32;
4406 let visible_lines = editor.visible_line_count().unwrap() as u32;
4407 let visible_range = scroll_top..(scroll_top + visible_lines);
4408
4409 assert!(visible_range.contains(&cursor_row));
4410 })
4411 });
4412 }
4413
4414 #[gpui::test]
4415 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
4416 init_test(cx);
4417
4418 let app_state = cx.update(AppState::test);
4419
4420 cx.update(|cx| {
4421 editor::init(cx);
4422 workspace::init(app_state.clone(), cx);
4423 });
4424
4425 app_state
4426 .fs
4427 .as_fake()
4428 .insert_tree(path!("/dir"), json!({}))
4429 .await;
4430
4431 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
4432 let window =
4433 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4434 let workspace = window
4435 .read_with(cx, |mw, _| mw.workspace().clone())
4436 .unwrap();
4437
4438 let mut cx = VisualTestContext::from_window(window.into(), cx);
4439
4440 let thread_store = cx.new(|cx| ThreadStore::new(cx));
4441
4442 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
4443 let workspace_handle = cx.weak_entity();
4444 let message_editor = cx.new(|cx| {
4445 MessageEditor::new(
4446 workspace_handle,
4447 project.downgrade(),
4448 Some(thread_store.clone()),
4449 Default::default(),
4450 "Test Agent".into(),
4451 "Test",
4452 EditorMode::AutoHeight {
4453 max_lines: None,
4454 min_lines: 1,
4455 },
4456 window,
4457 cx,
4458 )
4459 });
4460 workspace.active_pane().update(cx, |pane, cx| {
4461 pane.add_item(
4462 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4463 true,
4464 true,
4465 None,
4466 window,
4467 cx,
4468 );
4469 });
4470 message_editor.read(cx).focus_handle(cx).focus(window, cx);
4471 let editor = message_editor.read(cx).editor().clone();
4472 (message_editor, editor)
4473 });
4474
4475 editor.update_in(&mut cx, |editor, window, cx| {
4476 editor.set_text("😄😄", window, cx);
4477 });
4478
4479 cx.run_until_parked();
4480
4481 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4482 message_editor.insert_context_type("file", window, cx);
4483 });
4484
4485 cx.run_until_parked();
4486
4487 editor.update(&mut cx, |editor, cx| {
4488 assert_eq!(editor.text(cx), "😄😄@file");
4489 });
4490 }
4491
4492 #[gpui::test]
4493 async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) {
4494 init_test(cx);
4495
4496 let app_state = cx.update(AppState::test);
4497
4498 cx.update(|cx| {
4499 editor::init(cx);
4500 workspace::init(app_state.clone(), cx);
4501 });
4502
4503 app_state
4504 .fs
4505 .as_fake()
4506 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
4507 .await;
4508
4509 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
4510 let window =
4511 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4512 let workspace = window
4513 .read_with(cx, |mw, _| mw.workspace().clone())
4514 .unwrap();
4515
4516 let mut cx = VisualTestContext::from_window(window.into(), cx);
4517
4518 let thread_store = cx.new(|cx| ThreadStore::new(cx));
4519
4520 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
4521 let workspace_handle = cx.weak_entity();
4522 let message_editor = cx.new(|cx| {
4523 MessageEditor::new(
4524 workspace_handle,
4525 project.downgrade(),
4526 Some(thread_store),
4527 Default::default(),
4528 "Test Agent".into(),
4529 "Test",
4530 EditorMode::AutoHeight {
4531 max_lines: None,
4532 min_lines: 1,
4533 },
4534 window,
4535 cx,
4536 )
4537 });
4538 workspace.active_pane().update(cx, |pane, cx| {
4539 pane.add_item(
4540 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4541 true,
4542 true,
4543 None,
4544 window,
4545 cx,
4546 );
4547 });
4548 message_editor.read(cx).focus_handle(cx).focus(window, cx);
4549 let editor = message_editor.read(cx).editor().clone();
4550 (message_editor, editor)
4551 });
4552
4553 editor.update_in(&mut cx, |editor, window, cx| {
4554 editor.set_text(
4555 "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA",
4556 window,
4557 cx,
4558 );
4559 });
4560
4561 cx.run_until_parked();
4562
4563 editor.update_in(&mut cx, |editor, window, cx| {
4564 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
4565 s.select_ranges([
4566 MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large)
4567 MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest)
4568 ]);
4569 });
4570 });
4571
4572 let mention_link = "[@f](file:///test.txt)";
4573 cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into()));
4574
4575 message_editor.update_in(&mut cx, |message_editor, window, cx| {
4576 message_editor.paste(&Paste, window, cx);
4577 });
4578
4579 let text = editor.update(&mut cx, |editor, cx| editor.text(cx));
4580 assert!(
4581 text.contains("[@f](file:///test.txt)"),
4582 "Expected mention link to be pasted, got: {}",
4583 text
4584 );
4585 }
4586
4587 #[gpui::test]
4588 async fn test_copy_with_selection_mentions_serializes_links(cx: &mut TestAppContext) {
4589 init_test(cx);
4590
4591 let (source_message_editor, _source_editor, mut cx) = setup_paste_test_message_editor(
4592 json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
4593 cx,
4594 )
4595 .await;
4596
4597 let workspace = source_message_editor.read_with(&cx, |message_editor, _| {
4598 message_editor.workspace.upgrade().expect("workspace")
4599 });
4600 let project = workspace.read_with(&cx, |workspace, _| workspace.project().clone());
4601
4602 let source_text = "selection needs work\nselection looks fine";
4603 let first_range = 0..9;
4604 let second_start = "selection needs work\n".len();
4605 let second_range = second_start..(second_start + "selection".len());
4606 let first_uri = MentionUri::Selection {
4607 abs_path: Some(path!("/project/file.rs").into()),
4608 line_range: 0..=1,
4609 column: None,
4610 };
4611 let second_uri = MentionUri::Selection {
4612 abs_path: Some(path!("/project/file.rs").into()),
4613 line_range: 2..=3,
4614 column: None,
4615 };
4616
4617 source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
4618 message_editor.set_text(source_text, window, cx);
4619
4620 let snapshot = message_editor
4621 .editor
4622 .read(cx)
4623 .buffer()
4624 .read(cx)
4625 .snapshot(cx);
4626 for (range, uri, content) in [
4627 (
4628 first_range.clone(),
4629 first_uri.clone(),
4630 "line 1\nline 2\n".to_string(),
4631 ),
4632 (
4633 second_range.clone(),
4634 second_uri.clone(),
4635 "line 3\nline 4\n".to_string(),
4636 ),
4637 ] {
4638 let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
4639 snapshot
4640 .anchor_to_buffer_anchor(
4641 snapshot.anchor_before(MultiBufferOffset(range.start)),
4642 )
4643 .expect("selection mention anchor should map to a buffer")
4644 .0,
4645 range.len(),
4646 uri.name().into(),
4647 uri.icon_path(cx),
4648 uri.tooltip_text(),
4649 Some(uri.clone()),
4650 Some(message_editor.workspace.clone()),
4651 None,
4652 message_editor.editor.clone(),
4653 window,
4654 cx,
4655 ) else {
4656 panic!("expected mention crease insertion");
4657 };
4658 drop(tx);
4659
4660 message_editor.mention_set.update(cx, |mention_set, cx| {
4661 mention_set.insert_mention(
4662 crease_id,
4663 uri,
4664 Task::ready(Ok(Mention::Text {
4665 content,
4666 tracked_buffers: Vec::new(),
4667 }))
4668 .shared(),
4669 None,
4670 cx,
4671 );
4672 });
4673 }
4674
4675 let buffer_len = snapshot.len();
4676 message_editor.editor.update(cx, |editor, cx| {
4677 editor.change_selections(Default::default(), window, cx, |selections| {
4678 selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
4679 });
4680 });
4681 });
4682
4683 let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| {
4684 message_editor
4685 .serialize_selection_with_mentions(false, cx)
4686 .map(|(text, _)| text)
4687 .expect("selection mentions should serialize")
4688 });
4689 let expected_text = format!(
4690 "{} needs work\n{} looks fine",
4691 first_uri.as_link(),
4692 second_uri.as_link()
4693 );
4694 assert_eq!(copied_text, expected_text);
4695
4696 let target_message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
4697 let workspace_handle = cx.weak_entity();
4698 let thread_store = cx.new(|cx| ThreadStore::new(cx));
4699 let message_editor = cx.new(|cx| {
4700 MessageEditor::new(
4701 workspace_handle,
4702 project.downgrade(),
4703 Some(thread_store),
4704 Default::default(),
4705 "Test Agent".into(),
4706 "Test",
4707 EditorMode::AutoHeight {
4708 max_lines: None,
4709 min_lines: 1,
4710 },
4711 window,
4712 cx,
4713 )
4714 });
4715 workspace.active_pane().update(cx, |pane, cx| {
4716 pane.add_item(
4717 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
4718 true,
4719 true,
4720 None,
4721 window,
4722 cx,
4723 );
4724 });
4725 message_editor.read(cx).focus_handle(cx).focus(window, cx);
4726 message_editor
4727 });
4728
4729 cx.write_to_clipboard(ClipboardItem::new_string(copied_text));
4730 target_message_editor.update_in(&mut cx, |message_editor, window, cx| {
4731 message_editor.paste(&Paste, window, cx);
4732 });
4733 cx.run_until_parked();
4734
4735 let target_text = target_message_editor.read_with(&cx, |message_editor, cx| {
4736 message_editor.editor.read(cx).text(cx)
4737 });
4738 assert_eq!(target_text, expected_text);
4739
4740 let contents = mention_contents(&target_message_editor, &mut cx).await;
4741 assert_eq!(contents.len(), 2);
4742 assert!(contents.iter().any(|(uri, _)| uri == &first_uri));
4743 assert!(contents.iter().any(|(uri, _)| uri == &second_uri));
4744 }
4745
4746 struct SelectionMentionFixture {
4747 message_editor: Entity<MessageEditor>,
4748 first_uri: MentionUri,
4749 first_range: Range<usize>,
4750 second_uri: MentionUri,
4751 second_range: Range<usize>,
4752 buffer_len: MultiBufferOffset,
4753 }
4754
4755 async fn setup_selection_mention_fixture(
4756 cx: &mut TestAppContext,
4757 ) -> (SelectionMentionFixture, VisualTestContext) {
4758 let (message_editor, _source_editor, mut cx) = setup_paste_test_message_editor(
4759 json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
4760 cx,
4761 )
4762 .await;
4763
4764 let source_text = "selection needs work\nselection looks fine";
4765 let first_range = 0..9;
4766 let second_start = "selection needs work\n".len();
4767 let second_range = second_start..(second_start + "selection".len());
4768 let first_uri = MentionUri::Selection {
4769 abs_path: Some(path!("/project/file.rs").into()),
4770 line_range: 0..=1,
4771 column: None,
4772 };
4773 let second_uri = MentionUri::Selection {
4774 abs_path: Some(path!("/project/file.rs").into()),
4775 line_range: 2..=3,
4776 column: None,
4777 };
4778
4779 let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
4780 message_editor.set_text(source_text, window, cx);
4781
4782 let snapshot = message_editor
4783 .editor
4784 .read(cx)
4785 .buffer()
4786 .read(cx)
4787 .snapshot(cx);
4788 for (range, uri, content) in [
4789 (
4790 first_range.clone(),
4791 first_uri.clone(),
4792 "line 1\nline 2\n".to_string(),
4793 ),
4794 (
4795 second_range.clone(),
4796 second_uri.clone(),
4797 "line 3\nline 4\n".to_string(),
4798 ),
4799 ] {
4800 let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
4801 snapshot
4802 .anchor_to_buffer_anchor(
4803 snapshot.anchor_before(MultiBufferOffset(range.start)),
4804 )
4805 .expect("selection mention anchor should map to a buffer")
4806 .0,
4807 range.len(),
4808 uri.name().into(),
4809 uri.icon_path(cx),
4810 uri.tooltip_text(),
4811 Some(uri.clone()),
4812 Some(message_editor.workspace.clone()),
4813 None,
4814 message_editor.editor.clone(),
4815 window,
4816 cx,
4817 ) else {
4818 panic!("expected mention crease insertion");
4819 };
4820 drop(tx);
4821
4822 message_editor.mention_set.update(cx, |mention_set, cx| {
4823 mention_set.insert_mention(
4824 crease_id,
4825 uri,
4826 Task::ready(Ok(Mention::Text {
4827 content,
4828 tracked_buffers: Vec::new(),
4829 }))
4830 .shared(),
4831 None,
4832 cx,
4833 );
4834 });
4835 }
4836
4837 snapshot.len()
4838 });
4839
4840 (
4841 SelectionMentionFixture {
4842 message_editor,
4843 first_uri,
4844 first_range,
4845 second_uri,
4846 second_range,
4847 buffer_len,
4848 },
4849 cx,
4850 )
4851 }
4852
4853 #[gpui::test]
4854 async fn test_serialized_copy_text_selection_covers_only_mention(cx: &mut TestAppContext) {
4855 init_test(cx);
4856
4857 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
4858
4859 fixture
4860 .message_editor
4861 .update_in(&mut cx, |message_editor, window, cx| {
4862 let range = fixture.first_range.clone();
4863 message_editor.editor.update(cx, |editor, cx| {
4864 editor.change_selections(Default::default(), window, cx, |selections| {
4865 selections.select_ranges([
4866 MultiBufferOffset(range.start)..MultiBufferOffset(range.end)
4867 ]);
4868 });
4869 });
4870 });
4871
4872 let copied = fixture
4873 .message_editor
4874 .update(&mut cx, |message_editor, cx| {
4875 message_editor
4876 .serialize_selection_with_mentions(false, cx)
4877 .map(|(text, _)| text)
4878 });
4879
4880 assert_eq!(copied, Some(fixture.first_uri.as_link().to_string()));
4881 }
4882
4883 #[gpui::test]
4884 async fn test_serialized_copy_text_returns_none_when_mentions_outside_selection(
4885 cx: &mut TestAppContext,
4886 ) {
4887 init_test(cx);
4888
4889 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
4890
4891 let between_start = fixture.first_range.end;
4892 let between_end = fixture.second_range.start - 1;
4893
4894 fixture
4895 .message_editor
4896 .update_in(&mut cx, |message_editor, window, cx| {
4897 message_editor.editor.update(cx, |editor, cx| {
4898 editor.change_selections(Default::default(), window, cx, |selections| {
4899 selections.select_ranges([
4900 MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
4901 ]);
4902 });
4903 });
4904 });
4905
4906 let copied = fixture
4907 .message_editor
4908 .update(&mut cx, |message_editor, cx| {
4909 message_editor
4910 .serialize_selection_with_mentions(false, cx)
4911 .map(|(text, _)| text)
4912 });
4913
4914 assert_eq!(copied, None);
4915 }
4916
4917 #[gpui::test]
4918 async fn test_draft_content_blocks_snapshot_preserves_selection_mentions(
4919 cx: &mut TestAppContext,
4920 ) {
4921 init_test(cx);
4922
4923 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
4924
4925 let blocks = fixture.message_editor.update(&mut cx, |editor, cx| {
4926 editor
4927 .session_capabilities
4928 .write()
4929 .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
4930 editor.draft_content_blocks_snapshot(cx)
4931 });
4932
4933 // Each selection mention must round-trip as a `Resource` block carrying
4934 // its URI and content, not as a `Text` block containing the fold
4935 // placeholder string.
4936 let resource_uris: Vec<&str> =
4937 blocks
4938 .iter()
4939 .filter_map(|block| match block {
4940 acp::ContentBlock::Resource(acp::EmbeddedResource {
4941 resource:
4942 acp::EmbeddedResourceResource::TextResourceContents(
4943 acp::TextResourceContents { uri, .. },
4944 ),
4945 ..
4946 }) => Some(uri.as_str()),
4947 _ => None,
4948 })
4949 .collect();
4950 assert_eq!(
4951 resource_uris.len(),
4952 2,
4953 "snapshot should emit one Resource block per selection mention; got {blocks:#?}"
4954 );
4955 assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str()));
4956 for block in &blocks {
4957 if let acp::ContentBlock::Text(text) = block {
4958 assert!(
4959 !text.text.split_whitespace().any(|word| word == "selection"),
4960 "text block must not contain bare fold placeholder: {:?}",
4961 text.text
4962 );
4963 }
4964 }
4965 }
4966
4967 #[gpui::test]
4968 async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) {
4969 init_test(cx);
4970
4971 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
4972
4973 let buffer_len = fixture.buffer_len;
4974 fixture
4975 .message_editor
4976 .update_in(&mut cx, |message_editor, window, cx| {
4977 message_editor.editor.update(cx, |editor, cx| {
4978 editor.change_selections(Default::default(), window, cx, |selections| {
4979 selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
4980 });
4981 });
4982 message_editor.cut(&Cut, window, cx);
4983 });
4984
4985 let expected_text = format!(
4986 "{} needs work\n{} looks fine",
4987 fixture.first_uri.as_link(),
4988 fixture.second_uri.as_link()
4989 );
4990
4991 let clipboard_text = cx
4992 .read_from_clipboard()
4993 .and_then(|item| match item.entries().first().cloned() {
4994 Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
4995 _ => None,
4996 })
4997 .expect("cut should write serialized text to clipboard");
4998 assert_eq!(clipboard_text, expected_text);
4999
5000 let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
5001 message_editor.editor.read(cx).text(cx)
5002 });
5003 assert_eq!(remaining_text, "");
5004 }
5005
5006 #[gpui::test]
5007 async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line(
5008 cx: &mut TestAppContext,
5009 ) {
5010 init_test(cx);
5011
5012 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
5013
5014 let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4);
5015 fixture
5016 .message_editor
5017 .update_in(&mut cx, |message_editor, window, cx| {
5018 message_editor.editor.update(cx, |editor, cx| {
5019 editor.change_selections(Default::default(), window, cx, |selections| {
5020 selections.select_ranges([cursor_offset..cursor_offset]);
5021 });
5022 });
5023 message_editor.cut(&Cut, window, cx);
5024 });
5025
5026 let clipboard_text = cx
5027 .read_from_clipboard()
5028 .and_then(|item| match item.entries().first().cloned() {
5029 Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
5030 _ => None,
5031 })
5032 .expect("cut should write serialized text to clipboard");
5033 assert_eq!(
5034 clipboard_text,
5035 format!("{} needs work\n", fixture.first_uri.as_link())
5036 );
5037
5038 let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
5039 message_editor.editor.read(cx).text(cx)
5040 });
5041 assert_eq!(remaining_text, "selection looks fine");
5042 }
5043
5044 #[gpui::test]
5045 async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection(
5046 cx: &mut TestAppContext,
5047 ) {
5048 init_test(cx);
5049
5050 let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
5051
5052 let between_start = fixture.first_range.end;
5053 let between_end = fixture.second_range.start - 1;
5054 fixture
5055 .message_editor
5056 .update_in(&mut cx, |message_editor, window, cx| {
5057 message_editor.editor.update(cx, |editor, cx| {
5058 editor.change_selections(Default::default(), window, cx, |selections| {
5059 selections.select_ranges([
5060 MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
5061 ]);
5062 });
5063 });
5064 });
5065
5066 let result = fixture
5067 .message_editor
5068 .update(&mut cx, |message_editor, cx| {
5069 message_editor.serialize_selection_with_mentions(true, cx)
5070 });
5071
5072 assert!(
5073 result.is_none(),
5074 "serialize_selection_with_mentions should return None so the default editor cut runs"
5075 );
5076 }
5077
5078 #[gpui::test]
5079 async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
5080 cx: &mut TestAppContext,
5081 ) {
5082 init_test(cx);
5083
5084 let app_state = cx.update(AppState::test);
5085
5086 cx.update(|cx| {
5087 editor::init(cx);
5088 workspace::init(app_state.clone(), cx);
5089 });
5090
5091 app_state
5092 .fs
5093 .as_fake()
5094 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
5095 .await;
5096
5097 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
5098 let window =
5099 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5100 let workspace = window
5101 .read_with(cx, |mw, _| mw.workspace().clone())
5102 .unwrap();
5103
5104 let mut cx = VisualTestContext::from_window(window.into(), cx);
5105
5106 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5107
5108 let (_message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
5109 let workspace_handle = cx.weak_entity();
5110 let message_editor = cx.new(|cx| {
5111 MessageEditor::new(
5112 workspace_handle,
5113 project.downgrade(),
5114 Some(thread_store),
5115 Default::default(),
5116 "Test Agent".into(),
5117 "Test",
5118 EditorMode::AutoHeight {
5119 max_lines: None,
5120 min_lines: 1,
5121 },
5122 window,
5123 cx,
5124 )
5125 });
5126 workspace.active_pane().update(cx, |pane, cx| {
5127 pane.add_item(
5128 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
5129 true,
5130 true,
5131 None,
5132 window,
5133 cx,
5134 );
5135 });
5136 message_editor.read(cx).focus_handle(cx).focus(window, cx);
5137 let editor = message_editor.read(cx).editor().clone();
5138 (message_editor, editor)
5139 });
5140
5141 cx.simulate_input("@");
5142
5143 editor.update(&mut cx, |editor, cx| {
5144 assert_eq!(editor.text(cx), "@");
5145 assert!(editor.has_visible_completions_menu());
5146 });
5147
5148 cx.write_to_clipboard(ClipboardItem::new_string("[@f](file:///test.txt) @".into()));
5149 cx.dispatch_action(Paste);
5150
5151 editor.update(&mut cx, |editor, cx| {
5152 assert!(editor.text(cx).contains("[@f](file:///test.txt)"));
5153 });
5154 }
5155
5156 #[gpui::test]
5157 async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) {
5158 init_test(cx);
5159 let (message_editor, editor, mut cx) =
5160 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
5161 paste_external_paths(
5162 &message_editor,
5163 vec![PathBuf::from(path!("/project/file.txt"))],
5164 &mut cx,
5165 );
5166
5167 let expected_uri = MentionUri::File {
5168 abs_path: path!("/project/file.txt").into(),
5169 }
5170 .to_uri()
5171 .to_string();
5172
5173 editor.update(&mut cx, |editor, cx| {
5174 assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) "));
5175 });
5176
5177 let contents = mention_contents(&message_editor, &mut cx).await;
5178
5179 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
5180 panic!("Unexpected mentions");
5181 };
5182 assert_eq!(content, "content");
5183 assert_eq!(
5184 uri,
5185 &MentionUri::File {
5186 abs_path: path!("/project/file.txt").into(),
5187 }
5188 );
5189 }
5190
5191 #[gpui::test]
5192 async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) {
5193 init_test(cx);
5194 let (message_editor, editor, mut cx) = setup_paste_test_message_editor(
5195 json!({
5196 "src": {
5197 "main.rs": "fn main() {}\n",
5198 }
5199 }),
5200 cx,
5201 )
5202 .await;
5203 paste_external_paths(
5204 &message_editor,
5205 vec![PathBuf::from(path!("/project/src"))],
5206 &mut cx,
5207 );
5208
5209 let expected_uri = MentionUri::Directory {
5210 abs_path: path!("/project/src").into(),
5211 }
5212 .to_uri()
5213 .to_string();
5214
5215 editor.update(&mut cx, |editor, cx| {
5216 assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) "));
5217 });
5218
5219 let contents = mention_contents(&message_editor, &mut cx).await;
5220
5221 let [(uri, Mention::Link)] = contents.as_slice() else {
5222 panic!("Unexpected mentions");
5223 };
5224 assert_eq!(
5225 uri,
5226 &MentionUri::Directory {
5227 abs_path: path!("/project/src").into(),
5228 }
5229 );
5230 }
5231
5232 #[gpui::test]
5233 async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) {
5234 init_test(cx);
5235 let (message_editor, editor, mut cx) =
5236 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
5237
5238 editor.update_in(&mut cx, |editor, window, cx| {
5239 editor.set_text("Hello world", window, cx);
5240 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
5241 selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
5242 });
5243 });
5244
5245 paste_external_paths(
5246 &message_editor,
5247 vec![PathBuf::from(path!("/project/file.txt"))],
5248 &mut cx,
5249 );
5250
5251 let expected_uri = MentionUri::File {
5252 abs_path: path!("/project/file.txt").into(),
5253 }
5254 .to_uri()
5255 .to_string();
5256
5257 editor.update(&mut cx, |editor, cx| {
5258 assert_eq!(
5259 editor.text(cx),
5260 format!("Hello [@file.txt]({expected_uri}) world")
5261 );
5262 });
5263 }
5264
5265 #[gpui::test]
5266 async fn test_dragged_file_path_inserts_at_cursor(cx: &mut TestAppContext) {
5267 init_test(cx);
5268 let (message_editor, editor, mut cx) =
5269 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
5270
5271 editor.update_in(&mut cx, |editor, window, cx| {
5272 editor.set_text("Hello world", window, cx);
5273 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
5274 selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
5275 });
5276 });
5277
5278 insert_dragged_project_paths(&message_editor, vec!["file.txt"], &mut cx);
5279
5280 let expected_uri = MentionUri::File {
5281 abs_path: path!("/project/file.txt").into(),
5282 }
5283 .to_uri()
5284 .to_string();
5285
5286 editor.update(&mut cx, |editor, cx| {
5287 assert_eq!(
5288 editor.text(cx),
5289 format!("Hello [@file.txt]({expected_uri}) world")
5290 );
5291 });
5292
5293 let contents = mention_contents(&message_editor, &mut cx).await;
5294
5295 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
5296 panic!("Unexpected mentions");
5297 };
5298 assert_eq!(content, "content");
5299 assert_eq!(
5300 uri,
5301 &MentionUri::File {
5302 abs_path: path!("/project/file.txt").into(),
5303 }
5304 );
5305 }
5306
5307 #[gpui::test]
5308 async fn test_dragged_file_paths_insert_in_order_at_cursor(cx: &mut TestAppContext) {
5309 init_test(cx);
5310 let (message_editor, editor, mut cx) = setup_paste_test_message_editor(
5311 json!({
5312 "one.txt": "one",
5313 "two.txt": "two",
5314 }),
5315 cx,
5316 )
5317 .await;
5318
5319 editor.update_in(&mut cx, |editor, window, cx| {
5320 editor.set_text("Hello world", window, cx);
5321 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
5322 selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
5323 });
5324 });
5325
5326 insert_dragged_project_paths(&message_editor, vec!["one.txt", "two.txt"], &mut cx);
5327
5328 let first_uri = MentionUri::File {
5329 abs_path: path!("/project/one.txt").into(),
5330 }
5331 .to_uri()
5332 .to_string();
5333 let second_uri = MentionUri::File {
5334 abs_path: path!("/project/two.txt").into(),
5335 }
5336 .to_uri()
5337 .to_string();
5338
5339 editor.update(&mut cx, |editor, cx| {
5340 assert_eq!(
5341 editor.text(cx),
5342 format!("Hello [@one.txt]({first_uri}) [@two.txt]({second_uri}) world")
5343 );
5344 });
5345 }
5346
5347 #[gpui::test]
5348 async fn test_paste_mixed_external_image_without_extension_and_file_path(
5349 cx: &mut TestAppContext,
5350 ) {
5351 init_test(cx);
5352 let (message_editor, editor, mut cx) =
5353 setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
5354
5355 message_editor.update(&mut cx, |message_editor, _cx| {
5356 message_editor
5357 .session_capabilities
5358 .write()
5359 .set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
5360 });
5361
5362 let temporary_image_path = write_test_png_file(None);
5363 paste_external_paths(
5364 &message_editor,
5365 vec![
5366 temporary_image_path.clone(),
5367 PathBuf::from(path!("/project/file.txt")),
5368 ],
5369 &mut cx,
5370 );
5371
5372 let image_name = temporary_image_path
5373 .file_name()
5374 .and_then(|n| n.to_str())
5375 .unwrap_or("Image")
5376 .to_string();
5377 std::fs::remove_file(&temporary_image_path).expect("remove temp png");
5378
5379 let expected_file_uri = MentionUri::File {
5380 abs_path: path!("/project/file.txt").into(),
5381 }
5382 .to_uri()
5383 .to_string();
5384 let expected_image_uri = MentionUri::PastedImage {
5385 name: image_name.clone(),
5386 }
5387 .to_uri()
5388 .to_string();
5389
5390 editor.update(&mut cx, |editor, cx| {
5391 assert_eq!(
5392 editor.text(cx),
5393 format!("[@{image_name}]({expected_image_uri}) [@file.txt]({expected_file_uri}) ")
5394 );
5395 });
5396
5397 let contents = mention_contents(&message_editor, &mut cx).await;
5398
5399 assert_eq!(contents.len(), 2);
5400 assert!(contents.iter().any(|(uri, mention)| {
5401 matches!(uri, MentionUri::PastedImage { .. }) && matches!(mention, Mention::Image(_))
5402 }));
5403 assert!(contents.iter().any(|(uri, mention)| {
5404 *uri == MentionUri::File {
5405 abs_path: path!("/project/file.txt").into(),
5406 } && matches!(
5407 mention,
5408 Mention::Text {
5409 content,
5410 tracked_buffers: _,
5411 } if content == "content"
5412 )
5413 }));
5414 }
5415
5416 async fn setup_paste_test_message_editor(
5417 project_tree: Value,
5418 cx: &mut TestAppContext,
5419 ) -> (Entity<MessageEditor>, Entity<Editor>, VisualTestContext) {
5420 let app_state = cx.update(AppState::test);
5421
5422 cx.update(|cx| {
5423 editor::init(cx);
5424 workspace::init(app_state.clone(), cx);
5425 });
5426
5427 app_state
5428 .fs
5429 .as_fake()
5430 .insert_tree(path!("/project"), project_tree)
5431 .await;
5432
5433 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
5434 let window =
5435 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5436 let workspace = window
5437 .read_with(cx, |mw, _| mw.workspace().clone())
5438 .unwrap();
5439
5440 let mut cx = VisualTestContext::from_window(window.into(), cx);
5441
5442 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5443
5444 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
5445 let workspace_handle = cx.weak_entity();
5446 let message_editor = cx.new(|cx| {
5447 MessageEditor::new(
5448 workspace_handle,
5449 project.downgrade(),
5450 Some(thread_store),
5451 Default::default(),
5452 "Test Agent".into(),
5453 "Test",
5454 EditorMode::AutoHeight {
5455 max_lines: None,
5456 min_lines: 1,
5457 },
5458 window,
5459 cx,
5460 )
5461 });
5462 workspace.active_pane().update(cx, |pane, cx| {
5463 pane.add_item(
5464 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
5465 true,
5466 true,
5467 None,
5468 window,
5469 cx,
5470 );
5471 });
5472 message_editor.read(cx).focus_handle(cx).focus(window, cx);
5473 let editor = message_editor.read(cx).editor().clone();
5474 (message_editor, editor)
5475 });
5476
5477 (message_editor, editor, cx)
5478 }
5479
5480 fn paste_external_paths(
5481 message_editor: &Entity<MessageEditor>,
5482 paths: Vec<PathBuf>,
5483 cx: &mut VisualTestContext,
5484 ) {
5485 cx.write_to_clipboard(ClipboardItem {
5486 entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))],
5487 });
5488
5489 message_editor.update_in(cx, |message_editor, window, cx| {
5490 message_editor.paste(&Paste, window, cx);
5491 });
5492 cx.run_until_parked();
5493 }
5494
5495 fn insert_dragged_project_paths(
5496 message_editor: &Entity<MessageEditor>,
5497 paths: Vec<&str>,
5498 cx: &mut VisualTestContext,
5499 ) {
5500 message_editor.update_in(cx, |message_editor, window, cx| {
5501 let workspace = message_editor
5502 .workspace
5503 .upgrade()
5504 .expect("message editor should keep workspace alive");
5505 let project = workspace.read(cx).project().clone();
5506 let worktree_id = project.update(cx, |project, cx| {
5507 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
5508 assert_eq!(worktrees.len(), 1, "expected a single worktree");
5509 worktrees.pop().unwrap().read(cx).id()
5510 });
5511
5512 let paths = paths
5513 .into_iter()
5514 .map(|path| ProjectPath {
5515 worktree_id,
5516 path: rel_path(path).into(),
5517 })
5518 .collect();
5519
5520 message_editor.insert_dragged_files(paths, vec![], window, cx);
5521 });
5522 cx.run_until_parked();
5523 }
5524
5525 async fn mention_contents(
5526 message_editor: &Entity<MessageEditor>,
5527 cx: &mut VisualTestContext,
5528 ) -> Vec<(MentionUri, Mention)> {
5529 message_editor
5530 .update(cx, |message_editor, cx| {
5531 message_editor
5532 .mention_set()
5533 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
5534 })
5535 .await
5536 .unwrap()
5537 .into_values()
5538 .collect::<Vec<_>>()
5539 }
5540
5541 fn write_test_png_file(extension: Option<&str>) -> PathBuf {
5542 let bytes = base64::prelude::BASE64_STANDARD
5543 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
5544 .expect("decode png");
5545 let file_name = match extension {
5546 Some(extension) => {
5547 format!("omega-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension)
5548 }
5549 None => format!("omega-agent-ui-test-{}", uuid::Uuid::new_v4()),
5550 };
5551 let path = std::env::temp_dir().join(file_name);
5552 std::fs::write(&path, bytes).expect("write temp png");
5553 path
5554 }
5555
5556 // Helper that creates a minimal MessageEditor inside a window, returning both
5557 // the entity and the underlying VisualTestContext so callers can drive updates.
5558 async fn setup_message_editor(
5559 cx: &mut TestAppContext,
5560 ) -> (Entity<MessageEditor>, &mut VisualTestContext) {
5561 let fs = FakeFs::new(cx.executor());
5562 fs.insert_tree("/project", json!({"file.txt": ""})).await;
5563 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
5564
5565 let (multi_workspace, cx) =
5566 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5567 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5568
5569 let message_editor = cx.update(|window, cx| {
5570 cx.new(|cx| {
5571 MessageEditor::new(
5572 workspace.downgrade(),
5573 project.downgrade(),
5574 None,
5575 Default::default(),
5576 "Test Agent".into(),
5577 "Test",
5578 EditorMode::AutoHeight {
5579 min_lines: 1,
5580 max_lines: None,
5581 },
5582 window,
5583 cx,
5584 )
5585 })
5586 });
5587
5588 cx.run_until_parked();
5589 (message_editor, cx)
5590 }
5591
5592 #[gpui::test]
5593 async fn test_set_message_plain_text(cx: &mut TestAppContext) {
5594 init_test(cx);
5595 let (message_editor, cx) = setup_message_editor(cx).await;
5596
5597 message_editor.update_in(cx, |editor, window, cx| {
5598 editor.set_message(
5599 vec![acp::ContentBlock::Text(acp::TextContent::new(
5600 "hello world".to_string(),
5601 ))],
5602 window,
5603 cx,
5604 );
5605 });
5606
5607 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5608 assert_eq!(text, "hello world");
5609 assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx)));
5610 }
5611
5612 #[gpui::test]
5613 async fn test_set_message_normalizes_crlf_before_mention(cx: &mut TestAppContext) {
5614 init_test(cx);
5615 let (message_editor, cx) = setup_message_editor(cx).await;
5616
5617 message_editor.update_in(cx, |editor, window, cx| {
5618 editor.set_message(
5619 vec![
5620 acp::ContentBlock::Text(acp::TextContent::new("before\r\n".to_string())),
5621 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
5622 "file.txt",
5623 "file:///project/file.txt",
5624 )),
5625 ],
5626 window,
5627 cx,
5628 );
5629 });
5630
5631 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5632 assert_eq!(text, "before\n[@file.txt](file:///project/file.txt)");
5633
5634 let mention_uris =
5635 message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
5636 assert_eq!(mention_uris.len(), 1);
5637 }
5638
5639 #[gpui::test]
5640 async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) {
5641 init_test(cx);
5642 let (message_editor, cx) = setup_message_editor(cx).await;
5643
5644 // Set initial content.
5645 message_editor.update_in(cx, |editor, window, cx| {
5646 editor.set_message(
5647 vec![acp::ContentBlock::Text(acp::TextContent::new(
5648 "old content".to_string(),
5649 ))],
5650 window,
5651 cx,
5652 );
5653 });
5654
5655 // Replace with new content.
5656 message_editor.update_in(cx, |editor, window, cx| {
5657 editor.set_message(
5658 vec![acp::ContentBlock::Text(acp::TextContent::new(
5659 "new content".to_string(),
5660 ))],
5661 window,
5662 cx,
5663 );
5664 });
5665
5666 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5667 assert_eq!(
5668 text, "new content",
5669 "set_message should replace old content"
5670 );
5671 }
5672
5673 #[gpui::test]
5674 async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) {
5675 init_test(cx);
5676 let (message_editor, cx) = setup_message_editor(cx).await;
5677
5678 message_editor.update_in(cx, |editor, window, cx| {
5679 editor.append_message(
5680 vec![acp::ContentBlock::Text(acp::TextContent::new(
5681 "appended".to_string(),
5682 ))],
5683 Some("\n\n"),
5684 window,
5685 cx,
5686 );
5687 });
5688
5689 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5690 assert_eq!(
5691 text, "appended",
5692 "No separator should be inserted when the editor is empty"
5693 );
5694 }
5695
5696 #[gpui::test]
5697 async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) {
5698 init_test(cx);
5699 let (message_editor, cx) = setup_message_editor(cx).await;
5700
5701 // Seed initial content.
5702 message_editor.update_in(cx, |editor, window, cx| {
5703 editor.set_message(
5704 vec![acp::ContentBlock::Text(acp::TextContent::new(
5705 "initial".to_string(),
5706 ))],
5707 window,
5708 cx,
5709 );
5710 });
5711
5712 // Append with separator.
5713 message_editor.update_in(cx, |editor, window, cx| {
5714 editor.append_message(
5715 vec![acp::ContentBlock::Text(acp::TextContent::new(
5716 "appended".to_string(),
5717 ))],
5718 Some("\n\n"),
5719 window,
5720 cx,
5721 );
5722 });
5723
5724 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5725 assert_eq!(
5726 text, "initial\n\nappended",
5727 "Separator should appear between existing and appended content"
5728 );
5729 }
5730
5731 #[gpui::test]
5732 async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
5733 init_test(cx);
5734
5735 let fs = FakeFs::new(cx.executor());
5736 fs.insert_tree("/project", json!({"file.txt": "content"}))
5737 .await;
5738 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
5739
5740 let (multi_workspace, cx) =
5741 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5742 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5743
5744 let message_editor = cx.update(|window, cx| {
5745 cx.new(|cx| {
5746 MessageEditor::new(
5747 workspace.downgrade(),
5748 project.downgrade(),
5749 None,
5750 Default::default(),
5751 "Test Agent".into(),
5752 "Test",
5753 EditorMode::AutoHeight {
5754 min_lines: 1,
5755 max_lines: None,
5756 },
5757 window,
5758 cx,
5759 )
5760 })
5761 });
5762
5763 cx.run_until_parked();
5764
5765 // Seed plain-text prefix so the editor is non-empty before appending.
5766 message_editor.update_in(cx, |editor, window, cx| {
5767 editor.set_message(
5768 vec![acp::ContentBlock::Text(acp::TextContent::new(
5769 "prefix text".to_string(),
5770 ))],
5771 window,
5772 cx,
5773 );
5774 });
5775
5776 // Append a message that contains a ResourceLink mention.
5777 message_editor.update_in(cx, |editor, window, cx| {
5778 editor.append_message(
5779 vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
5780 "file.txt",
5781 "file:///project/file.txt",
5782 ))],
5783 Some("\n\n"),
5784 window,
5785 cx,
5786 );
5787 });
5788
5789 cx.run_until_parked();
5790
5791 // The mention should be registered in the mention_set so that contents()
5792 // will emit it as a structured block rather than plain text.
5793 let mention_uris =
5794 message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
5795 assert_eq!(
5796 mention_uris.len(),
5797 1,
5798 "Expected exactly one mention in the mention_set after append, got: {mention_uris:?}"
5799 );
5800
5801 // The editor text should start with the prefix, then the separator, then
5802 // the mention placeholder — confirming the offset was computed correctly.
5803 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
5804 assert!(
5805 text.starts_with("prefix text\n\n"),
5806 "Expected text to start with 'prefix text\\n\\n', got: {text:?}"
5807 );
5808 }
5809}
5810