message_editor.rs

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