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