message_editor.rs

   1use crate::{
   2    acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion},
   3    context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
   4};
   5use acp_thread::{MentionUri, selection_name};
   6use agent_client_protocol as acp;
   7use agent_servers::{AgentServer, AgentServerDelegate};
   8use agent2::HistoryStore;
   9use anyhow::{Result, anyhow};
  10use assistant_slash_commands::codeblock_fence_for_path;
  11use assistant_tool::outline;
  12use collections::{HashMap, HashSet};
  13use editor::{
  14    Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
  15    EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId,
  16    MultiBuffer, ToOffset,
  17    actions::Paste,
  18    display_map::{Crease, CreaseId, FoldId, Inlay},
  19};
  20use futures::{
  21    FutureExt as _,
  22    future::{Shared, join_all},
  23};
  24use gpui::{
  25    Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
  26    EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString,
  27    Subscription, Task, TextStyle, WeakEntity, pulsating_between,
  28};
  29use language::{Buffer, Language, language_settings::InlayHintKind};
  30use language_model::LanguageModelImage;
  31use postage::stream::Stream as _;
  32use project::{
  33    CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree,
  34};
  35use prompt_store::{PromptId, PromptStore};
  36use rope::Point;
  37use settings::Settings;
  38use std::{
  39    cell::RefCell,
  40    ffi::OsStr,
  41    fmt::Write,
  42    ops::{Range, RangeInclusive},
  43    path::{Path, PathBuf},
  44    rc::Rc,
  45    sync::Arc,
  46    time::Duration,
  47};
  48use text::OffsetRangeExt;
  49use theme::ThemeSettings;
  50use ui::{ButtonLike, TintColor, Toggleable, prelude::*};
  51use util::{ResultExt, debug_panic, rel_path::RelPath};
  52use workspace::{Workspace, notifications::NotifyResultExt as _};
  53use zed_actions::agent::Chat;
  54
  55pub struct MessageEditor {
  56    mention_set: MentionSet,
  57    editor: Entity<Editor>,
  58    project: Entity<Project>,
  59    workspace: WeakEntity<Workspace>,
  60    history_store: Entity<HistoryStore>,
  61    prompt_store: Option<Entity<PromptStore>>,
  62    prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
  63    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
  64    agent_name: SharedString,
  65    _subscriptions: Vec<Subscription>,
  66    _parse_slash_command_task: Task<()>,
  67}
  68
  69#[derive(Clone, Copy, Debug)]
  70pub enum MessageEditorEvent {
  71    Send,
  72    Cancel,
  73    Focus,
  74    LostFocus,
  75}
  76
  77impl EventEmitter<MessageEditorEvent> for MessageEditor {}
  78
  79const COMMAND_HINT_INLAY_ID: u32 = 0;
  80
  81impl MessageEditor {
  82    pub fn new(
  83        workspace: WeakEntity<Workspace>,
  84        project: Entity<Project>,
  85        history_store: Entity<HistoryStore>,
  86        prompt_store: Option<Entity<PromptStore>>,
  87        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
  88        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
  89        agent_name: SharedString,
  90        placeholder: &str,
  91        mode: EditorMode,
  92        window: &mut Window,
  93        cx: &mut Context<Self>,
  94    ) -> Self {
  95        let language = Language::new(
  96            language::LanguageConfig {
  97                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
  98                ..Default::default()
  99            },
 100            None,
 101        );
 102        let completion_provider = Rc::new(ContextPickerCompletionProvider::new(
 103            cx.weak_entity(),
 104            workspace.clone(),
 105            history_store.clone(),
 106            prompt_store.clone(),
 107            prompt_capabilities.clone(),
 108            available_commands.clone(),
 109        ));
 110        let mention_set = MentionSet::default();
 111        let editor = cx.new(|cx| {
 112            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
 113            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 114
 115            let mut editor = Editor::new(mode, buffer, None, window, cx);
 116            editor.set_placeholder_text(placeholder, window, cx);
 117            editor.set_show_indent_guides(false, cx);
 118            editor.set_soft_wrap();
 119            editor.set_use_modal_editing(true);
 120            editor.set_completion_provider(Some(completion_provider.clone()));
 121            editor.set_context_menu_options(ContextMenuOptions {
 122                min_entries_visible: 12,
 123                max_entries_visible: 12,
 124                placement: Some(ContextMenuPlacement::Above),
 125            });
 126            editor.register_addon(MessageEditorAddon::new());
 127            editor
 128        });
 129
 130        cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
 131            cx.emit(MessageEditorEvent::Focus)
 132        })
 133        .detach();
 134        cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
 135            cx.emit(MessageEditorEvent::LostFocus)
 136        })
 137        .detach();
 138
 139        let mut has_hint = false;
 140        let mut subscriptions = Vec::new();
 141
 142        subscriptions.push(cx.subscribe_in(&editor, window, {
 143            move |this, editor, event, window, cx| {
 144                if let EditorEvent::Edited { .. } = event
 145                    && !editor.read(cx).read_only(cx)
 146                {
 147                    let snapshot = editor.update(cx, |editor, cx| {
 148                        let new_hints = this
 149                            .command_hint(editor.buffer(), cx)
 150                            .into_iter()
 151                            .collect::<Vec<_>>();
 152                        let has_new_hint = !new_hints.is_empty();
 153                        editor.splice_inlays(
 154                            if has_hint {
 155                                &[InlayId::Hint(COMMAND_HINT_INLAY_ID)]
 156                            } else {
 157                                &[]
 158                            },
 159                            new_hints,
 160                            cx,
 161                        );
 162                        has_hint = has_new_hint;
 163
 164                        editor.snapshot(window, cx)
 165                    });
 166                    this.mention_set.remove_invalid(snapshot);
 167
 168                    cx.notify();
 169                }
 170            }
 171        }));
 172
 173        Self {
 174            editor,
 175            project,
 176            mention_set,
 177            workspace,
 178            history_store,
 179            prompt_store,
 180            prompt_capabilities,
 181            available_commands,
 182            agent_name,
 183            _subscriptions: subscriptions,
 184            _parse_slash_command_task: Task::ready(()),
 185        }
 186    }
 187
 188    fn command_hint(&self, buffer: &Entity<MultiBuffer>, cx: &App) -> Option<Inlay> {
 189        let available_commands = self.available_commands.borrow();
 190        if available_commands.is_empty() {
 191            return None;
 192        }
 193
 194        let snapshot = buffer.read(cx).snapshot(cx);
 195        let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
 196        if parsed_command.argument.is_some() {
 197            return None;
 198        }
 199
 200        let command_name = parsed_command.command?;
 201        let available_command = available_commands
 202            .iter()
 203            .find(|command| command.name == command_name)?;
 204
 205        let acp::AvailableCommandInput::Unstructured { mut hint } =
 206            available_command.input.clone()?;
 207
 208        let mut hint_pos = parsed_command.source_range.end + 1;
 209        if hint_pos > snapshot.len() {
 210            hint_pos = snapshot.len();
 211            hint.insert(0, ' ');
 212        }
 213
 214        let hint_pos = snapshot.anchor_after(hint_pos);
 215
 216        Some(Inlay::hint(
 217            COMMAND_HINT_INLAY_ID,
 218            hint_pos,
 219            &InlayHint {
 220                position: hint_pos.text_anchor,
 221                label: InlayHintLabel::String(hint),
 222                kind: Some(InlayHintKind::Parameter),
 223                padding_left: false,
 224                padding_right: false,
 225                tooltip: None,
 226                resolve_state: project::ResolveState::Resolved,
 227            },
 228        ))
 229    }
 230
 231    pub fn insert_thread_summary(
 232        &mut self,
 233        thread: agent2::DbThreadMetadata,
 234        window: &mut Window,
 235        cx: &mut Context<Self>,
 236    ) {
 237        let uri = MentionUri::Thread {
 238            id: thread.id.clone(),
 239            name: thread.title.to_string(),
 240        };
 241        let content = format!("{}\n", uri.as_link());
 242
 243        let content_len = content.len() - 1;
 244
 245        let start = self.editor.update(cx, |editor, cx| {
 246            editor.set_text(content, window, cx);
 247            editor
 248                .buffer()
 249                .read(cx)
 250                .snapshot(cx)
 251                .anchor_before(Point::zero())
 252                .text_anchor
 253        });
 254
 255        self.confirm_mention_completion(thread.title.clone(), start, content_len, uri, window, cx)
 256            .detach();
 257    }
 258
 259    #[cfg(test)]
 260    pub(crate) fn editor(&self) -> &Entity<Editor> {
 261        &self.editor
 262    }
 263
 264    #[cfg(test)]
 265    pub(crate) fn mention_set(&mut self) -> &mut MentionSet {
 266        &mut self.mention_set
 267    }
 268
 269    pub fn is_empty(&self, cx: &App) -> bool {
 270        self.editor.read(cx).is_empty(cx)
 271    }
 272
 273    pub fn mentions(&self) -> HashSet<MentionUri> {
 274        self.mention_set
 275            .mentions
 276            .values()
 277            .map(|(uri, _)| uri.clone())
 278            .collect()
 279    }
 280
 281    pub fn confirm_mention_completion(
 282        &mut self,
 283        crease_text: SharedString,
 284        start: text::Anchor,
 285        content_len: usize,
 286        mention_uri: MentionUri,
 287        window: &mut Window,
 288        cx: &mut Context<Self>,
 289    ) -> Task<()> {
 290        let snapshot = self
 291            .editor
 292            .update(cx, |editor, cx| editor.snapshot(window, cx));
 293        let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot().as_singleton() else {
 294            return Task::ready(());
 295        };
 296        let Some(start_anchor) = snapshot
 297            .buffer_snapshot()
 298            .anchor_in_excerpt(*excerpt_id, start)
 299        else {
 300            return Task::ready(());
 301        };
 302        let end_anchor = snapshot
 303            .buffer_snapshot()
 304            .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1);
 305
 306        let crease = if let MentionUri::File { abs_path } = &mention_uri
 307            && let Some(extension) = abs_path.extension()
 308            && let Some(extension) = extension.to_str()
 309            && Img::extensions().contains(&extension)
 310            && !extension.contains("svg")
 311        {
 312            let Some(project_path) = self
 313                .project
 314                .read(cx)
 315                .project_path_for_absolute_path(&abs_path, cx)
 316            else {
 317                log::error!("project path not found");
 318                return Task::ready(());
 319            };
 320            let image = self
 321                .project
 322                .update(cx, |project, cx| project.open_image(project_path, cx));
 323            let image = cx
 324                .spawn(async move |_, cx| {
 325                    let image = image.await.map_err(|e| e.to_string())?;
 326                    let image = image
 327                        .update(cx, |image, _| image.image.clone())
 328                        .map_err(|e| e.to_string())?;
 329                    Ok(image)
 330                })
 331                .shared();
 332            insert_crease_for_mention(
 333                *excerpt_id,
 334                start,
 335                content_len,
 336                mention_uri.name().into(),
 337                IconName::Image.path().into(),
 338                Some(image),
 339                self.editor.clone(),
 340                window,
 341                cx,
 342            )
 343        } else {
 344            insert_crease_for_mention(
 345                *excerpt_id,
 346                start,
 347                content_len,
 348                crease_text,
 349                mention_uri.icon_path(cx),
 350                None,
 351                self.editor.clone(),
 352                window,
 353                cx,
 354            )
 355        };
 356        let Some((crease_id, tx)) = crease else {
 357            return Task::ready(());
 358        };
 359
 360        let task = match mention_uri.clone() {
 361            MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx),
 362            MentionUri::Directory { .. } => Task::ready(Ok(Mention::UriOnly)),
 363            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
 364            MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx),
 365            MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx),
 366            MentionUri::Symbol {
 367                abs_path,
 368                line_range,
 369                ..
 370            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
 371            MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
 372            MentionUri::PastedImage => {
 373                debug_panic!("pasted image URI should not be included in completions");
 374                Task::ready(Err(anyhow!(
 375                    "pasted imaged URI should not be included in completions"
 376                )))
 377            }
 378            MentionUri::Selection { .. } => {
 379                // Handled elsewhere
 380                debug_panic!("unexpected selection URI");
 381                Task::ready(Err(anyhow!("unexpected selection URI")))
 382            }
 383        };
 384        let task = cx
 385            .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
 386            .shared();
 387        self.mention_set
 388            .mentions
 389            .insert(crease_id, (mention_uri, task.clone()));
 390
 391        // Notify the user if we failed to load the mentioned context
 392        cx.spawn_in(window, async move |this, cx| {
 393            let result = task.await.notify_async_err(cx);
 394            drop(tx);
 395            if result.is_none() {
 396                this.update(cx, |this, cx| {
 397                    this.editor.update(cx, |editor, cx| {
 398                        // Remove mention
 399                        editor.edit([(start_anchor..end_anchor, "")], cx);
 400                    });
 401                    this.mention_set.mentions.remove(&crease_id);
 402                })
 403                .ok();
 404            }
 405        })
 406    }
 407
 408    fn confirm_mention_for_file(
 409        &mut self,
 410        abs_path: PathBuf,
 411        cx: &mut Context<Self>,
 412    ) -> Task<Result<Mention>> {
 413        let Some(project_path) = self
 414            .project
 415            .read(cx)
 416            .project_path_for_absolute_path(&abs_path, cx)
 417        else {
 418            return Task::ready(Err(anyhow!("project path not found")));
 419        };
 420        let extension = abs_path
 421            .extension()
 422            .and_then(OsStr::to_str)
 423            .unwrap_or_default();
 424
 425        if Img::extensions().contains(&extension) && !extension.contains("svg") {
 426            if !self.prompt_capabilities.borrow().image {
 427                return Task::ready(Err(anyhow!("This model does not support images yet")));
 428            }
 429            let task = self
 430                .project
 431                .update(cx, |project, cx| project.open_image(project_path, cx));
 432            return cx.spawn(async move |_, cx| {
 433                let image = task.await?;
 434                let image = image.update(cx, |image, _| image.image.clone())?;
 435                let format = image.format;
 436                let image = cx
 437                    .update(|cx| LanguageModelImage::from_image(image, cx))?
 438                    .await;
 439                if let Some(image) = image {
 440                    Ok(Mention::Image(MentionImage {
 441                        data: image.source,
 442                        format,
 443                    }))
 444                } else {
 445                    Err(anyhow!("Failed to convert image"))
 446                }
 447            });
 448        }
 449
 450        let buffer = self
 451            .project
 452            .update(cx, |project, cx| project.open_buffer(project_path, cx));
 453        cx.spawn(async move |_, cx| {
 454            let buffer = buffer.await?;
 455            let buffer_content = outline::get_buffer_content_or_outline(
 456                buffer.clone(),
 457                Some(&abs_path.to_string_lossy()),
 458                &cx,
 459            )
 460            .await?;
 461
 462            Ok(Mention::Text {
 463                content: buffer_content.text,
 464                tracked_buffers: vec![buffer],
 465            })
 466        })
 467    }
 468
 469    fn confirm_mention_for_fetch(
 470        &mut self,
 471        url: url::Url,
 472        cx: &mut Context<Self>,
 473    ) -> Task<Result<Mention>> {
 474        let http_client = match self
 475            .workspace
 476            .update(cx, |workspace, _| workspace.client().http_client())
 477        {
 478            Ok(http_client) => http_client,
 479            Err(e) => return Task::ready(Err(e)),
 480        };
 481        cx.background_executor().spawn(async move {
 482            let content = fetch_url_content(http_client, url.to_string()).await?;
 483            Ok(Mention::Text {
 484                content,
 485                tracked_buffers: Vec::new(),
 486            })
 487        })
 488    }
 489
 490    fn confirm_mention_for_symbol(
 491        &mut self,
 492        abs_path: PathBuf,
 493        line_range: RangeInclusive<u32>,
 494        cx: &mut Context<Self>,
 495    ) -> Task<Result<Mention>> {
 496        let Some(project_path) = self
 497            .project
 498            .read(cx)
 499            .project_path_for_absolute_path(&abs_path, cx)
 500        else {
 501            return Task::ready(Err(anyhow!("project path not found")));
 502        };
 503        let buffer = self
 504            .project
 505            .update(cx, |project, cx| project.open_buffer(project_path, cx));
 506        cx.spawn(async move |_, cx| {
 507            let buffer = buffer.await?;
 508            let mention = buffer.update(cx, |buffer, cx| {
 509                let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
 510                let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
 511                let content = buffer.text_for_range(start..end).collect();
 512                Mention::Text {
 513                    content,
 514                    tracked_buffers: vec![cx.entity()],
 515                }
 516            })?;
 517            anyhow::Ok(mention)
 518        })
 519    }
 520
 521    fn confirm_mention_for_rule(
 522        &mut self,
 523        id: PromptId,
 524        cx: &mut Context<Self>,
 525    ) -> Task<Result<Mention>> {
 526        let Some(prompt_store) = self.prompt_store.clone() else {
 527            return Task::ready(Err(anyhow!("missing prompt store")));
 528        };
 529        let prompt = prompt_store.read(cx).load(id, cx);
 530        cx.spawn(async move |_, _| {
 531            let prompt = prompt.await?;
 532            Ok(Mention::Text {
 533                content: prompt,
 534                tracked_buffers: Vec::new(),
 535            })
 536        })
 537    }
 538
 539    pub fn confirm_mention_for_selection(
 540        &mut self,
 541        source_range: Range<text::Anchor>,
 542        selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
 543        window: &mut Window,
 544        cx: &mut Context<Self>,
 545    ) {
 546        let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
 547        let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else {
 548            return;
 549        };
 550        let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else {
 551            return;
 552        };
 553
 554        let offset = start.to_offset(&snapshot);
 555
 556        for (buffer, selection_range, range_to_fold) in selections {
 557            let range = snapshot.anchor_after(offset + range_to_fold.start)
 558                ..snapshot.anchor_after(offset + range_to_fold.end);
 559
 560            let abs_path = buffer
 561                .read(cx)
 562                .project_path(cx)
 563                .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx));
 564            let snapshot = buffer.read(cx).snapshot();
 565
 566            let text = snapshot
 567                .text_for_range(selection_range.clone())
 568                .collect::<String>();
 569            let point_range = selection_range.to_point(&snapshot);
 570            let line_range = point_range.start.row..=point_range.end.row;
 571
 572            let uri = MentionUri::Selection {
 573                abs_path: abs_path.clone(),
 574                line_range: line_range.clone(),
 575            };
 576            let crease = crate::context_picker::crease_for_mention(
 577                selection_name(abs_path.as_deref(), &line_range).into(),
 578                uri.icon_path(cx),
 579                range,
 580                self.editor.downgrade(),
 581            );
 582
 583            let crease_id = self.editor.update(cx, |editor, cx| {
 584                let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
 585                editor.fold_creases(vec![crease], false, window, cx);
 586                crease_ids.first().copied().unwrap()
 587            });
 588
 589            self.mention_set.mentions.insert(
 590                crease_id,
 591                (
 592                    uri,
 593                    Task::ready(Ok(Mention::Text {
 594                        content: text,
 595                        tracked_buffers: vec![buffer],
 596                    }))
 597                    .shared(),
 598                ),
 599            );
 600        }
 601    }
 602
 603    fn confirm_mention_for_thread(
 604        &mut self,
 605        id: acp::SessionId,
 606        cx: &mut Context<Self>,
 607    ) -> Task<Result<Mention>> {
 608        let server = Rc::new(agent2::NativeAgentServer::new(
 609            self.project.read(cx).fs().clone(),
 610            self.history_store.clone(),
 611        ));
 612        let delegate = AgentServerDelegate::new(
 613            self.project.read(cx).agent_server_store().clone(),
 614            self.project.clone(),
 615            None,
 616            None,
 617        );
 618        let connection = server.connect(None, delegate, cx);
 619        cx.spawn(async move |_, cx| {
 620            let (agent, _) = connection.await?;
 621            let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
 622            let summary = agent
 623                .0
 624                .update(cx, |agent, cx| agent.thread_summary(id, cx))?
 625                .await?;
 626            anyhow::Ok(Mention::Text {
 627                content: summary.to_string(),
 628                tracked_buffers: Vec::new(),
 629            })
 630        })
 631    }
 632
 633    fn confirm_mention_for_text_thread(
 634        &mut self,
 635        path: PathBuf,
 636        cx: &mut Context<Self>,
 637    ) -> Task<Result<Mention>> {
 638        let context = self.history_store.update(cx, |text_thread_store, cx| {
 639            text_thread_store.load_text_thread(path.as_path().into(), cx)
 640        });
 641        cx.spawn(async move |_, cx| {
 642            let context = context.await?;
 643            let xml = context.update(cx, |context, cx| context.to_xml(cx))?;
 644            Ok(Mention::Text {
 645                content: xml,
 646                tracked_buffers: Vec::new(),
 647            })
 648        })
 649    }
 650
 651    fn validate_slash_commands(
 652        text: &str,
 653        available_commands: &[acp::AvailableCommand],
 654        agent_name: &str,
 655    ) -> Result<()> {
 656        if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
 657            if let Some(command_name) = parsed_command.command {
 658                // Check if this command is in the list of available commands from the server
 659                let is_supported = available_commands
 660                    .iter()
 661                    .any(|cmd| cmd.name == command_name);
 662
 663                if !is_supported {
 664                    return Err(anyhow!(
 665                        "The /{} command is not supported by {}.\n\nAvailable commands: {}",
 666                        command_name,
 667                        agent_name,
 668                        if available_commands.is_empty() {
 669                            "none".to_string()
 670                        } else {
 671                            available_commands
 672                                .iter()
 673                                .map(|cmd| format!("/{}", cmd.name))
 674                                .collect::<Vec<_>>()
 675                                .join(", ")
 676                        }
 677                    ));
 678                }
 679            }
 680        }
 681        Ok(())
 682    }
 683
 684    pub fn contents(
 685        &self,
 686        full_mention_content: bool,
 687        cx: &mut Context<Self>,
 688    ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
 689        // Check for unsupported slash commands before spawning async task
 690        let text = self.editor.read(cx).text(cx);
 691        let available_commands = self.available_commands.borrow().clone();
 692        if let Err(err) =
 693            Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
 694        {
 695            return Task::ready(Err(err));
 696        }
 697
 698        let contents = self.mention_set.contents(
 699            &self.prompt_capabilities.borrow(),
 700            full_mention_content,
 701            self.project.clone(),
 702            cx,
 703        );
 704        let editor = self.editor.clone();
 705
 706        cx.spawn(async move |_, cx| {
 707            let contents = contents.await?;
 708            let mut all_tracked_buffers = Vec::new();
 709
 710            let result = editor.update(cx, |editor, cx| {
 711                let mut ix = 0;
 712                let mut chunks: Vec<acp::ContentBlock> = Vec::new();
 713                let text = editor.text(cx);
 714                editor.display_map.update(cx, |map, cx| {
 715                    let snapshot = map.snapshot(cx);
 716                    for (crease_id, crease) in snapshot.crease_snapshot.creases() {
 717                        let Some((uri, mention)) = contents.get(&crease_id) else {
 718                            continue;
 719                        };
 720
 721                        let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
 722                        if crease_range.start > ix {
 723                            //todo(): Custom slash command ContentBlock?
 724                            // let chunk = if prevent_slash_commands
 725                            //     && ix == 0
 726                            //     && parse_slash_command(&text[ix..]).is_some()
 727                            // {
 728                            //     format!(" {}", &text[ix..crease_range.start]).into()
 729                            // } else {
 730                            //     text[ix..crease_range.start].into()
 731                            // };
 732                            let chunk = text[ix..crease_range.start].into();
 733                            chunks.push(chunk);
 734                        }
 735                        let chunk = match mention {
 736                            Mention::Text {
 737                                content,
 738                                tracked_buffers,
 739                            } => {
 740                                all_tracked_buffers.extend(tracked_buffers.iter().cloned());
 741                                acp::ContentBlock::Resource(acp::EmbeddedResource {
 742                                    annotations: None,
 743                                    resource: acp::EmbeddedResourceResource::TextResourceContents(
 744                                        acp::TextResourceContents {
 745                                            mime_type: None,
 746                                            text: content.clone(),
 747                                            uri: uri.to_uri().to_string(),
 748                                            meta: None,
 749                                        },
 750                                    ),
 751                                    meta: None,
 752                                })
 753                            }
 754                            Mention::Image(mention_image) => {
 755                                let uri = match uri {
 756                                    MentionUri::File { .. } => Some(uri.to_uri().to_string()),
 757                                    MentionUri::PastedImage => None,
 758                                    other => {
 759                                        debug_panic!(
 760                                            "unexpected mention uri for image: {:?}",
 761                                            other
 762                                        );
 763                                        None
 764                                    }
 765                                };
 766                                acp::ContentBlock::Image(acp::ImageContent {
 767                                    annotations: None,
 768                                    data: mention_image.data.to_string(),
 769                                    mime_type: mention_image.format.mime_type().into(),
 770                                    uri,
 771                                    meta: None,
 772                                })
 773                            }
 774                            Mention::UriOnly => {
 775                                acp::ContentBlock::ResourceLink(acp::ResourceLink {
 776                                    name: uri.name(),
 777                                    uri: uri.to_uri().to_string(),
 778                                    annotations: None,
 779                                    description: None,
 780                                    mime_type: None,
 781                                    size: None,
 782                                    title: None,
 783                                    meta: None,
 784                                })
 785                            }
 786                        };
 787                        chunks.push(chunk);
 788                        ix = crease_range.end;
 789                    }
 790
 791                    if ix < text.len() {
 792                        //todo(): Custom slash command ContentBlock?
 793                        // let last_chunk = if prevent_slash_commands
 794                        //     && ix == 0
 795                        //     && parse_slash_command(&text[ix..]).is_some()
 796                        // {
 797                        //     format!(" {}", text[ix..].trim_end())
 798                        // } else {
 799                        //     text[ix..].trim_end().to_owned()
 800                        // };
 801                        let last_chunk = text[ix..].trim_end().to_owned();
 802                        if !last_chunk.is_empty() {
 803                            chunks.push(last_chunk.into());
 804                        }
 805                    }
 806                });
 807                Ok((chunks, all_tracked_buffers))
 808            })?;
 809            result
 810        })
 811    }
 812
 813    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 814        self.editor.update(cx, |editor, cx| {
 815            editor.clear(window, cx);
 816            editor.remove_creases(
 817                self.mention_set
 818                    .mentions
 819                    .drain()
 820                    .map(|(crease_id, _)| crease_id),
 821                cx,
 822            )
 823        });
 824    }
 825
 826    pub fn send(&mut self, cx: &mut Context<Self>) {
 827        if self.is_empty(cx) {
 828            return;
 829        }
 830        self.editor.update(cx, |editor, cx| {
 831            editor.clear_inlay_hints(cx);
 832        });
 833        cx.emit(MessageEditorEvent::Send)
 834    }
 835
 836    fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
 837        self.send(cx);
 838    }
 839
 840    fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
 841        cx.emit(MessageEditorEvent::Cancel)
 842    }
 843
 844    fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
 845        if !self.prompt_capabilities.borrow().image {
 846            return;
 847        }
 848
 849        let images = cx
 850            .read_from_clipboard()
 851            .map(|item| {
 852                item.into_entries()
 853                    .filter_map(|entry| {
 854                        if let ClipboardEntry::Image(image) = entry {
 855                            Some(image)
 856                        } else {
 857                            None
 858                        }
 859                    })
 860                    .collect::<Vec<_>>()
 861            })
 862            .unwrap_or_default();
 863
 864        if images.is_empty() {
 865            return;
 866        }
 867        cx.stop_propagation();
 868
 869        let replacement_text = MentionUri::PastedImage.as_link().to_string();
 870        for image in images {
 871            let (excerpt_id, text_anchor, multibuffer_anchor) =
 872                self.editor.update(cx, |message_editor, cx| {
 873                    let snapshot = message_editor.snapshot(window, cx);
 874                    let (excerpt_id, _, buffer_snapshot) =
 875                        snapshot.buffer_snapshot().as_singleton().unwrap();
 876
 877                    let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
 878                    let multibuffer_anchor = snapshot
 879                        .buffer_snapshot()
 880                        .anchor_in_excerpt(*excerpt_id, text_anchor);
 881                    message_editor.edit(
 882                        [(
 883                            multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
 884                            format!("{replacement_text} "),
 885                        )],
 886                        cx,
 887                    );
 888                    (*excerpt_id, text_anchor, multibuffer_anchor)
 889                });
 890
 891            let content_len = replacement_text.len();
 892            let Some(start_anchor) = multibuffer_anchor else {
 893                continue;
 894            };
 895            let end_anchor = self.editor.update(cx, |editor, cx| {
 896                let snapshot = editor.buffer().read(cx).snapshot(cx);
 897                snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
 898            });
 899            let image = Arc::new(image);
 900            let Some((crease_id, tx)) = insert_crease_for_mention(
 901                excerpt_id,
 902                text_anchor,
 903                content_len,
 904                MentionUri::PastedImage.name().into(),
 905                IconName::Image.path().into(),
 906                Some(Task::ready(Ok(image.clone())).shared()),
 907                self.editor.clone(),
 908                window,
 909                cx,
 910            ) else {
 911                continue;
 912            };
 913            let task = cx
 914                .spawn_in(window, {
 915                    async move |_, cx| {
 916                        let format = image.format;
 917                        let image = cx
 918                            .update(|_, cx| LanguageModelImage::from_image(image, cx))
 919                            .map_err(|e| e.to_string())?
 920                            .await;
 921                        drop(tx);
 922                        if let Some(image) = image {
 923                            Ok(Mention::Image(MentionImage {
 924                                data: image.source,
 925                                format,
 926                            }))
 927                        } else {
 928                            Err("Failed to convert image".into())
 929                        }
 930                    }
 931                })
 932                .shared();
 933
 934            self.mention_set
 935                .mentions
 936                .insert(crease_id, (MentionUri::PastedImage, task.clone()));
 937
 938            cx.spawn_in(window, async move |this, cx| {
 939                if task.await.notify_async_err(cx).is_none() {
 940                    this.update(cx, |this, cx| {
 941                        this.editor.update(cx, |editor, cx| {
 942                            editor.edit([(start_anchor..end_anchor, "")], cx);
 943                        });
 944                        this.mention_set.mentions.remove(&crease_id);
 945                    })
 946                    .ok();
 947                }
 948            })
 949            .detach();
 950        }
 951    }
 952
 953    pub fn insert_dragged_files(
 954        &mut self,
 955        paths: Vec<project::ProjectPath>,
 956        added_worktrees: Vec<Entity<Worktree>>,
 957        window: &mut Window,
 958        cx: &mut Context<Self>,
 959    ) {
 960        let path_style = self.project.read(cx).path_style(cx);
 961        let buffer = self.editor.read(cx).buffer().clone();
 962        let Some(buffer) = buffer.read(cx).as_singleton() else {
 963            return;
 964        };
 965        let mut tasks = Vec::new();
 966        for path in paths {
 967            let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
 968                continue;
 969            };
 970            let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else {
 971                continue;
 972            };
 973            let abs_path = worktree.read(cx).absolutize(&path.path);
 974            let (file_name, _) =
 975                crate::context_picker::file_context_picker::extract_file_name_and_directory(
 976                    &path.path,
 977                    worktree.read(cx).root_name(),
 978                    path_style,
 979                );
 980
 981            let uri = if entry.is_dir() {
 982                MentionUri::Directory { abs_path }
 983            } else {
 984                MentionUri::File { abs_path }
 985            };
 986
 987            let new_text = format!("{} ", uri.as_link());
 988            let content_len = new_text.len() - 1;
 989
 990            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
 991
 992            self.editor.update(cx, |message_editor, cx| {
 993                message_editor.edit(
 994                    [(
 995                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
 996                        new_text,
 997                    )],
 998                    cx,
 999                );
1000            });
1001            tasks.push(self.confirm_mention_completion(
1002                file_name,
1003                anchor,
1004                content_len,
1005                uri,
1006                window,
1007                cx,
1008            ));
1009        }
1010        cx.spawn(async move |_, _| {
1011            join_all(tasks).await;
1012            drop(added_worktrees);
1013        })
1014        .detach();
1015    }
1016
1017    pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1018        let editor = self.editor.read(cx);
1019        let editor_buffer = editor.buffer().read(cx);
1020        let Some(buffer) = editor_buffer.as_singleton() else {
1021            return;
1022        };
1023        let cursor_anchor = editor.selections.newest_anchor().head();
1024        let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1025        let anchor = buffer.update(cx, |buffer, _cx| {
1026            buffer.anchor_before(cursor_offset.min(buffer.len()))
1027        });
1028        let Some(workspace) = self.workspace.upgrade() else {
1029            return;
1030        };
1031        let Some(completion) = ContextPickerCompletionProvider::completion_for_action(
1032            ContextPickerAction::AddSelections,
1033            anchor..anchor,
1034            cx.weak_entity(),
1035            &workspace,
1036            cx,
1037        ) else {
1038            return;
1039        };
1040
1041        self.editor.update(cx, |message_editor, cx| {
1042            message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1043        });
1044        if let Some(confirm) = completion.confirm {
1045            confirm(CompletionIntent::Complete, window, cx);
1046        }
1047    }
1048
1049    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1050        self.editor.update(cx, |message_editor, cx| {
1051            message_editor.set_read_only(read_only);
1052            cx.notify()
1053        })
1054    }
1055
1056    pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1057        self.editor.update(cx, |editor, cx| {
1058            editor.set_mode(mode);
1059            cx.notify()
1060        });
1061    }
1062
1063    pub fn set_message(
1064        &mut self,
1065        message: Vec<acp::ContentBlock>,
1066        window: &mut Window,
1067        cx: &mut Context<Self>,
1068    ) {
1069        self.clear(window, cx);
1070
1071        let mut text = String::new();
1072        let mut mentions = Vec::new();
1073
1074        for chunk in message {
1075            match chunk {
1076                acp::ContentBlock::Text(text_content) => {
1077                    text.push_str(&text_content.text);
1078                }
1079                acp::ContentBlock::Resource(acp::EmbeddedResource {
1080                    resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1081                    ..
1082                }) => {
1083                    let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() else {
1084                        continue;
1085                    };
1086                    let start = text.len();
1087                    write!(&mut text, "{}", mention_uri.as_link()).ok();
1088                    let end = text.len();
1089                    mentions.push((
1090                        start..end,
1091                        mention_uri,
1092                        Mention::Text {
1093                            content: resource.text,
1094                            tracked_buffers: Vec::new(),
1095                        },
1096                    ));
1097                }
1098                acp::ContentBlock::ResourceLink(resource) => {
1099                    if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() {
1100                        let start = text.len();
1101                        write!(&mut text, "{}", mention_uri.as_link()).ok();
1102                        let end = text.len();
1103                        mentions.push((start..end, mention_uri, Mention::UriOnly));
1104                    }
1105                }
1106                acp::ContentBlock::Image(acp::ImageContent {
1107                    uri,
1108                    data,
1109                    mime_type,
1110                    annotations: _,
1111                    meta: _,
1112                }) => {
1113                    let mention_uri = if let Some(uri) = uri {
1114                        MentionUri::parse(&uri)
1115                    } else {
1116                        Ok(MentionUri::PastedImage)
1117                    };
1118                    let Some(mention_uri) = mention_uri.log_err() else {
1119                        continue;
1120                    };
1121                    let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1122                        log::error!("failed to parse MIME type for image: {mime_type:?}");
1123                        continue;
1124                    };
1125                    let start = text.len();
1126                    write!(&mut text, "{}", mention_uri.as_link()).ok();
1127                    let end = text.len();
1128                    mentions.push((
1129                        start..end,
1130                        mention_uri,
1131                        Mention::Image(MentionImage {
1132                            data: data.into(),
1133                            format,
1134                        }),
1135                    ));
1136                }
1137                acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {}
1138            }
1139        }
1140
1141        let snapshot = self.editor.update(cx, |editor, cx| {
1142            editor.set_text(text, window, cx);
1143            editor.buffer().read(cx).snapshot(cx)
1144        });
1145
1146        for (range, mention_uri, mention) in mentions {
1147            let anchor = snapshot.anchor_before(range.start);
1148            let Some((crease_id, tx)) = insert_crease_for_mention(
1149                anchor.excerpt_id,
1150                anchor.text_anchor,
1151                range.end - range.start,
1152                mention_uri.name().into(),
1153                mention_uri.icon_path(cx),
1154                None,
1155                self.editor.clone(),
1156                window,
1157                cx,
1158            ) else {
1159                continue;
1160            };
1161            drop(tx);
1162
1163            self.mention_set.mentions.insert(
1164                crease_id,
1165                (mention_uri.clone(), Task::ready(Ok(mention)).shared()),
1166            );
1167        }
1168        cx.notify();
1169    }
1170
1171    pub fn text(&self, cx: &App) -> String {
1172        self.editor.read(cx).text(cx)
1173    }
1174
1175    #[cfg(test)]
1176    pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1177        self.editor.update(cx, |editor, cx| {
1178            editor.set_text(text, window, cx);
1179        });
1180    }
1181}
1182
1183fn full_mention_for_directory(
1184    project: &Entity<Project>,
1185    abs_path: &Path,
1186    cx: &mut App,
1187) -> Task<Result<Mention>> {
1188    fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1189        let mut files = Vec::new();
1190
1191        for entry in worktree.child_entries(path) {
1192            if entry.is_dir() {
1193                files.extend(collect_files_in_path(worktree, &entry.path));
1194            } else if entry.is_file() {
1195                files.push((
1196                    entry.path.clone(),
1197                    worktree
1198                        .full_path(&entry.path)
1199                        .to_string_lossy()
1200                        .to_string(),
1201                ));
1202            }
1203        }
1204
1205        files
1206    }
1207
1208    let Some(project_path) = project
1209        .read(cx)
1210        .project_path_for_absolute_path(&abs_path, cx)
1211    else {
1212        return Task::ready(Err(anyhow!("project path not found")));
1213    };
1214    let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1215        return Task::ready(Err(anyhow!("project entry not found")));
1216    };
1217    let directory_path = entry.path.clone();
1218    let worktree_id = project_path.worktree_id;
1219    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1220        return Task::ready(Err(anyhow!("worktree not found")));
1221    };
1222    let project = project.clone();
1223    cx.spawn(async move |cx| {
1224        let file_paths = worktree.read_with(cx, |worktree, _cx| {
1225            collect_files_in_path(worktree, &directory_path)
1226        })?;
1227        let descendants_future = cx.update(|cx| {
1228            join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
1229                let rel_path = worktree_path
1230                    .strip_prefix(&directory_path)
1231                    .log_err()
1232                    .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1233
1234                let open_task = project.update(cx, |project, cx| {
1235                    project.buffer_store().update(cx, |buffer_store, cx| {
1236                        let project_path = ProjectPath {
1237                            worktree_id,
1238                            path: worktree_path,
1239                        };
1240                        buffer_store.open_buffer(project_path, cx)
1241                    })
1242                });
1243
1244                cx.spawn(async move |cx| {
1245                    let buffer = open_task.await.log_err()?;
1246                    let buffer_content = outline::get_buffer_content_or_outline(
1247                        buffer.clone(),
1248                        Some(&full_path),
1249                        &cx,
1250                    )
1251                    .await
1252                    .ok()?;
1253
1254                    Some((rel_path, full_path, buffer_content.text, buffer))
1255                })
1256            }))
1257        })?;
1258
1259        let contents = cx
1260            .background_spawn(async move {
1261                let (contents, tracked_buffers) = descendants_future
1262                    .await
1263                    .into_iter()
1264                    .flatten()
1265                    .map(|(rel_path, full_path, rope, buffer)| {
1266                        ((rel_path, full_path, rope), buffer)
1267                    })
1268                    .unzip();
1269                Mention::Text {
1270                    content: render_directory_contents(contents),
1271                    tracked_buffers,
1272                }
1273            })
1274            .await;
1275        anyhow::Ok(contents)
1276    })
1277}
1278
1279fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1280    let mut output = String::new();
1281    for (_relative_path, full_path, content) in entries {
1282        let fence = codeblock_fence_for_path(Some(&full_path), None);
1283        write!(output, "\n{fence}\n{content}\n```").unwrap();
1284    }
1285    output
1286}
1287
1288impl Focusable for MessageEditor {
1289    fn focus_handle(&self, cx: &App) -> FocusHandle {
1290        self.editor.focus_handle(cx)
1291    }
1292}
1293
1294impl Render for MessageEditor {
1295    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1296        div()
1297            .key_context("MessageEditor")
1298            .on_action(cx.listener(Self::chat))
1299            .on_action(cx.listener(Self::cancel))
1300            .capture_action(cx.listener(Self::paste))
1301            .flex_1()
1302            .child({
1303                let settings = ThemeSettings::get_global(cx);
1304
1305                let text_style = TextStyle {
1306                    color: cx.theme().colors().text,
1307                    font_family: settings.buffer_font.family.clone(),
1308                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
1309                    font_features: settings.buffer_font.features.clone(),
1310                    font_size: settings.agent_buffer_font_size(cx).into(),
1311                    line_height: relative(settings.buffer_line_height.value()),
1312                    ..Default::default()
1313                };
1314
1315                EditorElement::new(
1316                    &self.editor,
1317                    EditorStyle {
1318                        background: cx.theme().colors().editor_background,
1319                        local_player: cx.theme().players().local(),
1320                        text: text_style,
1321                        syntax: cx.theme().syntax().clone(),
1322                        inlay_hints_style: editor::make_inlay_hints_style(cx),
1323                        ..Default::default()
1324                    },
1325                )
1326            })
1327    }
1328}
1329
1330pub(crate) fn insert_crease_for_mention(
1331    excerpt_id: ExcerptId,
1332    anchor: text::Anchor,
1333    content_len: usize,
1334    crease_label: SharedString,
1335    crease_icon: SharedString,
1336    // abs_path: Option<Arc<Path>>,
1337    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1338    editor: Entity<Editor>,
1339    window: &mut Window,
1340    cx: &mut App,
1341) -> Option<(CreaseId, postage::barrier::Sender)> {
1342    let (tx, rx) = postage::barrier::channel();
1343
1344    let crease_id = editor.update(cx, |editor, cx| {
1345        let snapshot = editor.buffer().read(cx).snapshot(cx);
1346
1347        let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
1348
1349        let start = start.bias_right(&snapshot);
1350        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
1351
1352        let placeholder = FoldPlaceholder {
1353            render: render_mention_fold_button(
1354                crease_label,
1355                crease_icon,
1356                start..end,
1357                rx,
1358                image,
1359                cx.weak_entity(),
1360                cx,
1361            ),
1362            merge_adjacent: false,
1363            ..Default::default()
1364        };
1365
1366        let crease = Crease::Inline {
1367            range: start..end,
1368            placeholder,
1369            render_toggle: None,
1370            render_trailer: None,
1371            metadata: None,
1372        };
1373
1374        let ids = editor.insert_creases(vec![crease.clone()], cx);
1375        editor.fold_creases(vec![crease], false, window, cx);
1376
1377        Some(ids[0])
1378    })?;
1379
1380    Some((crease_id, tx))
1381}
1382
1383fn render_mention_fold_button(
1384    label: SharedString,
1385    icon: SharedString,
1386    range: Range<Anchor>,
1387    mut loading_finished: postage::barrier::Receiver,
1388    image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1389    editor: WeakEntity<Editor>,
1390    cx: &mut App,
1391) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1392    let loading = cx.new(|cx| {
1393        let loading = cx.spawn(async move |this, cx| {
1394            loading_finished.recv().await;
1395            this.update(cx, |this: &mut LoadingContext, cx| {
1396                this.loading = None;
1397                cx.notify();
1398            })
1399            .ok();
1400        });
1401        LoadingContext {
1402            id: cx.entity_id(),
1403            label,
1404            icon,
1405            range,
1406            editor,
1407            loading: Some(loading),
1408            image: image_task.clone(),
1409        }
1410    });
1411    Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1412}
1413
1414struct LoadingContext {
1415    id: EntityId,
1416    label: SharedString,
1417    icon: SharedString,
1418    range: Range<Anchor>,
1419    editor: WeakEntity<Editor>,
1420    loading: Option<Task<()>>,
1421    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1422}
1423
1424impl Render for LoadingContext {
1425    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1426        let is_in_text_selection = self
1427            .editor
1428            .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1429            .unwrap_or_default();
1430        ButtonLike::new(("loading-context", self.id))
1431            .style(ButtonStyle::Filled)
1432            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1433            .toggle_state(is_in_text_selection)
1434            .when_some(self.image.clone(), |el, image_task| {
1435                el.hoverable_tooltip(move |_, cx| {
1436                    let image = image_task.peek().cloned().transpose().ok().flatten();
1437                    let image_task = image_task.clone();
1438                    cx.new::<ImageHover>(|cx| ImageHover {
1439                        image,
1440                        _task: cx.spawn(async move |this, cx| {
1441                            if let Ok(image) = image_task.clone().await {
1442                                this.update(cx, |this, cx| {
1443                                    if this.image.replace(image).is_none() {
1444                                        cx.notify();
1445                                    }
1446                                })
1447                                .ok();
1448                            }
1449                        }),
1450                    })
1451                    .into()
1452                })
1453            })
1454            .child(
1455                h_flex()
1456                    .gap_1()
1457                    .child(
1458                        Icon::from_path(self.icon.clone())
1459                            .size(IconSize::XSmall)
1460                            .color(Color::Muted),
1461                    )
1462                    .child(
1463                        Label::new(self.label.clone())
1464                            .size(LabelSize::Small)
1465                            .buffer_font(cx)
1466                            .single_line(),
1467                    )
1468                    .map(|el| {
1469                        if self.loading.is_some() {
1470                            el.with_animation(
1471                                "loading-context-crease",
1472                                Animation::new(Duration::from_secs(2))
1473                                    .repeat()
1474                                    .with_easing(pulsating_between(0.4, 0.8)),
1475                                |label, delta| label.opacity(delta),
1476                            )
1477                            .into_any()
1478                        } else {
1479                            el.into_any()
1480                        }
1481                    }),
1482            )
1483    }
1484}
1485
1486struct ImageHover {
1487    image: Option<Arc<Image>>,
1488    _task: Task<()>,
1489}
1490
1491impl Render for ImageHover {
1492    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1493        if let Some(image) = self.image.clone() {
1494            gpui::img(image).max_w_96().max_h_96().into_any_element()
1495        } else {
1496            gpui::Empty.into_any_element()
1497        }
1498    }
1499}
1500
1501#[derive(Debug, Clone, Eq, PartialEq)]
1502pub enum Mention {
1503    Text {
1504        content: String,
1505        tracked_buffers: Vec<Entity<Buffer>>,
1506    },
1507    Image(MentionImage),
1508    UriOnly,
1509}
1510
1511#[derive(Clone, Debug, Eq, PartialEq)]
1512pub struct MentionImage {
1513    pub data: SharedString,
1514    pub format: ImageFormat,
1515}
1516
1517#[derive(Default)]
1518pub struct MentionSet {
1519    mentions: HashMap<CreaseId, (MentionUri, Shared<Task<Result<Mention, String>>>)>,
1520}
1521
1522impl MentionSet {
1523    fn contents(
1524        &self,
1525        prompt_capabilities: &acp::PromptCapabilities,
1526        full_mention_content: bool,
1527        project: Entity<Project>,
1528        cx: &mut App,
1529    ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
1530        if !prompt_capabilities.embedded_context {
1531            let mentions = self
1532                .mentions
1533                .iter()
1534                .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly)))
1535                .collect();
1536
1537            return Task::ready(Ok(mentions));
1538        }
1539
1540        let mentions = self.mentions.clone();
1541        cx.spawn(async move |cx| {
1542            let mut contents = HashMap::default();
1543            for (crease_id, (mention_uri, task)) in mentions {
1544                let content = if full_mention_content
1545                    && let MentionUri::Directory { abs_path } = &mention_uri
1546                {
1547                    cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))?
1548                        .await?
1549                } else {
1550                    task.await.map_err(|e| anyhow!("{e}"))?
1551                };
1552
1553                contents.insert(crease_id, (mention_uri, content));
1554            }
1555            Ok(contents)
1556        })
1557    }
1558
1559    fn remove_invalid(&mut self, snapshot: EditorSnapshot) {
1560        for (crease_id, crease) in snapshot.crease_snapshot.creases() {
1561            if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) {
1562                self.mentions.remove(&crease_id);
1563            }
1564        }
1565    }
1566}
1567
1568pub struct MessageEditorAddon {}
1569
1570impl MessageEditorAddon {
1571    pub fn new() -> Self {
1572        Self {}
1573    }
1574}
1575
1576impl Addon for MessageEditorAddon {
1577    fn to_any(&self) -> &dyn std::any::Any {
1578        self
1579    }
1580
1581    fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1582        Some(self)
1583    }
1584
1585    fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1586        let settings = agent_settings::AgentSettings::get_global(cx);
1587        if settings.use_modifier_to_send {
1588            key_context.add("use_modifier_to_send");
1589        }
1590    }
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595    use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1596
1597    use acp_thread::MentionUri;
1598    use agent_client_protocol as acp;
1599    use agent2::HistoryStore;
1600    use assistant_context::ContextStore;
1601    use assistant_tool::outline;
1602    use editor::{AnchorRangeExt as _, Editor, EditorMode};
1603    use fs::FakeFs;
1604    use futures::StreamExt as _;
1605    use gpui::{
1606        AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1607    };
1608    use lsp::{CompletionContext, CompletionTriggerKind};
1609    use project::{CompletionIntent, Project, ProjectPath};
1610    use serde_json::json;
1611    use text::Point;
1612    use ui::{App, Context, IntoElement, Render, SharedString, Window};
1613    use util::{path, paths::PathStyle, rel_path::rel_path};
1614    use workspace::{AppState, Item, Workspace};
1615
1616    use crate::acp::{
1617        message_editor::{Mention, MessageEditor},
1618        thread_view::tests::init_test,
1619    };
1620
1621    #[gpui::test]
1622    async fn test_at_mention_removal(cx: &mut TestAppContext) {
1623        init_test(cx);
1624
1625        let fs = FakeFs::new(cx.executor());
1626        fs.insert_tree("/project", json!({"file": ""})).await;
1627        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1628
1629        let (workspace, cx) =
1630            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1631
1632        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
1633        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
1634
1635        let message_editor = cx.update(|window, cx| {
1636            cx.new(|cx| {
1637                MessageEditor::new(
1638                    workspace.downgrade(),
1639                    project.clone(),
1640                    history_store.clone(),
1641                    None,
1642                    Default::default(),
1643                    Default::default(),
1644                    "Test Agent".into(),
1645                    "Test",
1646                    EditorMode::AutoHeight {
1647                        min_lines: 1,
1648                        max_lines: None,
1649                    },
1650                    window,
1651                    cx,
1652                )
1653            })
1654        });
1655        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1656
1657        cx.run_until_parked();
1658
1659        let excerpt_id = editor.update(cx, |editor, cx| {
1660            editor
1661                .buffer()
1662                .read(cx)
1663                .excerpt_ids()
1664                .into_iter()
1665                .next()
1666                .unwrap()
1667        });
1668        let completions = editor.update_in(cx, |editor, window, cx| {
1669            editor.set_text("Hello @file ", window, cx);
1670            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1671            let completion_provider = editor.completion_provider().unwrap();
1672            completion_provider.completions(
1673                excerpt_id,
1674                &buffer,
1675                text::Anchor::MAX,
1676                CompletionContext {
1677                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1678                    trigger_character: Some("@".into()),
1679                },
1680                window,
1681                cx,
1682            )
1683        });
1684        let [_, completion]: [_; 2] = completions
1685            .await
1686            .unwrap()
1687            .into_iter()
1688            .flat_map(|response| response.completions)
1689            .collect::<Vec<_>>()
1690            .try_into()
1691            .unwrap();
1692
1693        editor.update_in(cx, |editor, window, cx| {
1694            let snapshot = editor.buffer().read(cx).snapshot(cx);
1695            let start = snapshot
1696                .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
1697                .unwrap();
1698            let end = snapshot
1699                .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
1700                .unwrap();
1701            editor.edit([(start..end, completion.new_text)], cx);
1702            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1703        });
1704
1705        cx.run_until_parked();
1706
1707        // Backspace over the inserted crease (and the following space).
1708        editor.update_in(cx, |editor, window, cx| {
1709            editor.backspace(&Default::default(), window, cx);
1710            editor.backspace(&Default::default(), window, cx);
1711        });
1712
1713        let (content, _) = message_editor
1714            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1715            .await
1716            .unwrap();
1717
1718        // We don't send a resource link for the deleted crease.
1719        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1720    }
1721
1722    #[gpui::test]
1723    async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1724        init_test(cx);
1725        let fs = FakeFs::new(cx.executor());
1726        fs.insert_tree(
1727            "/test",
1728            json!({
1729                ".zed": {
1730                    "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1731                },
1732                "src": {
1733                    "main.rs": "fn main() {}",
1734                },
1735            }),
1736        )
1737        .await;
1738
1739        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1740        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
1741        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
1742        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1743        // Start with no available commands - simulating Claude which doesn't support slash commands
1744        let available_commands = Rc::new(RefCell::new(vec![]));
1745
1746        let (workspace, cx) =
1747            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1748        let workspace_handle = workspace.downgrade();
1749        let message_editor = workspace.update_in(cx, |_, window, cx| {
1750            cx.new(|cx| {
1751                MessageEditor::new(
1752                    workspace_handle.clone(),
1753                    project.clone(),
1754                    history_store.clone(),
1755                    None,
1756                    prompt_capabilities.clone(),
1757                    available_commands.clone(),
1758                    "Claude Code".into(),
1759                    "Test",
1760                    EditorMode::AutoHeight {
1761                        min_lines: 1,
1762                        max_lines: None,
1763                    },
1764                    window,
1765                    cx,
1766                )
1767            })
1768        });
1769        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1770
1771        // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1772        editor.update_in(cx, |editor, window, cx| {
1773            editor.set_text("/file test.txt", window, cx);
1774        });
1775
1776        let contents_result = message_editor
1777            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1778            .await;
1779
1780        // Should fail because available_commands is empty (no commands supported)
1781        assert!(contents_result.is_err());
1782        let error_message = contents_result.unwrap_err().to_string();
1783        assert!(error_message.contains("not supported by Claude Code"));
1784        assert!(error_message.contains("Available commands: none"));
1785
1786        // Now simulate Claude providing its list of available commands (which doesn't include file)
1787        available_commands.replace(vec![acp::AvailableCommand {
1788            name: "help".to_string(),
1789            description: "Get help".to_string(),
1790            input: None,
1791            meta: None,
1792        }]);
1793
1794        // Test that unsupported slash commands trigger an error when we have a list of available commands
1795        editor.update_in(cx, |editor, window, cx| {
1796            editor.set_text("/file test.txt", window, cx);
1797        });
1798
1799        let contents_result = message_editor
1800            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1801            .await;
1802
1803        assert!(contents_result.is_err());
1804        let error_message = contents_result.unwrap_err().to_string();
1805        assert!(error_message.contains("not supported by Claude Code"));
1806        assert!(error_message.contains("/file"));
1807        assert!(error_message.contains("Available commands: /help"));
1808
1809        // Test that supported commands work fine
1810        editor.update_in(cx, |editor, window, cx| {
1811            editor.set_text("/help", window, cx);
1812        });
1813
1814        let contents_result = message_editor
1815            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1816            .await;
1817
1818        // Should succeed because /help is in available_commands
1819        assert!(contents_result.is_ok());
1820
1821        // Test that regular text works fine
1822        editor.update_in(cx, |editor, window, cx| {
1823            editor.set_text("Hello Claude!", window, cx);
1824        });
1825
1826        let (content, _) = message_editor
1827            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1828            .await
1829            .unwrap();
1830
1831        assert_eq!(content.len(), 1);
1832        if let acp::ContentBlock::Text(text) = &content[0] {
1833            assert_eq!(text.text, "Hello Claude!");
1834        } else {
1835            panic!("Expected ContentBlock::Text");
1836        }
1837
1838        // Test that @ mentions still work
1839        editor.update_in(cx, |editor, window, cx| {
1840            editor.set_text("Check this @", window, cx);
1841        });
1842
1843        // The @ mention functionality should not be affected
1844        let (content, _) = message_editor
1845            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1846            .await
1847            .unwrap();
1848
1849        assert_eq!(content.len(), 1);
1850        if let acp::ContentBlock::Text(text) = &content[0] {
1851            assert_eq!(text.text, "Check this @");
1852        } else {
1853            panic!("Expected ContentBlock::Text");
1854        }
1855    }
1856
1857    struct MessageEditorItem(Entity<MessageEditor>);
1858
1859    impl Item for MessageEditorItem {
1860        type Event = ();
1861
1862        fn include_in_nav_history() -> bool {
1863            false
1864        }
1865
1866        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1867            "Test".into()
1868        }
1869    }
1870
1871    impl EventEmitter<()> for MessageEditorItem {}
1872
1873    impl Focusable for MessageEditorItem {
1874        fn focus_handle(&self, cx: &App) -> FocusHandle {
1875            self.0.read(cx).focus_handle(cx)
1876        }
1877    }
1878
1879    impl Render for MessageEditorItem {
1880        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1881            self.0.clone().into_any_element()
1882        }
1883    }
1884
1885    #[gpui::test]
1886    async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1887        init_test(cx);
1888
1889        let app_state = cx.update(AppState::test);
1890
1891        cx.update(|cx| {
1892            language::init(cx);
1893            editor::init(cx);
1894            workspace::init(app_state.clone(), cx);
1895            Project::init_settings(cx);
1896        });
1897
1898        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1899        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1900        let workspace = window.root(cx).unwrap();
1901
1902        let mut cx = VisualTestContext::from_window(*window, cx);
1903
1904        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
1905        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
1906        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1907        let available_commands = Rc::new(RefCell::new(vec![
1908            acp::AvailableCommand {
1909                name: "quick-math".to_string(),
1910                description: "2 + 2 = 4 - 1 = 3".to_string(),
1911                input: None,
1912                meta: None,
1913            },
1914            acp::AvailableCommand {
1915                name: "say-hello".to_string(),
1916                description: "Say hello to whoever you want".to_string(),
1917                input: Some(acp::AvailableCommandInput::Unstructured {
1918                    hint: "<name>".to_string(),
1919                }),
1920                meta: None,
1921            },
1922        ]));
1923
1924        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1925            let workspace_handle = cx.weak_entity();
1926            let message_editor = cx.new(|cx| {
1927                MessageEditor::new(
1928                    workspace_handle,
1929                    project.clone(),
1930                    history_store.clone(),
1931                    None,
1932                    prompt_capabilities.clone(),
1933                    available_commands.clone(),
1934                    "Test Agent".into(),
1935                    "Test",
1936                    EditorMode::AutoHeight {
1937                        max_lines: None,
1938                        min_lines: 1,
1939                    },
1940                    window,
1941                    cx,
1942                )
1943            });
1944            workspace.active_pane().update(cx, |pane, cx| {
1945                pane.add_item(
1946                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1947                    true,
1948                    true,
1949                    None,
1950                    window,
1951                    cx,
1952                );
1953            });
1954            message_editor.read(cx).focus_handle(cx).focus(window);
1955            message_editor.read(cx).editor().clone()
1956        });
1957
1958        cx.simulate_input("/");
1959
1960        editor.update_in(&mut cx, |editor, window, cx| {
1961            assert_eq!(editor.text(cx), "/");
1962            assert!(editor.has_visible_completions_menu());
1963
1964            assert_eq!(
1965                current_completion_labels_with_documentation(editor),
1966                &[
1967                    ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1968                    ("say-hello".into(), "Say hello to whoever you want".into())
1969                ]
1970            );
1971            editor.set_text("", window, cx);
1972        });
1973
1974        cx.simulate_input("/qui");
1975
1976        editor.update_in(&mut cx, |editor, window, cx| {
1977            assert_eq!(editor.text(cx), "/qui");
1978            assert!(editor.has_visible_completions_menu());
1979
1980            assert_eq!(
1981                current_completion_labels_with_documentation(editor),
1982                &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1983            );
1984            editor.set_text("", window, cx);
1985        });
1986
1987        editor.update_in(&mut cx, |editor, window, cx| {
1988            assert!(editor.has_visible_completions_menu());
1989            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1990        });
1991
1992        cx.run_until_parked();
1993
1994        editor.update_in(&mut cx, |editor, window, cx| {
1995            assert_eq!(editor.display_text(cx), "/quick-math ");
1996            assert!(!editor.has_visible_completions_menu());
1997            editor.set_text("", window, cx);
1998        });
1999
2000        cx.simulate_input("/say");
2001
2002        editor.update_in(&mut cx, |editor, _window, cx| {
2003            assert_eq!(editor.display_text(cx), "/say");
2004            assert!(editor.has_visible_completions_menu());
2005
2006            assert_eq!(
2007                current_completion_labels_with_documentation(editor),
2008                &[("say-hello".into(), "Say hello to whoever you want".into())]
2009            );
2010        });
2011
2012        editor.update_in(&mut cx, |editor, window, cx| {
2013            assert!(editor.has_visible_completions_menu());
2014            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2015        });
2016
2017        cx.run_until_parked();
2018
2019        editor.update_in(&mut cx, |editor, _window, cx| {
2020            assert_eq!(editor.text(cx), "/say-hello ");
2021            assert_eq!(editor.display_text(cx), "/say-hello <name>");
2022            assert!(!editor.has_visible_completions_menu());
2023        });
2024
2025        cx.simulate_input("GPT5");
2026
2027        cx.run_until_parked();
2028
2029        editor.update_in(&mut cx, |editor, window, cx| {
2030            assert_eq!(editor.text(cx), "/say-hello GPT5");
2031            assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2032            assert!(!editor.has_visible_completions_menu());
2033
2034            // Delete argument
2035            for _ in 0..5 {
2036                editor.backspace(&editor::actions::Backspace, window, cx);
2037            }
2038        });
2039
2040        cx.run_until_parked();
2041
2042        editor.update_in(&mut cx, |editor, window, cx| {
2043            assert_eq!(editor.text(cx), "/say-hello");
2044            // Hint is visible because argument was deleted
2045            assert_eq!(editor.display_text(cx), "/say-hello <name>");
2046
2047            // Delete last command letter
2048            editor.backspace(&editor::actions::Backspace, window, cx);
2049        });
2050
2051        cx.run_until_parked();
2052
2053        editor.update_in(&mut cx, |editor, _window, cx| {
2054            // Hint goes away once command no longer matches an available one
2055            assert_eq!(editor.text(cx), "/say-hell");
2056            assert_eq!(editor.display_text(cx), "/say-hell");
2057            assert!(!editor.has_visible_completions_menu());
2058        });
2059    }
2060
2061    #[gpui::test]
2062    async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2063        init_test(cx);
2064
2065        let app_state = cx.update(AppState::test);
2066
2067        cx.update(|cx| {
2068            language::init(cx);
2069            editor::init(cx);
2070            workspace::init(app_state.clone(), cx);
2071            Project::init_settings(cx);
2072        });
2073
2074        app_state
2075            .fs
2076            .as_fake()
2077            .insert_tree(
2078                path!("/dir"),
2079                json!({
2080                    "editor": "",
2081                    "a": {
2082                        "one.txt": "1",
2083                        "two.txt": "2",
2084                        "three.txt": "3",
2085                        "four.txt": "4"
2086                    },
2087                    "b": {
2088                        "five.txt": "5",
2089                        "six.txt": "6",
2090                        "seven.txt": "7",
2091                        "eight.txt": "8",
2092                    },
2093                    "x.png": "",
2094                }),
2095            )
2096            .await;
2097
2098        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2099        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2100        let workspace = window.root(cx).unwrap();
2101
2102        let worktree = project.update(cx, |project, cx| {
2103            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2104            assert_eq!(worktrees.len(), 1);
2105            worktrees.pop().unwrap()
2106        });
2107        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2108
2109        let mut cx = VisualTestContext::from_window(*window, cx);
2110
2111        let paths = vec![
2112            rel_path("a/one.txt"),
2113            rel_path("a/two.txt"),
2114            rel_path("a/three.txt"),
2115            rel_path("a/four.txt"),
2116            rel_path("b/five.txt"),
2117            rel_path("b/six.txt"),
2118            rel_path("b/seven.txt"),
2119            rel_path("b/eight.txt"),
2120        ];
2121
2122        let slash = PathStyle::local().separator();
2123
2124        let mut opened_editors = Vec::new();
2125        for path in paths {
2126            let buffer = workspace
2127                .update_in(&mut cx, |workspace, window, cx| {
2128                    workspace.open_path(
2129                        ProjectPath {
2130                            worktree_id,
2131                            path: path.into(),
2132                        },
2133                        None,
2134                        false,
2135                        window,
2136                        cx,
2137                    )
2138                })
2139                .await
2140                .unwrap();
2141            opened_editors.push(buffer);
2142        }
2143
2144        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
2145        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
2146        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2147
2148        let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2149            let workspace_handle = cx.weak_entity();
2150            let message_editor = cx.new(|cx| {
2151                MessageEditor::new(
2152                    workspace_handle,
2153                    project.clone(),
2154                    history_store.clone(),
2155                    None,
2156                    prompt_capabilities.clone(),
2157                    Default::default(),
2158                    "Test Agent".into(),
2159                    "Test",
2160                    EditorMode::AutoHeight {
2161                        max_lines: None,
2162                        min_lines: 1,
2163                    },
2164                    window,
2165                    cx,
2166                )
2167            });
2168            workspace.active_pane().update(cx, |pane, cx| {
2169                pane.add_item(
2170                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2171                    true,
2172                    true,
2173                    None,
2174                    window,
2175                    cx,
2176                );
2177            });
2178            message_editor.read(cx).focus_handle(cx).focus(window);
2179            let editor = message_editor.read(cx).editor().clone();
2180            (message_editor, editor)
2181        });
2182
2183        cx.simulate_input("Lorem @");
2184
2185        editor.update_in(&mut cx, |editor, window, cx| {
2186            assert_eq!(editor.text(cx), "Lorem @");
2187            assert!(editor.has_visible_completions_menu());
2188
2189            assert_eq!(
2190                current_completion_labels(editor),
2191                &[
2192                    format!("eight.txt dir{slash}b{slash}"),
2193                    format!("seven.txt dir{slash}b{slash}"),
2194                    format!("six.txt dir{slash}b{slash}"),
2195                    format!("five.txt dir{slash}b{slash}"),
2196                ]
2197            );
2198            editor.set_text("", window, cx);
2199        });
2200
2201        prompt_capabilities.replace(acp::PromptCapabilities {
2202            image: true,
2203            audio: true,
2204            embedded_context: true,
2205            meta: None,
2206        });
2207
2208        cx.simulate_input("Lorem ");
2209
2210        editor.update(&mut cx, |editor, cx| {
2211            assert_eq!(editor.text(cx), "Lorem ");
2212            assert!(!editor.has_visible_completions_menu());
2213        });
2214
2215        cx.simulate_input("@");
2216
2217        editor.update(&mut cx, |editor, cx| {
2218            assert_eq!(editor.text(cx), "Lorem @");
2219            assert!(editor.has_visible_completions_menu());
2220            assert_eq!(
2221                current_completion_labels(editor),
2222                &[
2223                    format!("eight.txt dir{slash}b{slash}"),
2224                    format!("seven.txt dir{slash}b{slash}"),
2225                    format!("six.txt dir{slash}b{slash}"),
2226                    format!("five.txt dir{slash}b{slash}"),
2227                    "Files & Directories".into(),
2228                    "Symbols".into(),
2229                    "Threads".into(),
2230                    "Fetch".into()
2231                ]
2232            );
2233        });
2234
2235        // Select and confirm "File"
2236        editor.update_in(&mut cx, |editor, window, cx| {
2237            assert!(editor.has_visible_completions_menu());
2238            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2239            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2240            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2241            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2242            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2243        });
2244
2245        cx.run_until_parked();
2246
2247        editor.update(&mut cx, |editor, cx| {
2248            assert_eq!(editor.text(cx), "Lorem @file ");
2249            assert!(editor.has_visible_completions_menu());
2250        });
2251
2252        cx.simulate_input("one");
2253
2254        editor.update(&mut cx, |editor, cx| {
2255            assert_eq!(editor.text(cx), "Lorem @file one");
2256            assert!(editor.has_visible_completions_menu());
2257            assert_eq!(
2258                current_completion_labels(editor),
2259                vec![format!("one.txt dir{slash}a{slash}")]
2260            );
2261        });
2262
2263        editor.update_in(&mut cx, |editor, window, cx| {
2264            assert!(editor.has_visible_completions_menu());
2265            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2266        });
2267
2268        let url_one = MentionUri::File {
2269            abs_path: path!("/dir/a/one.txt").into(),
2270        }
2271        .to_uri()
2272        .to_string();
2273        editor.update(&mut cx, |editor, cx| {
2274            let text = editor.text(cx);
2275            assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2276            assert!(!editor.has_visible_completions_menu());
2277            assert_eq!(fold_ranges(editor, cx).len(), 1);
2278        });
2279
2280        let all_prompt_capabilities = acp::PromptCapabilities {
2281            image: true,
2282            audio: true,
2283            embedded_context: true,
2284            meta: None,
2285        };
2286
2287        let contents = message_editor
2288            .update(&mut cx, |message_editor, cx| {
2289                message_editor.mention_set().contents(
2290                    &all_prompt_capabilities,
2291                    false,
2292                    project.clone(),
2293                    cx,
2294                )
2295            })
2296            .await
2297            .unwrap()
2298            .into_values()
2299            .collect::<Vec<_>>();
2300
2301        {
2302            let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2303                panic!("Unexpected mentions");
2304            };
2305            pretty_assertions::assert_eq!(content, "1");
2306            pretty_assertions::assert_eq!(uri, &url_one.parse::<MentionUri>().unwrap());
2307        }
2308
2309        let contents = message_editor
2310            .update(&mut cx, |message_editor, cx| {
2311                message_editor.mention_set().contents(
2312                    &acp::PromptCapabilities::default(),
2313                    false,
2314                    project.clone(),
2315                    cx,
2316                )
2317            })
2318            .await
2319            .unwrap()
2320            .into_values()
2321            .collect::<Vec<_>>();
2322
2323        {
2324            let [(uri, Mention::UriOnly)] = contents.as_slice() else {
2325                panic!("Unexpected mentions");
2326            };
2327            pretty_assertions::assert_eq!(uri, &url_one.parse::<MentionUri>().unwrap());
2328        }
2329
2330        cx.simulate_input(" ");
2331
2332        editor.update(&mut cx, |editor, cx| {
2333            let text = editor.text(cx);
2334            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  "));
2335            assert!(!editor.has_visible_completions_menu());
2336            assert_eq!(fold_ranges(editor, cx).len(), 1);
2337        });
2338
2339        cx.simulate_input("Ipsum ");
2340
2341        editor.update(&mut cx, |editor, cx| {
2342            let text = editor.text(cx);
2343            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum "),);
2344            assert!(!editor.has_visible_completions_menu());
2345            assert_eq!(fold_ranges(editor, cx).len(), 1);
2346        });
2347
2348        cx.simulate_input("@file ");
2349
2350        editor.update(&mut cx, |editor, cx| {
2351            let text = editor.text(cx);
2352            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum @file "),);
2353            assert!(editor.has_visible_completions_menu());
2354            assert_eq!(fold_ranges(editor, cx).len(), 1);
2355        });
2356
2357        editor.update_in(&mut cx, |editor, window, cx| {
2358            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2359        });
2360
2361        cx.run_until_parked();
2362
2363        let contents = message_editor
2364            .update(&mut cx, |message_editor, cx| {
2365                message_editor.mention_set().contents(
2366                    &all_prompt_capabilities,
2367                    false,
2368                    project.clone(),
2369                    cx,
2370                )
2371            })
2372            .await
2373            .unwrap()
2374            .into_values()
2375            .collect::<Vec<_>>();
2376
2377        let url_eight = MentionUri::File {
2378            abs_path: path!("/dir/b/eight.txt").into(),
2379        }
2380        .to_uri()
2381        .to_string();
2382
2383        {
2384            let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2385                panic!("Unexpected mentions");
2386            };
2387            pretty_assertions::assert_eq!(content, "8");
2388            pretty_assertions::assert_eq!(uri, &url_eight.parse::<MentionUri>().unwrap());
2389        }
2390
2391        editor.update(&mut cx, |editor, cx| {
2392            assert_eq!(
2393                editor.text(cx),
2394                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) ")
2395            );
2396            assert!(!editor.has_visible_completions_menu());
2397            assert_eq!(fold_ranges(editor, cx).len(), 2);
2398        });
2399
2400        let plain_text_language = Arc::new(language::Language::new(
2401            language::LanguageConfig {
2402                name: "Plain Text".into(),
2403                matcher: language::LanguageMatcher {
2404                    path_suffixes: vec!["txt".to_string()],
2405                    ..Default::default()
2406                },
2407                ..Default::default()
2408            },
2409            None,
2410        ));
2411
2412        // Register the language and fake LSP
2413        let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2414        language_registry.add(plain_text_language);
2415
2416        let mut fake_language_servers = language_registry.register_fake_lsp(
2417            "Plain Text",
2418            language::FakeLspAdapter {
2419                capabilities: lsp::ServerCapabilities {
2420                    workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2421                    ..Default::default()
2422                },
2423                ..Default::default()
2424            },
2425        );
2426
2427        // Open the buffer to trigger LSP initialization
2428        let buffer = project
2429            .update(&mut cx, |project, cx| {
2430                project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2431            })
2432            .await
2433            .unwrap();
2434
2435        // Register the buffer with language servers
2436        let _handle = project.update(&mut cx, |project, cx| {
2437            project.register_buffer_with_language_servers(&buffer, cx)
2438        });
2439
2440        cx.run_until_parked();
2441
2442        let fake_language_server = fake_language_servers.next().await.unwrap();
2443        fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2444            move |_, _| async move {
2445                Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2446                    #[allow(deprecated)]
2447                    lsp::SymbolInformation {
2448                        name: "MySymbol".into(),
2449                        location: lsp::Location {
2450                            uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2451                            range: lsp::Range::new(
2452                                lsp::Position::new(0, 0),
2453                                lsp::Position::new(0, 1),
2454                            ),
2455                        },
2456                        kind: lsp::SymbolKind::CONSTANT,
2457                        tags: None,
2458                        container_name: None,
2459                        deprecated: None,
2460                    },
2461                ])))
2462            },
2463        );
2464
2465        cx.simulate_input("@symbol ");
2466
2467        editor.update(&mut cx, |editor, cx| {
2468            assert_eq!(
2469                editor.text(cx),
2470                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) @symbol ")
2471            );
2472            assert!(editor.has_visible_completions_menu());
2473            assert_eq!(current_completion_labels(editor), &["MySymbol"]);
2474        });
2475
2476        editor.update_in(&mut cx, |editor, window, cx| {
2477            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2478        });
2479
2480        let symbol = MentionUri::Symbol {
2481            abs_path: path!("/dir/a/one.txt").into(),
2482            name: "MySymbol".into(),
2483            line_range: 0..=0,
2484        };
2485
2486        let contents = message_editor
2487            .update(&mut cx, |message_editor, cx| {
2488                message_editor.mention_set().contents(
2489                    &all_prompt_capabilities,
2490                    false,
2491                    project.clone(),
2492                    cx,
2493                )
2494            })
2495            .await
2496            .unwrap()
2497            .into_values()
2498            .collect::<Vec<_>>();
2499
2500        {
2501            let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2502                panic!("Unexpected mentions");
2503            };
2504            pretty_assertions::assert_eq!(content, "1");
2505            pretty_assertions::assert_eq!(uri, &symbol);
2506        }
2507
2508        cx.run_until_parked();
2509
2510        editor.read_with(&cx, |editor, cx| {
2511            assert_eq!(
2512                editor.text(cx),
2513                format!(
2514                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2515                    symbol.to_uri(),
2516                )
2517            );
2518        });
2519
2520        // Try to mention an "image" file that will fail to load
2521        cx.simulate_input("@file x.png");
2522
2523        editor.update(&mut cx, |editor, cx| {
2524            assert_eq!(
2525                editor.text(cx),
2526                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2527            );
2528            assert!(editor.has_visible_completions_menu());
2529            assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]);
2530        });
2531
2532        editor.update_in(&mut cx, |editor, window, cx| {
2533            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2534        });
2535
2536        // Getting the message contents fails
2537        message_editor
2538            .update(&mut cx, |message_editor, cx| {
2539                message_editor.mention_set().contents(
2540                    &all_prompt_capabilities,
2541                    false,
2542                    project.clone(),
2543                    cx,
2544                )
2545            })
2546            .await
2547            .expect_err("Should fail to load x.png");
2548
2549        cx.run_until_parked();
2550
2551        // Mention was removed
2552        editor.read_with(&cx, |editor, cx| {
2553            assert_eq!(
2554                editor.text(cx),
2555                format!(
2556                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2557                    symbol.to_uri()
2558                )
2559            );
2560        });
2561
2562        // Once more
2563        cx.simulate_input("@file x.png");
2564
2565        editor.update(&mut cx, |editor, cx| {
2566                    assert_eq!(
2567                        editor.text(cx),
2568                        format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2569                    );
2570                    assert!(editor.has_visible_completions_menu());
2571                    assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]);
2572                });
2573
2574        editor.update_in(&mut cx, |editor, window, cx| {
2575            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2576        });
2577
2578        // This time don't immediately get the contents, just let the confirmed completion settle
2579        cx.run_until_parked();
2580
2581        // Mention was removed
2582        editor.read_with(&cx, |editor, cx| {
2583            assert_eq!(
2584                editor.text(cx),
2585                format!(
2586                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2587                    symbol.to_uri()
2588                )
2589            );
2590        });
2591
2592        // Now getting the contents succeeds, because the invalid mention was removed
2593        let contents = message_editor
2594            .update(&mut cx, |message_editor, cx| {
2595                message_editor.mention_set().contents(
2596                    &all_prompt_capabilities,
2597                    false,
2598                    project.clone(),
2599                    cx,
2600                )
2601            })
2602            .await
2603            .unwrap();
2604        assert_eq!(contents.len(), 3);
2605    }
2606
2607    fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2608        let snapshot = editor.buffer().read(cx).snapshot(cx);
2609        editor.display_map.update(cx, |display_map, cx| {
2610            display_map
2611                .snapshot(cx)
2612                .folds_in_range(0..snapshot.len())
2613                .map(|fold| fold.range.to_point(&snapshot))
2614                .collect()
2615        })
2616    }
2617
2618    fn current_completion_labels(editor: &Editor) -> Vec<String> {
2619        let completions = editor.current_completions().expect("Missing completions");
2620        completions
2621            .into_iter()
2622            .map(|completion| completion.label.text)
2623            .collect::<Vec<_>>()
2624    }
2625
2626    fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2627        let completions = editor.current_completions().expect("Missing completions");
2628        completions
2629            .into_iter()
2630            .map(|completion| {
2631                (
2632                    completion.label.text,
2633                    completion
2634                        .documentation
2635                        .map(|d| d.text().to_string())
2636                        .unwrap_or_default(),
2637                )
2638            })
2639            .collect::<Vec<_>>()
2640    }
2641
2642    #[gpui::test]
2643    async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) {
2644        init_test(cx);
2645
2646        let fs = FakeFs::new(cx.executor());
2647
2648        // Create a large file that exceeds AUTO_OUTLINE_SIZE
2649        const LINE: &str = "fn example_function() { /* some code */ }\n";
2650        let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2651        assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2652
2653        // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2654        let small_content = "fn small_function() { /* small */ }\n";
2655        assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2656
2657        fs.insert_tree(
2658            "/project",
2659            json!({
2660                "large_file.rs": large_content.clone(),
2661                "small_file.rs": small_content,
2662            }),
2663        )
2664        .await;
2665
2666        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2667
2668        let (workspace, cx) =
2669            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2670
2671        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
2672        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
2673
2674        let message_editor = cx.update(|window, cx| {
2675            cx.new(|cx| {
2676                let editor = MessageEditor::new(
2677                    workspace.downgrade(),
2678                    project.clone(),
2679                    history_store.clone(),
2680                    None,
2681                    Default::default(),
2682                    Default::default(),
2683                    "Test Agent".into(),
2684                    "Test",
2685                    EditorMode::AutoHeight {
2686                        min_lines: 1,
2687                        max_lines: None,
2688                    },
2689                    window,
2690                    cx,
2691                );
2692                // Enable embedded context so files are actually included
2693                editor.prompt_capabilities.replace(acp::PromptCapabilities {
2694                    embedded_context: true,
2695                    meta: None,
2696                    ..Default::default()
2697                });
2698                editor
2699            })
2700        });
2701
2702        // Test large file mention
2703        // Get the absolute path using the project's worktree
2704        let large_file_abs_path = project.read_with(cx, |project, cx| {
2705            let worktree = project.worktrees(cx).next().unwrap();
2706            let worktree_root = worktree.read(cx).abs_path();
2707            worktree_root.join("large_file.rs")
2708        });
2709        let large_file_task = message_editor.update(cx, |editor, cx| {
2710            editor.confirm_mention_for_file(large_file_abs_path, cx)
2711        });
2712
2713        let large_file_mention = large_file_task.await.unwrap();
2714        match large_file_mention {
2715            Mention::Text { content, .. } => {
2716                // Should contain outline header for large files
2717                assert!(content.contains("File outline for"));
2718                assert!(content.contains("file too large to show full content"));
2719                // Should not contain the full repeated content
2720                assert!(!content.contains(&LINE.repeat(100)));
2721            }
2722            _ => panic!("Expected Text mention for large file"),
2723        }
2724
2725        // Test small file mention
2726        // Get the absolute path using the project's worktree
2727        let small_file_abs_path = project.read_with(cx, |project, cx| {
2728            let worktree = project.worktrees(cx).next().unwrap();
2729            let worktree_root = worktree.read(cx).abs_path();
2730            worktree_root.join("small_file.rs")
2731        });
2732        let small_file_task = message_editor.update(cx, |editor, cx| {
2733            editor.confirm_mention_for_file(small_file_abs_path, cx)
2734        });
2735
2736        let small_file_mention = small_file_task.await.unwrap();
2737        match small_file_mention {
2738            Mention::Text { content, .. } => {
2739                // Should contain the actual content
2740                assert_eq!(content, small_content);
2741                // Should not contain outline header
2742                assert!(!content.contains("File outline for"));
2743            }
2744            _ => panic!("Expected Text mention for small file"),
2745        }
2746    }
2747
2748    #[gpui::test]
2749    async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2750        init_test(cx);
2751
2752        let fs = FakeFs::new(cx.executor());
2753        fs.insert_tree("/project", json!({"file": ""})).await;
2754        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2755
2756        let (workspace, cx) =
2757            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2758
2759        let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
2760        let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
2761
2762        // Create a thread metadata to insert as summary
2763        let thread_metadata = agent2::DbThreadMetadata {
2764            id: acp::SessionId("thread-123".into()),
2765            title: "Previous Conversation".into(),
2766            updated_at: chrono::Utc::now(),
2767        };
2768
2769        let message_editor = cx.update(|window, cx| {
2770            cx.new(|cx| {
2771                let mut editor = MessageEditor::new(
2772                    workspace.downgrade(),
2773                    project.clone(),
2774                    history_store.clone(),
2775                    None,
2776                    Default::default(),
2777                    Default::default(),
2778                    "Test Agent".into(),
2779                    "Test",
2780                    EditorMode::AutoHeight {
2781                        min_lines: 1,
2782                        max_lines: None,
2783                    },
2784                    window,
2785                    cx,
2786                );
2787                editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2788                editor
2789            })
2790        });
2791
2792        // Construct expected values for verification
2793        let expected_uri = MentionUri::Thread {
2794            id: thread_metadata.id.clone(),
2795            name: thread_metadata.title.to_string(),
2796        };
2797        let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2798
2799        message_editor.read_with(cx, |editor, cx| {
2800            let text = editor.text(cx);
2801
2802            assert!(
2803                text.contains(&expected_link),
2804                "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2805                expected_link,
2806                text
2807            );
2808
2809            let mentions = editor.mentions();
2810            assert_eq!(
2811                mentions.len(),
2812                1,
2813                "Expected exactly one mention after inserting thread summary"
2814            );
2815
2816            assert!(
2817                mentions.contains(&expected_uri),
2818                "Expected mentions to contain the thread URI"
2819            );
2820        });
2821    }
2822}