Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:35.916Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

message_editor.rs

5810 lines · 211.8 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.