message_editor.rs

   1use crate::SendImmediately;
   2use crate::acp::AcpThreadHistory;
   3use crate::{
   4    ChatWithFollow,
   5    completion_provider::{
   6        PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
   7        PromptContextType, SlashCommandCompletion,
   8    },
   9    mention_set::{
  10        Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
  11    },
  12};
  13use acp_thread::{AgentSessionInfo, MentionUri};
  14use agent::ThreadStore;
  15use agent_client_protocol as acp;
  16use anyhow::{Result, anyhow};
  17use collections::HashSet;
  18use editor::{
  19    Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
  20    EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
  21    MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
  22    scroll::Autoscroll,
  23};
  24use futures::{FutureExt as _, future::join_all};
  25use gpui::{
  26    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
  27    KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
  28};
  29use language::{Buffer, Language, language_settings::InlayHintKind};
  30use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
  31use prompt_store::PromptStore;
  32use rope::Point;
  33use settings::Settings;
  34use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
  35use theme::ThemeSettings;
  36use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
  37use util::paths::PathStyle;
  38use util::{ResultExt, debug_panic};
  39use workspace::{CollaboratorId, Workspace};
  40use zed_actions::agent::{Chat, PasteRaw};
  41
  42pub struct MessageEditor {
  43    mention_set: Entity<MentionSet>,
  44    editor: Entity<Editor>,
  45    workspace: WeakEntity<Workspace>,
  46    prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
  47    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
  48    agent_name: SharedString,
  49    thread_store: Option<Entity<ThreadStore>>,
  50    _subscriptions: Vec<Subscription>,
  51    _parse_slash_command_task: Task<()>,
  52}
  53
  54#[derive(Clone, Copy, Debug)]
  55pub enum MessageEditorEvent {
  56    Send,
  57    SendImmediately,
  58    Cancel,
  59    Focus,
  60    LostFocus,
  61}
  62
  63impl EventEmitter<MessageEditorEvent> for MessageEditor {}
  64
  65const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
  66
  67impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
  68    fn supports_images(&self, cx: &App) -> bool {
  69        self.read(cx).prompt_capabilities.borrow().image
  70    }
  71
  72    fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
  73        let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
  74        if self.read(cx).prompt_capabilities.borrow().embedded_context {
  75            if self.read(cx).thread_store.is_some() {
  76                supported.push(PromptContextType::Thread);
  77            }
  78            supported.extend(&[
  79                PromptContextType::Diagnostics,
  80                PromptContextType::Fetch,
  81                PromptContextType::Rules,
  82            ]);
  83        }
  84        supported
  85    }
  86
  87    fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
  88        self.read(cx)
  89            .available_commands
  90            .borrow()
  91            .iter()
  92            .map(|cmd| crate::completion_provider::AvailableCommand {
  93                name: cmd.name.clone().into(),
  94                description: cmd.description.clone().into(),
  95                requires_argument: cmd.input.is_some(),
  96            })
  97            .collect()
  98    }
  99
 100    fn confirm_command(&self, cx: &mut App) {
 101        self.update(cx, |this, cx| this.send(cx));
 102    }
 103}
 104
 105impl MessageEditor {
 106    pub fn new(
 107        workspace: WeakEntity<Workspace>,
 108        project: WeakEntity<Project>,
 109        thread_store: Option<Entity<ThreadStore>>,
 110        history: WeakEntity<AcpThreadHistory>,
 111        prompt_store: Option<Entity<PromptStore>>,
 112        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
 113        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
 114        agent_name: SharedString,
 115        placeholder: &str,
 116        mode: EditorMode,
 117        window: &mut Window,
 118        cx: &mut Context<Self>,
 119    ) -> Self {
 120        let language = Language::new(
 121            language::LanguageConfig {
 122                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
 123                ..Default::default()
 124            },
 125            None,
 126        );
 127
 128        let editor = cx.new(|cx| {
 129            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
 130            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 131
 132            let mut editor = Editor::new(mode, buffer, None, window, cx);
 133            editor.set_placeholder_text(placeholder, window, cx);
 134            editor.set_show_indent_guides(false, cx);
 135            editor.set_show_completions_on_input(Some(true));
 136            editor.set_soft_wrap();
 137            editor.set_use_modal_editing(true);
 138            editor.set_context_menu_options(ContextMenuOptions {
 139                min_entries_visible: 12,
 140                max_entries_visible: 12,
 141                placement: Some(ContextMenuPlacement::Above),
 142            });
 143            editor.register_addon(MessageEditorAddon::new());
 144
 145            editor.set_custom_context_menu(|editor, _point, window, cx| {
 146                let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
 147
 148                Some(ContextMenu::build(window, cx, |menu, _, _| {
 149                    menu.action("Cut", Box::new(editor::actions::Cut))
 150                        .action_disabled_when(
 151                            !has_selection,
 152                            "Copy",
 153                            Box::new(editor::actions::Copy),
 154                        )
 155                        .action("Paste", Box::new(editor::actions::Paste))
 156                }))
 157            });
 158
 159            editor
 160        });
 161        let mention_set =
 162            cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
 163        let completion_provider = Rc::new(PromptCompletionProvider::new(
 164            cx.entity(),
 165            editor.downgrade(),
 166            mention_set.clone(),
 167            history,
 168            prompt_store.clone(),
 169            workspace.clone(),
 170        ));
 171        editor.update(cx, |editor, _cx| {
 172            editor.set_completion_provider(Some(completion_provider.clone()))
 173        });
 174
 175        cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
 176            cx.emit(MessageEditorEvent::Focus)
 177        })
 178        .detach();
 179        cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
 180            cx.emit(MessageEditorEvent::LostFocus)
 181        })
 182        .detach();
 183
 184        let mut has_hint = false;
 185        let mut subscriptions = Vec::new();
 186
 187        subscriptions.push(cx.subscribe_in(&editor, window, {
 188            move |this, editor, event, window, cx| {
 189                if let EditorEvent::Edited { .. } = event
 190                    && !editor.read(cx).read_only(cx)
 191                {
 192                    editor.update(cx, |editor, cx| {
 193                        let snapshot = editor.snapshot(window, cx);
 194                        this.mention_set
 195                            .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
 196
 197                        let new_hints = this
 198                            .command_hint(snapshot.buffer())
 199                            .into_iter()
 200                            .collect::<Vec<_>>();
 201                        let has_new_hint = !new_hints.is_empty();
 202                        editor.splice_inlays(
 203                            if has_hint {
 204                                &[COMMAND_HINT_INLAY_ID]
 205                            } else {
 206                                &[]
 207                            },
 208                            new_hints,
 209                            cx,
 210                        );
 211                        has_hint = has_new_hint;
 212                    });
 213                    cx.notify();
 214                }
 215            }
 216        }));
 217
 218        Self {
 219            editor,
 220            mention_set,
 221            workspace,
 222            prompt_capabilities,
 223            available_commands,
 224            agent_name,
 225            thread_store,
 226            _subscriptions: subscriptions,
 227            _parse_slash_command_task: Task::ready(()),
 228        }
 229    }
 230
 231    pub fn set_command_state(
 232        &mut self,
 233        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
 234        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
 235        _cx: &mut Context<Self>,
 236    ) {
 237        self.prompt_capabilities = prompt_capabilities;
 238        self.available_commands = available_commands;
 239    }
 240
 241    fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
 242        let available_commands = self.available_commands.borrow();
 243        if available_commands.is_empty() {
 244            return None;
 245        }
 246
 247        let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
 248        if parsed_command.argument.is_some() {
 249            return None;
 250        }
 251
 252        let command_name = parsed_command.command?;
 253        let available_command = available_commands
 254            .iter()
 255            .find(|command| command.name == command_name)?;
 256
 257        let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
 258            mut hint,
 259            ..
 260        }) = available_command.input.clone()?
 261        else {
 262            return None;
 263        };
 264
 265        let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
 266        if hint_pos > snapshot.len() {
 267            hint_pos = snapshot.len();
 268            hint.insert(0, ' ');
 269        }
 270
 271        let hint_pos = snapshot.anchor_after(hint_pos);
 272
 273        Some(Inlay::hint(
 274            COMMAND_HINT_INLAY_ID,
 275            hint_pos,
 276            &InlayHint {
 277                position: hint_pos.text_anchor,
 278                label: InlayHintLabel::String(hint),
 279                kind: Some(InlayHintKind::Parameter),
 280                padding_left: false,
 281                padding_right: false,
 282                tooltip: None,
 283                resolve_state: project::ResolveState::Resolved,
 284            },
 285        ))
 286    }
 287
 288    pub fn insert_thread_summary(
 289        &mut self,
 290        thread: AgentSessionInfo,
 291        window: &mut Window,
 292        cx: &mut Context<Self>,
 293    ) {
 294        if self.thread_store.is_none() {
 295            return;
 296        }
 297        let Some(workspace) = self.workspace.upgrade() else {
 298            return;
 299        };
 300        let thread_title = thread
 301            .title
 302            .clone()
 303            .filter(|title| !title.is_empty())
 304            .unwrap_or_else(|| SharedString::new_static("New Thread"));
 305        let uri = MentionUri::Thread {
 306            id: thread.session_id,
 307            name: thread_title.to_string(),
 308        };
 309        let content = format!("{}\n", uri.as_link());
 310
 311        let content_len = content.len() - 1;
 312
 313        let start = self.editor.update(cx, |editor, cx| {
 314            editor.set_text(content, window, cx);
 315            editor
 316                .buffer()
 317                .read(cx)
 318                .snapshot(cx)
 319                .anchor_before(Point::zero())
 320                .text_anchor
 321        });
 322
 323        let supports_images = self.prompt_capabilities.borrow().image;
 324
 325        self.mention_set
 326            .update(cx, |mention_set, cx| {
 327                mention_set.confirm_mention_completion(
 328                    thread_title,
 329                    start,
 330                    content_len,
 331                    uri,
 332                    supports_images,
 333                    self.editor.clone(),
 334                    &workspace,
 335                    window,
 336                    cx,
 337                )
 338            })
 339            .detach();
 340    }
 341
 342    #[cfg(test)]
 343    pub(crate) fn editor(&self) -> &Entity<Editor> {
 344        &self.editor
 345    }
 346
 347    pub fn is_empty(&self, cx: &App) -> bool {
 348        self.editor.read(cx).is_empty(cx)
 349    }
 350
 351    pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
 352        self.editor
 353            .read(cx)
 354            .context_menu()
 355            .borrow()
 356            .as_ref()
 357            .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
 358    }
 359
 360    #[cfg(test)]
 361    pub fn mention_set(&self) -> &Entity<MentionSet> {
 362        &self.mention_set
 363    }
 364
 365    fn validate_slash_commands(
 366        text: &str,
 367        available_commands: &[acp::AvailableCommand],
 368        agent_name: &str,
 369    ) -> Result<()> {
 370        if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
 371            if let Some(command_name) = parsed_command.command {
 372                // Check if this command is in the list of available commands from the server
 373                let is_supported = available_commands
 374                    .iter()
 375                    .any(|cmd| cmd.name == command_name);
 376
 377                if !is_supported {
 378                    return Err(anyhow!(
 379                        "The /{} command is not supported by {}.\n\nAvailable commands: {}",
 380                        command_name,
 381                        agent_name,
 382                        if available_commands.is_empty() {
 383                            "none".to_string()
 384                        } else {
 385                            available_commands
 386                                .iter()
 387                                .map(|cmd| format!("/{}", cmd.name))
 388                                .collect::<Vec<_>>()
 389                                .join(", ")
 390                        }
 391                    ));
 392                }
 393            }
 394        }
 395        Ok(())
 396    }
 397
 398    pub fn contents(
 399        &self,
 400        full_mention_content: bool,
 401        cx: &mut Context<Self>,
 402    ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
 403        let text = self.editor.read(cx).text(cx);
 404        let available_commands = self.available_commands.borrow().clone();
 405        let agent_name = self.agent_name.clone();
 406
 407        let contents = self
 408            .mention_set
 409            .update(cx, |store, cx| store.contents(full_mention_content, cx));
 410        let editor = self.editor.clone();
 411        let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
 412
 413        cx.spawn(async move |_, cx| {
 414            Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
 415
 416            let contents = contents.await?;
 417            let mut all_tracked_buffers = Vec::new();
 418
 419            let result = editor.update(cx, |editor, cx| {
 420                let (mut ix, _) = text
 421                    .char_indices()
 422                    .find(|(_, c)| !c.is_whitespace())
 423                    .unwrap_or((0, '\0'));
 424                let mut chunks: Vec<acp::ContentBlock> = Vec::new();
 425                let text = editor.text(cx);
 426                editor.display_map.update(cx, |map, cx| {
 427                    let snapshot = map.snapshot(cx);
 428                    for (crease_id, crease) in snapshot.crease_snapshot.creases() {
 429                        let Some((uri, mention)) = contents.get(&crease_id) else {
 430                            continue;
 431                        };
 432
 433                        let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
 434                        if crease_range.start.0 > ix {
 435                            let chunk = text[ix..crease_range.start.0].into();
 436                            chunks.push(chunk);
 437                        }
 438                        let chunk = match mention {
 439                            Mention::Text {
 440                                content,
 441                                tracked_buffers,
 442                            } => {
 443                                all_tracked_buffers.extend(tracked_buffers.iter().cloned());
 444                                if supports_embedded_context {
 445                                    acp::ContentBlock::Resource(acp::EmbeddedResource::new(
 446                                        acp::EmbeddedResourceResource::TextResourceContents(
 447                                            acp::TextResourceContents::new(
 448                                                content.clone(),
 449                                                uri.to_uri().to_string(),
 450                                            ),
 451                                        ),
 452                                    ))
 453                                } else {
 454                                    acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
 455                                        uri.name(),
 456                                        uri.to_uri().to_string(),
 457                                    ))
 458                                }
 459                            }
 460                            Mention::Image(mention_image) => acp::ContentBlock::Image(
 461                                acp::ImageContent::new(
 462                                    mention_image.data.clone(),
 463                                    mention_image.format.mime_type(),
 464                                )
 465                                .uri(match uri {
 466                                    MentionUri::File { .. } => Some(uri.to_uri().to_string()),
 467                                    MentionUri::PastedImage => None,
 468                                    other => {
 469                                        debug_panic!(
 470                                            "unexpected mention uri for image: {:?}",
 471                                            other
 472                                        );
 473                                        None
 474                                    }
 475                                }),
 476                            ),
 477                            Mention::Link => acp::ContentBlock::ResourceLink(
 478                                acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
 479                            ),
 480                        };
 481                        chunks.push(chunk);
 482                        ix = crease_range.end.0;
 483                    }
 484
 485                    if ix < text.len() {
 486                        let last_chunk = text[ix..].trim_end().to_owned();
 487                        if !last_chunk.is_empty() {
 488                            chunks.push(last_chunk.into());
 489                        }
 490                    }
 491                });
 492                anyhow::Ok((chunks, all_tracked_buffers))
 493            })?;
 494            Ok(result)
 495        })
 496    }
 497
 498    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 499        self.editor.update(cx, |editor, cx| {
 500            editor.clear(window, cx);
 501            editor.remove_creases(
 502                self.mention_set.update(cx, |mention_set, _cx| {
 503                    mention_set
 504                        .clear()
 505                        .map(|(crease_id, _)| crease_id)
 506                        .collect::<Vec<_>>()
 507                }),
 508                cx,
 509            )
 510        });
 511    }
 512
 513    pub fn send(&mut self, cx: &mut Context<Self>) {
 514        if !self.is_empty(cx) {
 515            self.editor.update(cx, |editor, cx| {
 516                editor.clear_inlay_hints(cx);
 517            });
 518        }
 519        cx.emit(MessageEditorEvent::Send)
 520    }
 521
 522    pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 523        self.insert_context_prefix("@", window, cx);
 524    }
 525
 526    pub fn insert_context_type(
 527        &mut self,
 528        context_keyword: &str,
 529        window: &mut Window,
 530        cx: &mut Context<Self>,
 531    ) {
 532        let prefix = format!("@{}", context_keyword);
 533        self.insert_context_prefix(&prefix, window, cx);
 534    }
 535
 536    fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
 537        let editor = self.editor.clone();
 538        let prefix = prefix.to_string();
 539
 540        cx.spawn_in(window, async move |_, cx| {
 541            editor
 542                .update_in(cx, |editor, window, cx| {
 543                    let menu_is_open =
 544                        editor.context_menu().borrow().as_ref().is_some_and(|menu| {
 545                            matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
 546                        });
 547
 548                    let has_prefix = {
 549                        let snapshot = editor.display_snapshot(cx);
 550                        let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
 551                        let offset = cursor.to_offset(&snapshot);
 552                        let buffer_snapshot = snapshot.buffer_snapshot();
 553                        let prefix_char_count = prefix.chars().count();
 554                        buffer_snapshot
 555                            .reversed_chars_at(offset)
 556                            .take(prefix_char_count)
 557                            .eq(prefix.chars().rev())
 558                    };
 559
 560                    if menu_is_open && has_prefix {
 561                        return;
 562                    }
 563
 564                    editor.insert(&prefix, window, cx);
 565                    editor.show_completions(&editor::actions::ShowCompletions, window, cx);
 566                })
 567                .log_err();
 568        })
 569        .detach();
 570    }
 571
 572    fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
 573        self.send(cx);
 574    }
 575
 576    fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
 577        if self.is_empty(cx) {
 578            return;
 579        }
 580
 581        self.editor.update(cx, |editor, cx| {
 582            editor.clear_inlay_hints(cx);
 583        });
 584
 585        cx.emit(MessageEditorEvent::SendImmediately)
 586    }
 587
 588    fn chat_with_follow(
 589        &mut self,
 590        _: &ChatWithFollow,
 591        window: &mut Window,
 592        cx: &mut Context<Self>,
 593    ) {
 594        self.workspace
 595            .update(cx, |this, cx| {
 596                this.follow(CollaboratorId::Agent, window, cx)
 597            })
 598            .log_err();
 599
 600        self.send(cx);
 601    }
 602
 603    fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
 604        cx.emit(MessageEditorEvent::Cancel)
 605    }
 606
 607    fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
 608        let Some(workspace) = self.workspace.upgrade() else {
 609            return;
 610        };
 611        let editor_clipboard_selections = cx
 612            .read_from_clipboard()
 613            .and_then(|item| item.entries().first().cloned())
 614            .and_then(|entry| match entry {
 615                ClipboardEntry::String(text) => {
 616                    text.metadata_json::<Vec<editor::ClipboardSelection>>()
 617                }
 618                _ => None,
 619            });
 620
 621        // Insert creases for pasted clipboard selections that:
 622        // 1. Contain exactly one selection
 623        // 2. Have an associated file path
 624        // 3. Span multiple lines (not single-line selections)
 625        // 4. Belong to a file that exists in the current project
 626        let should_insert_creases = util::maybe!({
 627            let selections = editor_clipboard_selections.as_ref()?;
 628            if selections.len() > 1 {
 629                return Some(false);
 630            }
 631            let selection = selections.first()?;
 632            let file_path = selection.file_path.as_ref()?;
 633            let line_range = selection.line_range.as_ref()?;
 634
 635            if line_range.start() == line_range.end() {
 636                return Some(false);
 637            }
 638
 639            Some(
 640                workspace
 641                    .read(cx)
 642                    .project()
 643                    .read(cx)
 644                    .project_path_for_absolute_path(file_path, cx)
 645                    .is_some(),
 646            )
 647        })
 648        .unwrap_or(false);
 649
 650        if should_insert_creases && let Some(selections) = editor_clipboard_selections {
 651            cx.stop_propagation();
 652            let insertion_target = self
 653                .editor
 654                .read(cx)
 655                .selections
 656                .newest_anchor()
 657                .start
 658                .text_anchor;
 659
 660            let project = workspace.read(cx).project().clone();
 661            for selection in selections {
 662                if let (Some(file_path), Some(line_range)) =
 663                    (selection.file_path, selection.line_range)
 664                {
 665                    let crease_text =
 666                        acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
 667
 668                    let mention_uri = MentionUri::Selection {
 669                        abs_path: Some(file_path.clone()),
 670                        line_range: line_range.clone(),
 671                    };
 672
 673                    let mention_text = mention_uri.as_link().to_string();
 674                    let (excerpt_id, text_anchor, content_len) =
 675                        self.editor.update(cx, |editor, cx| {
 676                            let buffer = editor.buffer().read(cx);
 677                            let snapshot = buffer.snapshot(cx);
 678                            let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
 679                            let text_anchor = insertion_target.bias_left(&buffer_snapshot);
 680
 681                            editor.insert(&mention_text, window, cx);
 682                            editor.insert(" ", window, cx);
 683
 684                            (excerpt_id, text_anchor, mention_text.len())
 685                        });
 686
 687                    let Some((crease_id, tx)) = insert_crease_for_mention(
 688                        excerpt_id,
 689                        text_anchor,
 690                        content_len,
 691                        crease_text.into(),
 692                        mention_uri.icon_path(cx),
 693                        None,
 694                        self.editor.clone(),
 695                        window,
 696                        cx,
 697                    ) else {
 698                        continue;
 699                    };
 700                    drop(tx);
 701
 702                    let mention_task = cx
 703                        .spawn({
 704                            let project = project.clone();
 705                            async move |_, cx| {
 706                                let project_path = project
 707                                    .update(cx, |project, cx| {
 708                                        project.project_path_for_absolute_path(&file_path, cx)
 709                                    })
 710                                    .ok_or_else(|| "project path not found".to_string())?;
 711
 712                                let buffer = project
 713                                    .update(cx, |project, cx| project.open_buffer(project_path, cx))
 714                                    .await
 715                                    .map_err(|e| e.to_string())?;
 716
 717                                Ok(buffer.update(cx, |buffer, cx| {
 718                                    let start =
 719                                        Point::new(*line_range.start(), 0).min(buffer.max_point());
 720                                    let end = Point::new(*line_range.end() + 1, 0)
 721                                        .min(buffer.max_point());
 722                                    let content = buffer.text_for_range(start..end).collect();
 723                                    Mention::Text {
 724                                        content,
 725                                        tracked_buffers: vec![cx.entity()],
 726                                    }
 727                                }))
 728                            }
 729                        })
 730                        .shared();
 731
 732                    self.mention_set.update(cx, |mention_set, _cx| {
 733                        mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
 734                    });
 735                }
 736            }
 737            return;
 738        }
 739        // Handle text paste with potential markdown mention links.
 740        // This must be checked BEFORE paste_images_as_context because that function
 741        // returns a task even when there are no images in the clipboard.
 742        if let Some(clipboard_text) = cx
 743            .read_from_clipboard()
 744            .and_then(|item| item.entries().first().cloned())
 745            .and_then(|entry| match entry {
 746                ClipboardEntry::String(text) => Some(text.text().to_string()),
 747                _ => None,
 748            })
 749        {
 750            let path_style = workspace.read(cx).project().read(cx).path_style(cx);
 751
 752            // Parse markdown mention links in format: [@name](uri)
 753            let parsed_mentions = parse_mention_links(&clipboard_text, path_style);
 754
 755            if !parsed_mentions.is_empty() {
 756                cx.stop_propagation();
 757
 758                let insertion_offset = self.editor.update(cx, |editor, cx| {
 759                    let snapshot = editor.buffer().read(cx).snapshot(cx);
 760                    editor.selections.newest_anchor().start.to_offset(&snapshot)
 761                });
 762
 763                // Insert the raw text first
 764                self.editor.update(cx, |editor, cx| {
 765                    editor.insert(&clipboard_text, window, cx);
 766                });
 767
 768                let supports_images = self.prompt_capabilities.borrow().image;
 769                let http_client = workspace.read(cx).client().http_client();
 770
 771                // Now create creases for each mention and load their content
 772                let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
 773                for (range, mention_uri) in parsed_mentions {
 774                    let start_offset = insertion_offset.0 + range.start;
 775                    let anchor = snapshot.anchor_before(MultiBufferOffset(start_offset));
 776                    let content_len = range.end - range.start;
 777
 778                    let Some((crease_id, tx)) = insert_crease_for_mention(
 779                        anchor.excerpt_id,
 780                        anchor.text_anchor,
 781                        content_len,
 782                        mention_uri.name().into(),
 783                        mention_uri.icon_path(cx),
 784                        None,
 785                        self.editor.clone(),
 786                        window,
 787                        cx,
 788                    ) else {
 789                        continue;
 790                    };
 791
 792                    // Create the confirmation task based on the mention URI type.
 793                    // This properly loads file content, fetches URLs, etc.
 794                    let task = self.mention_set.update(cx, |mention_set, cx| {
 795                        mention_set.confirm_mention_for_uri(
 796                            mention_uri.clone(),
 797                            supports_images,
 798                            http_client.clone(),
 799                            cx,
 800                        )
 801                    });
 802                    let task = cx
 803                        .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
 804                        .shared();
 805
 806                    self.mention_set.update(cx, |mention_set, _cx| {
 807                        mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
 808                    });
 809
 810                    // Drop the tx after inserting to signal the crease is ready
 811                    drop(tx);
 812                }
 813                return;
 814            }
 815        }
 816
 817        if self.prompt_capabilities.borrow().image
 818            && let Some(task) = paste_images_as_context(
 819                self.editor.clone(),
 820                self.mention_set.clone(),
 821                self.workspace.clone(),
 822                window,
 823                cx,
 824            )
 825        {
 826            task.detach();
 827            return;
 828        }
 829
 830        // Fall through to default editor paste
 831        cx.propagate();
 832    }
 833
 834    fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
 835        let editor = self.editor.clone();
 836        window.defer(cx, move |window, cx| {
 837            editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
 838        });
 839    }
 840
 841    pub fn insert_dragged_files(
 842        &mut self,
 843        paths: Vec<project::ProjectPath>,
 844        added_worktrees: Vec<Entity<Worktree>>,
 845        window: &mut Window,
 846        cx: &mut Context<Self>,
 847    ) {
 848        let Some(workspace) = self.workspace.upgrade() else {
 849            return;
 850        };
 851        let project = workspace.read(cx).project().clone();
 852        let path_style = project.read(cx).path_style(cx);
 853        let buffer = self.editor.read(cx).buffer().clone();
 854        let Some(buffer) = buffer.read(cx).as_singleton() else {
 855            return;
 856        };
 857        let mut tasks = Vec::new();
 858        for path in paths {
 859            let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
 860                continue;
 861            };
 862            let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
 863                continue;
 864            };
 865            let abs_path = worktree.read(cx).absolutize(&path.path);
 866            let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
 867                &path.path,
 868                worktree.read(cx).root_name(),
 869                path_style,
 870            );
 871
 872            let uri = if entry.is_dir() {
 873                MentionUri::Directory { abs_path }
 874            } else {
 875                MentionUri::File { abs_path }
 876            };
 877
 878            let new_text = format!("{} ", uri.as_link());
 879            let content_len = new_text.len() - 1;
 880
 881            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
 882
 883            self.editor.update(cx, |message_editor, cx| {
 884                message_editor.edit(
 885                    [(
 886                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
 887                        new_text,
 888                    )],
 889                    cx,
 890                );
 891            });
 892            let supports_images = self.prompt_capabilities.borrow().image;
 893            tasks.push(self.mention_set.update(cx, |mention_set, cx| {
 894                mention_set.confirm_mention_completion(
 895                    file_name,
 896                    anchor,
 897                    content_len,
 898                    uri,
 899                    supports_images,
 900                    self.editor.clone(),
 901                    &workspace,
 902                    window,
 903                    cx,
 904                )
 905            }));
 906        }
 907        cx.spawn(async move |_, _| {
 908            join_all(tasks).await;
 909            drop(added_worktrees);
 910        })
 911        .detach();
 912    }
 913
 914    /// Inserts code snippets as creases into the editor.
 915    /// Each tuple contains (code_text, crease_title).
 916    pub fn insert_code_creases(
 917        &mut self,
 918        creases: Vec<(String, String)>,
 919        window: &mut Window,
 920        cx: &mut Context<Self>,
 921    ) {
 922        self.editor.update(cx, |editor, cx| {
 923            editor.insert("\n", window, cx);
 924        });
 925        for (text, crease_title) in creases {
 926            self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
 927        }
 928    }
 929
 930    pub fn insert_terminal_crease(
 931        &mut self,
 932        text: String,
 933        window: &mut Window,
 934        cx: &mut Context<Self>,
 935    ) {
 936        let line_count = text.lines().count() as u32;
 937        let mention_uri = MentionUri::TerminalSelection { line_count };
 938        let mention_text = mention_uri.as_link().to_string();
 939
 940        let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
 941            let buffer = editor.buffer().read(cx);
 942            let snapshot = buffer.snapshot(cx);
 943            let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
 944            let text_anchor = editor
 945                .selections
 946                .newest_anchor()
 947                .start
 948                .text_anchor
 949                .bias_left(&buffer_snapshot);
 950
 951            editor.insert(&mention_text, window, cx);
 952            editor.insert(" ", window, cx);
 953
 954            (excerpt_id, text_anchor, mention_text.len())
 955        });
 956
 957        let Some((crease_id, tx)) = insert_crease_for_mention(
 958            excerpt_id,
 959            text_anchor,
 960            content_len,
 961            mention_uri.name().into(),
 962            mention_uri.icon_path(cx),
 963            None,
 964            self.editor.clone(),
 965            window,
 966            cx,
 967        ) else {
 968            return;
 969        };
 970        drop(tx);
 971
 972        let mention_task = Task::ready(Ok(Mention::Text {
 973            content: text,
 974            tracked_buffers: vec![],
 975        }))
 976        .shared();
 977
 978        self.mention_set.update(cx, |mention_set, _| {
 979            mention_set.insert_mention(crease_id, mention_uri, mention_task);
 980        });
 981    }
 982
 983    fn insert_crease_impl(
 984        &mut self,
 985        text: String,
 986        title: String,
 987        icon: IconName,
 988        add_trailing_newline: bool,
 989        window: &mut Window,
 990        cx: &mut Context<Self>,
 991    ) {
 992        use editor::display_map::{Crease, FoldPlaceholder};
 993        use multi_buffer::MultiBufferRow;
 994        use rope::Point;
 995
 996        self.editor.update(cx, |editor, cx| {
 997            let point = editor
 998                .selections
 999                .newest::<Point>(&editor.display_snapshot(cx))
1000                .head();
1001            let start_row = MultiBufferRow(point.row);
1002
1003            editor.insert(&text, window, cx);
1004
1005            let snapshot = editor.buffer().read(cx).snapshot(cx);
1006            let anchor_before = snapshot.anchor_after(point);
1007            let anchor_after = editor
1008                .selections
1009                .newest_anchor()
1010                .head()
1011                .bias_left(&snapshot);
1012
1013            if add_trailing_newline {
1014                editor.insert("\n", window, cx);
1015            }
1016
1017            let fold_placeholder = FoldPlaceholder {
1018                render: Arc::new({
1019                    let title = title.clone();
1020                    move |_fold_id, _fold_range, _cx| {
1021                        ButtonLike::new("crease")
1022                            .style(ButtonStyle::Filled)
1023                            .layer(ElevationIndex::ElevatedSurface)
1024                            .child(Icon::new(icon))
1025                            .child(Label::new(title.clone()).single_line())
1026                            .into_any_element()
1027                    }
1028                }),
1029                merge_adjacent: false,
1030                ..Default::default()
1031            };
1032
1033            let crease = Crease::inline(
1034                anchor_before..anchor_after,
1035                fold_placeholder,
1036                |row, is_folded, fold, _window, _cx| {
1037                    Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1038                        .toggle_state(is_folded)
1039                        .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1040                        .into_any_element()
1041                },
1042                |_, _, _, _| gpui::Empty.into_any(),
1043            );
1044            editor.insert_creases(vec![crease], cx);
1045            editor.fold_at(start_row, window, cx);
1046        });
1047    }
1048
1049    pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1050        let editor = self.editor.read(cx);
1051        let editor_buffer = editor.buffer().read(cx);
1052        let Some(buffer) = editor_buffer.as_singleton() else {
1053            return;
1054        };
1055        let cursor_anchor = editor.selections.newest_anchor().head();
1056        let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1057        let anchor = buffer.update(cx, |buffer, _cx| {
1058            buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1059        });
1060        let Some(workspace) = self.workspace.upgrade() else {
1061            return;
1062        };
1063        let Some(completion) =
1064            PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
1065                PromptContextAction::AddSelections,
1066                anchor..anchor,
1067                self.editor.downgrade(),
1068                self.mention_set.downgrade(),
1069                &workspace,
1070                cx,
1071            )
1072        else {
1073            return;
1074        };
1075
1076        self.editor.update(cx, |message_editor, cx| {
1077            message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1078            message_editor.request_autoscroll(Autoscroll::fit(), cx);
1079        });
1080        if let Some(confirm) = completion.confirm {
1081            confirm(CompletionIntent::Complete, window, cx);
1082        }
1083    }
1084
1085    pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1086        if !self.prompt_capabilities.borrow().image {
1087            return;
1088        }
1089
1090        let editor = self.editor.clone();
1091        let mention_set = self.mention_set.clone();
1092        let workspace = self.workspace.clone();
1093
1094        let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1095            files: true,
1096            directories: false,
1097            multiple: true,
1098            prompt: Some("Select Images".into()),
1099        });
1100
1101        window
1102            .spawn(cx, async move |cx| {
1103                let paths = match paths_receiver.await {
1104                    Ok(Ok(Some(paths))) => paths,
1105                    _ => return Ok::<(), anyhow::Error>(()),
1106                };
1107
1108                let supported_formats = [
1109                    ("png", gpui::ImageFormat::Png),
1110                    ("jpg", gpui::ImageFormat::Jpeg),
1111                    ("jpeg", gpui::ImageFormat::Jpeg),
1112                    ("webp", gpui::ImageFormat::Webp),
1113                    ("gif", gpui::ImageFormat::Gif),
1114                    ("bmp", gpui::ImageFormat::Bmp),
1115                    ("tiff", gpui::ImageFormat::Tiff),
1116                    ("tif", gpui::ImageFormat::Tiff),
1117                    ("ico", gpui::ImageFormat::Ico),
1118                ];
1119
1120                let mut images = Vec::new();
1121                for path in paths {
1122                    let extension = path
1123                        .extension()
1124                        .and_then(|ext| ext.to_str())
1125                        .map(|s| s.to_lowercase());
1126
1127                    let Some(format) = extension.and_then(|ext| {
1128                        supported_formats
1129                            .iter()
1130                            .find(|(e, _)| *e == ext)
1131                            .map(|(_, f)| *f)
1132                    }) else {
1133                        continue;
1134                    };
1135
1136                    let Ok(content) = async_fs::read(&path).await else {
1137                        continue;
1138                    };
1139
1140                    images.push(gpui::Image::from_bytes(format, content));
1141                }
1142
1143                crate::mention_set::insert_images_as_context(
1144                    images,
1145                    editor,
1146                    mention_set,
1147                    workspace,
1148                    cx,
1149                )
1150                .await;
1151                Ok(())
1152            })
1153            .detach_and_log_err(cx);
1154    }
1155
1156    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1157        self.editor.update(cx, |message_editor, cx| {
1158            message_editor.set_read_only(read_only);
1159            cx.notify()
1160        })
1161    }
1162
1163    pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1164        self.editor.update(cx, |editor, cx| {
1165            editor.set_mode(mode);
1166            cx.notify()
1167        });
1168    }
1169
1170    pub fn set_message(
1171        &mut self,
1172        message: Vec<acp::ContentBlock>,
1173        window: &mut Window,
1174        cx: &mut Context<Self>,
1175    ) {
1176        let Some(workspace) = self.workspace.upgrade() else {
1177            return;
1178        };
1179
1180        self.clear(window, cx);
1181
1182        let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1183        let mut text = String::new();
1184        let mut mentions = Vec::new();
1185
1186        for chunk in message {
1187            match chunk {
1188                acp::ContentBlock::Text(text_content) => {
1189                    text.push_str(&text_content.text);
1190                }
1191                acp::ContentBlock::Resource(acp::EmbeddedResource {
1192                    resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1193                    ..
1194                }) => {
1195                    let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1196                    else {
1197                        continue;
1198                    };
1199                    let start = text.len();
1200                    write!(&mut text, "{}", mention_uri.as_link()).ok();
1201                    let end = text.len();
1202                    mentions.push((
1203                        start..end,
1204                        mention_uri,
1205                        Mention::Text {
1206                            content: resource.text,
1207                            tracked_buffers: Vec::new(),
1208                        },
1209                    ));
1210                }
1211                acp::ContentBlock::ResourceLink(resource) => {
1212                    if let Some(mention_uri) =
1213                        MentionUri::parse(&resource.uri, path_style).log_err()
1214                    {
1215                        let start = text.len();
1216                        write!(&mut text, "{}", mention_uri.as_link()).ok();
1217                        let end = text.len();
1218                        mentions.push((start..end, mention_uri, Mention::Link));
1219                    }
1220                }
1221                acp::ContentBlock::Image(acp::ImageContent {
1222                    uri,
1223                    data,
1224                    mime_type,
1225                    ..
1226                }) => {
1227                    let mention_uri = if let Some(uri) = uri {
1228                        MentionUri::parse(&uri, path_style)
1229                    } else {
1230                        Ok(MentionUri::PastedImage)
1231                    };
1232                    let Some(mention_uri) = mention_uri.log_err() else {
1233                        continue;
1234                    };
1235                    let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1236                        log::error!("failed to parse MIME type for image: {mime_type:?}");
1237                        continue;
1238                    };
1239                    let start = text.len();
1240                    write!(&mut text, "{}", mention_uri.as_link()).ok();
1241                    let end = text.len();
1242                    mentions.push((
1243                        start..end,
1244                        mention_uri,
1245                        Mention::Image(MentionImage {
1246                            data: data.into(),
1247                            format,
1248                        }),
1249                    ));
1250                }
1251                _ => {}
1252            }
1253        }
1254
1255        let snapshot = self.editor.update(cx, |editor, cx| {
1256            editor.set_text(text, window, cx);
1257            editor.buffer().read(cx).snapshot(cx)
1258        });
1259
1260        for (range, mention_uri, mention) in mentions {
1261            let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
1262            let Some((crease_id, tx)) = insert_crease_for_mention(
1263                anchor.excerpt_id,
1264                anchor.text_anchor,
1265                range.end - range.start,
1266                mention_uri.name().into(),
1267                mention_uri.icon_path(cx),
1268                None,
1269                self.editor.clone(),
1270                window,
1271                cx,
1272            ) else {
1273                continue;
1274            };
1275            drop(tx);
1276
1277            self.mention_set.update(cx, |mention_set, _cx| {
1278                mention_set.insert_mention(
1279                    crease_id,
1280                    mention_uri.clone(),
1281                    Task::ready(Ok(mention)).shared(),
1282                )
1283            });
1284        }
1285        cx.notify();
1286    }
1287
1288    pub fn text(&self, cx: &App) -> String {
1289        self.editor.read(cx).text(cx)
1290    }
1291
1292    pub fn set_placeholder_text(
1293        &mut self,
1294        placeholder: &str,
1295        window: &mut Window,
1296        cx: &mut Context<Self>,
1297    ) {
1298        self.editor.update(cx, |editor, cx| {
1299            editor.set_placeholder_text(placeholder, window, cx);
1300        });
1301    }
1302
1303    #[cfg(test)]
1304    pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1305        self.editor.update(cx, |editor, cx| {
1306            editor.set_text(text, window, cx);
1307        });
1308    }
1309}
1310
1311impl Focusable for MessageEditor {
1312    fn focus_handle(&self, cx: &App) -> FocusHandle {
1313        self.editor.focus_handle(cx)
1314    }
1315}
1316
1317impl Render for MessageEditor {
1318    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1319        div()
1320            .key_context("MessageEditor")
1321            .on_action(cx.listener(Self::chat))
1322            .on_action(cx.listener(Self::send_immediately))
1323            .on_action(cx.listener(Self::chat_with_follow))
1324            .on_action(cx.listener(Self::cancel))
1325            .on_action(cx.listener(Self::paste_raw))
1326            .capture_action(cx.listener(Self::paste))
1327            .flex_1()
1328            .child({
1329                let settings = ThemeSettings::get_global(cx);
1330
1331                let text_style = TextStyle {
1332                    color: cx.theme().colors().text,
1333                    font_family: settings.buffer_font.family.clone(),
1334                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
1335                    font_features: settings.buffer_font.features.clone(),
1336                    font_size: settings.agent_buffer_font_size(cx).into(),
1337                    font_weight: settings.buffer_font.weight,
1338                    line_height: relative(settings.buffer_line_height.value()),
1339                    ..Default::default()
1340                };
1341
1342                EditorElement::new(
1343                    &self.editor,
1344                    EditorStyle {
1345                        background: cx.theme().colors().editor_background,
1346                        local_player: cx.theme().players().local(),
1347                        text: text_style,
1348                        syntax: cx.theme().syntax().clone(),
1349                        inlay_hints_style: editor::make_inlay_hints_style(cx),
1350                        ..Default::default()
1351                    },
1352                )
1353            })
1354    }
1355}
1356
1357pub struct MessageEditorAddon {}
1358
1359impl MessageEditorAddon {
1360    pub fn new() -> Self {
1361        Self {}
1362    }
1363}
1364
1365impl Addon for MessageEditorAddon {
1366    fn to_any(&self) -> &dyn std::any::Any {
1367        self
1368    }
1369
1370    fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1371        Some(self)
1372    }
1373
1374    fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1375        let settings = agent_settings::AgentSettings::get_global(cx);
1376        if settings.use_modifier_to_send {
1377            key_context.add("use_modifier_to_send");
1378        }
1379    }
1380}
1381
1382/// Parses markdown mention links in the format `[@name](uri)` from text.
1383/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1384fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1385    let mut mentions = Vec::new();
1386    let mut search_start = 0;
1387
1388    while let Some(link_start) = text[search_start..].find("[@") {
1389        let absolute_start = search_start + link_start;
1390
1391        // Find the matching closing bracket for the name, handling nested brackets.
1392        // Start at the '[' character so find_matching_bracket can track depth correctly.
1393        let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1394            search_start = absolute_start + 2;
1395            continue;
1396        };
1397        let name_end = absolute_start + name_end;
1398
1399        // Check for opening parenthesis immediately after
1400        if text.get(name_end + 1..name_end + 2) != Some("(") {
1401            search_start = name_end + 1;
1402            continue;
1403        }
1404
1405        // Find the matching closing parenthesis for the URI, handling nested parens
1406        let uri_start = name_end + 2;
1407        let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1408            search_start = uri_start;
1409            continue;
1410        };
1411        let uri_end = name_end + 1 + uri_end_relative;
1412        let link_end = uri_end + 1;
1413
1414        let uri_str = &text[uri_start..uri_end];
1415
1416        // Try to parse the URI as a MentionUri
1417        if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1418            mentions.push((absolute_start..link_end, mention_uri));
1419        }
1420
1421        search_start = link_end;
1422    }
1423
1424    mentions
1425}
1426
1427/// Finds the position of the matching closing bracket, handling nested brackets.
1428/// The input `text` should start with the opening bracket.
1429/// Returns the index of the matching closing bracket relative to `text`.
1430fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1431    let mut depth = 0;
1432    for (index, character) in text.char_indices() {
1433        if character == open {
1434            depth += 1;
1435        } else if character == close {
1436            depth -= 1;
1437            if depth == 0 {
1438                return Some(index);
1439            }
1440        }
1441    }
1442    None
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1448
1449    use acp_thread::{AgentSessionInfo, MentionUri};
1450    use agent::{ThreadStore, outline};
1451    use agent_client_protocol as acp;
1452    use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1453
1454    use fs::FakeFs;
1455    use futures::StreamExt as _;
1456    use gpui::{
1457        AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1458    };
1459    use language_model::LanguageModelRegistry;
1460    use lsp::{CompletionContext, CompletionTriggerKind};
1461    use project::{CompletionIntent, Project, ProjectPath};
1462    use serde_json::json;
1463
1464    use text::Point;
1465    use ui::{App, Context, IntoElement, Render, SharedString, Window};
1466    use util::{path, paths::PathStyle, rel_path::rel_path};
1467    use workspace::{AppState, Item, MultiWorkspace};
1468
1469    use crate::acp::{
1470        message_editor::{Mention, MessageEditor, parse_mention_links},
1471        thread_view::tests::init_test,
1472    };
1473    use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1474
1475    #[test]
1476    fn test_parse_mention_links() {
1477        // Single file mention
1478        let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1479        let mentions = parse_mention_links(text, PathStyle::local());
1480        assert_eq!(mentions.len(), 1);
1481        assert_eq!(mentions[0].0, 0..text.len());
1482        assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1483
1484        // Multiple mentions
1485        let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1486        let mentions = parse_mention_links(text, PathStyle::local());
1487        assert_eq!(mentions.len(), 2);
1488
1489        // Text without mentions
1490        let text = "Just some regular text without mentions";
1491        let mentions = parse_mention_links(text, PathStyle::local());
1492        assert_eq!(mentions.len(), 0);
1493
1494        // Malformed mentions (should be skipped)
1495        let text = "[@incomplete](invalid://uri) and [@missing](";
1496        let mentions = parse_mention_links(text, PathStyle::local());
1497        assert_eq!(mentions.len(), 0);
1498
1499        // Mixed content with valid mention
1500        let text = "Before [@valid](file:///path/to/file) after";
1501        let mentions = parse_mention_links(text, PathStyle::local());
1502        assert_eq!(mentions.len(), 1);
1503        assert_eq!(mentions[0].0.start, 7);
1504
1505        // HTTP URL mention (Fetch)
1506        let text = "Check out [@docs](https://example.com/docs) for more info";
1507        let mentions = parse_mention_links(text, PathStyle::local());
1508        assert_eq!(mentions.len(), 1);
1509        assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1510
1511        // Directory mention (trailing slash)
1512        let text = "[@src](file:///path/to/src/)";
1513        let mentions = parse_mention_links(text, PathStyle::local());
1514        assert_eq!(mentions.len(), 1);
1515        assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1516
1517        // Multiple different mention types
1518        let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1519        let mentions = parse_mention_links(text, PathStyle::local());
1520        assert_eq!(mentions.len(), 3);
1521        assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1522        assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1523        assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1524
1525        // Adjacent mentions without separator
1526        let text = "[@a](file:///a)[@b](file:///b)";
1527        let mentions = parse_mention_links(text, PathStyle::local());
1528        assert_eq!(mentions.len(), 2);
1529
1530        // Regular markdown link (not a mention) should be ignored
1531        let text = "[regular link](https://example.com)";
1532        let mentions = parse_mention_links(text, PathStyle::local());
1533        assert_eq!(mentions.len(), 0);
1534
1535        // Incomplete mention link patterns
1536        let text = "[@name] without url and [@name( malformed";
1537        let mentions = parse_mention_links(text, PathStyle::local());
1538        assert_eq!(mentions.len(), 0);
1539
1540        // Nested brackets in name portion
1541        let text = "[@name [with brackets]](file:///path/to/file)";
1542        let mentions = parse_mention_links(text, PathStyle::local());
1543        assert_eq!(mentions.len(), 1);
1544        assert_eq!(mentions[0].0, 0..text.len());
1545
1546        // Deeply nested brackets
1547        let text = "[@outer [inner [deep]]](file:///path)";
1548        let mentions = parse_mention_links(text, PathStyle::local());
1549        assert_eq!(mentions.len(), 1);
1550
1551        // Unbalanced brackets should fail gracefully
1552        let text = "[@unbalanced [bracket](file:///path)";
1553        let mentions = parse_mention_links(text, PathStyle::local());
1554        assert_eq!(mentions.len(), 0);
1555
1556        // Nested parentheses in URI (common in URLs with query params)
1557        let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
1558        let mentions = parse_mention_links(text, PathStyle::local());
1559        assert_eq!(mentions.len(), 1);
1560        if let MentionUri::Fetch { url } = &mentions[0].1 {
1561            assert!(url.as_str().contains("Rust_(programming_language)"));
1562        } else {
1563            panic!("Expected Fetch URI");
1564        }
1565    }
1566
1567    #[gpui::test]
1568    async fn test_at_mention_removal(cx: &mut TestAppContext) {
1569        init_test(cx);
1570
1571        let fs = FakeFs::new(cx.executor());
1572        fs.insert_tree("/project", json!({"file": ""})).await;
1573        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1574
1575        let (multi_workspace, cx) =
1576            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1577        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1578
1579        let thread_store = None;
1580        let history = cx
1581            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1582
1583        let message_editor = cx.update(|window, cx| {
1584            cx.new(|cx| {
1585                MessageEditor::new(
1586                    workspace.downgrade(),
1587                    project.downgrade(),
1588                    thread_store.clone(),
1589                    history.downgrade(),
1590                    None,
1591                    Default::default(),
1592                    Default::default(),
1593                    "Test Agent".into(),
1594                    "Test",
1595                    EditorMode::AutoHeight {
1596                        min_lines: 1,
1597                        max_lines: None,
1598                    },
1599                    window,
1600                    cx,
1601                )
1602            })
1603        });
1604        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1605
1606        cx.run_until_parked();
1607
1608        let excerpt_id = editor.update(cx, |editor, cx| {
1609            editor
1610                .buffer()
1611                .read(cx)
1612                .excerpt_ids()
1613                .into_iter()
1614                .next()
1615                .unwrap()
1616        });
1617        let completions = editor.update_in(cx, |editor, window, cx| {
1618            editor.set_text("Hello @file ", window, cx);
1619            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1620            let completion_provider = editor.completion_provider().unwrap();
1621            completion_provider.completions(
1622                excerpt_id,
1623                &buffer,
1624                text::Anchor::MAX,
1625                CompletionContext {
1626                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1627                    trigger_character: Some("@".into()),
1628                },
1629                window,
1630                cx,
1631            )
1632        });
1633        let [_, completion]: [_; 2] = completions
1634            .await
1635            .unwrap()
1636            .into_iter()
1637            .flat_map(|response| response.completions)
1638            .collect::<Vec<_>>()
1639            .try_into()
1640            .unwrap();
1641
1642        editor.update_in(cx, |editor, window, cx| {
1643            let snapshot = editor.buffer().read(cx).snapshot(cx);
1644            let range = snapshot
1645                .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1646                .unwrap();
1647            editor.edit([(range, completion.new_text)], cx);
1648            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1649        });
1650
1651        cx.run_until_parked();
1652
1653        // Backspace over the inserted crease (and the following space).
1654        editor.update_in(cx, |editor, window, cx| {
1655            editor.backspace(&Default::default(), window, cx);
1656            editor.backspace(&Default::default(), window, cx);
1657        });
1658
1659        let (content, _) = message_editor
1660            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1661            .await
1662            .unwrap();
1663
1664        // We don't send a resource link for the deleted crease.
1665        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1666    }
1667
1668    #[gpui::test]
1669    async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1670        init_test(cx);
1671        let fs = FakeFs::new(cx.executor());
1672        fs.insert_tree(
1673            "/test",
1674            json!({
1675                ".zed": {
1676                    "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1677                },
1678                "src": {
1679                    "main.rs": "fn main() {}",
1680                },
1681            }),
1682        )
1683        .await;
1684
1685        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1686        let thread_store = None;
1687        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1688        // Start with no available commands - simulating Claude which doesn't support slash commands
1689        let available_commands = Rc::new(RefCell::new(vec![]));
1690
1691        let (multi_workspace, cx) =
1692            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1693        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1694        let history = cx
1695            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1696        let workspace_handle = workspace.downgrade();
1697        let message_editor = workspace.update_in(cx, |_, window, cx| {
1698            cx.new(|cx| {
1699                MessageEditor::new(
1700                    workspace_handle.clone(),
1701                    project.downgrade(),
1702                    thread_store.clone(),
1703                    history.downgrade(),
1704                    None,
1705                    prompt_capabilities.clone(),
1706                    available_commands.clone(),
1707                    "Claude Agent".into(),
1708                    "Test",
1709                    EditorMode::AutoHeight {
1710                        min_lines: 1,
1711                        max_lines: None,
1712                    },
1713                    window,
1714                    cx,
1715                )
1716            })
1717        });
1718        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1719
1720        // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1721        editor.update_in(cx, |editor, window, cx| {
1722            editor.set_text("/file test.txt", window, cx);
1723        });
1724
1725        let contents_result = message_editor
1726            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1727            .await;
1728
1729        // Should fail because available_commands is empty (no commands supported)
1730        assert!(contents_result.is_err());
1731        let error_message = contents_result.unwrap_err().to_string();
1732        assert!(error_message.contains("not supported by Claude Agent"));
1733        assert!(error_message.contains("Available commands: none"));
1734
1735        // Now simulate Claude providing its list of available commands (which doesn't include file)
1736        available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1737
1738        // Test that unsupported slash commands trigger an error when we have a list of available commands
1739        editor.update_in(cx, |editor, window, cx| {
1740            editor.set_text("/file test.txt", window, cx);
1741        });
1742
1743        let contents_result = message_editor
1744            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1745            .await;
1746
1747        assert!(contents_result.is_err());
1748        let error_message = contents_result.unwrap_err().to_string();
1749        assert!(error_message.contains("not supported by Claude Agent"));
1750        assert!(error_message.contains("/file"));
1751        assert!(error_message.contains("Available commands: /help"));
1752
1753        // Test that supported commands work fine
1754        editor.update_in(cx, |editor, window, cx| {
1755            editor.set_text("/help", window, cx);
1756        });
1757
1758        let contents_result = message_editor
1759            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1760            .await;
1761
1762        // Should succeed because /help is in available_commands
1763        assert!(contents_result.is_ok());
1764
1765        // Test that regular text works fine
1766        editor.update_in(cx, |editor, window, cx| {
1767            editor.set_text("Hello Claude!", window, cx);
1768        });
1769
1770        let (content, _) = message_editor
1771            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1772            .await
1773            .unwrap();
1774
1775        assert_eq!(content.len(), 1);
1776        if let acp::ContentBlock::Text(text) = &content[0] {
1777            assert_eq!(text.text, "Hello Claude!");
1778        } else {
1779            panic!("Expected ContentBlock::Text");
1780        }
1781
1782        // Test that @ mentions still work
1783        editor.update_in(cx, |editor, window, cx| {
1784            editor.set_text("Check this @", window, cx);
1785        });
1786
1787        // The @ mention functionality should not be affected
1788        let (content, _) = message_editor
1789            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1790            .await
1791            .unwrap();
1792
1793        assert_eq!(content.len(), 1);
1794        if let acp::ContentBlock::Text(text) = &content[0] {
1795            assert_eq!(text.text, "Check this @");
1796        } else {
1797            panic!("Expected ContentBlock::Text");
1798        }
1799    }
1800
1801    struct MessageEditorItem(Entity<MessageEditor>);
1802
1803    impl Item for MessageEditorItem {
1804        type Event = ();
1805
1806        fn include_in_nav_history() -> bool {
1807            false
1808        }
1809
1810        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1811            "Test".into()
1812        }
1813    }
1814
1815    impl EventEmitter<()> for MessageEditorItem {}
1816
1817    impl Focusable for MessageEditorItem {
1818        fn focus_handle(&self, cx: &App) -> FocusHandle {
1819            self.0.read(cx).focus_handle(cx)
1820        }
1821    }
1822
1823    impl Render for MessageEditorItem {
1824        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1825            self.0.clone().into_any_element()
1826        }
1827    }
1828
1829    #[gpui::test]
1830    async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1831        init_test(cx);
1832
1833        let app_state = cx.update(AppState::test);
1834
1835        cx.update(|cx| {
1836            editor::init(cx);
1837            workspace::init(app_state.clone(), cx);
1838        });
1839
1840        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1841        let window =
1842            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1843        let workspace = window
1844            .read_with(cx, |mw, _| mw.workspace().clone())
1845            .unwrap();
1846
1847        let mut cx = VisualTestContext::from_window(window.into(), cx);
1848
1849        let thread_store = None;
1850        let history = cx
1851            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1852        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1853        let available_commands = Rc::new(RefCell::new(vec![
1854            acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1855            acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1856                acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1857                    "<name>",
1858                )),
1859            ),
1860        ]));
1861
1862        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1863            let workspace_handle = cx.weak_entity();
1864            let message_editor = cx.new(|cx| {
1865                MessageEditor::new(
1866                    workspace_handle,
1867                    project.downgrade(),
1868                    thread_store.clone(),
1869                    history.downgrade(),
1870                    None,
1871                    prompt_capabilities.clone(),
1872                    available_commands.clone(),
1873                    "Test Agent".into(),
1874                    "Test",
1875                    EditorMode::AutoHeight {
1876                        max_lines: None,
1877                        min_lines: 1,
1878                    },
1879                    window,
1880                    cx,
1881                )
1882            });
1883            workspace.active_pane().update(cx, |pane, cx| {
1884                pane.add_item(
1885                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1886                    true,
1887                    true,
1888                    None,
1889                    window,
1890                    cx,
1891                );
1892            });
1893            message_editor.read(cx).focus_handle(cx).focus(window, cx);
1894            message_editor.read(cx).editor().clone()
1895        });
1896
1897        cx.simulate_input("/");
1898
1899        editor.update_in(&mut cx, |editor, window, cx| {
1900            assert_eq!(editor.text(cx), "/");
1901            assert!(editor.has_visible_completions_menu());
1902
1903            assert_eq!(
1904                current_completion_labels_with_documentation(editor),
1905                &[
1906                    ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1907                    ("say-hello".into(), "Say hello to whoever you want".into())
1908                ]
1909            );
1910            editor.set_text("", window, cx);
1911        });
1912
1913        cx.simulate_input("/qui");
1914
1915        editor.update_in(&mut cx, |editor, window, cx| {
1916            assert_eq!(editor.text(cx), "/qui");
1917            assert!(editor.has_visible_completions_menu());
1918
1919            assert_eq!(
1920                current_completion_labels_with_documentation(editor),
1921                &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1922            );
1923            editor.set_text("", window, cx);
1924        });
1925
1926        editor.update_in(&mut cx, |editor, window, cx| {
1927            assert!(editor.has_visible_completions_menu());
1928            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1929        });
1930
1931        cx.run_until_parked();
1932
1933        editor.update_in(&mut cx, |editor, window, cx| {
1934            assert_eq!(editor.display_text(cx), "/quick-math ");
1935            assert!(!editor.has_visible_completions_menu());
1936            editor.set_text("", window, cx);
1937        });
1938
1939        cx.simulate_input("/say");
1940
1941        editor.update_in(&mut cx, |editor, _window, cx| {
1942            assert_eq!(editor.display_text(cx), "/say");
1943            assert!(editor.has_visible_completions_menu());
1944
1945            assert_eq!(
1946                current_completion_labels_with_documentation(editor),
1947                &[("say-hello".into(), "Say hello to whoever you want".into())]
1948            );
1949        });
1950
1951        editor.update_in(&mut cx, |editor, window, cx| {
1952            assert!(editor.has_visible_completions_menu());
1953            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1954        });
1955
1956        cx.run_until_parked();
1957
1958        editor.update_in(&mut cx, |editor, _window, cx| {
1959            assert_eq!(editor.text(cx), "/say-hello ");
1960            assert_eq!(editor.display_text(cx), "/say-hello <name>");
1961            assert!(!editor.has_visible_completions_menu());
1962        });
1963
1964        cx.simulate_input("GPT5");
1965
1966        cx.run_until_parked();
1967
1968        editor.update_in(&mut cx, |editor, window, cx| {
1969            assert_eq!(editor.text(cx), "/say-hello GPT5");
1970            assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1971            assert!(!editor.has_visible_completions_menu());
1972
1973            // Delete argument
1974            for _ in 0..5 {
1975                editor.backspace(&editor::actions::Backspace, window, cx);
1976            }
1977        });
1978
1979        cx.run_until_parked();
1980
1981        editor.update_in(&mut cx, |editor, window, cx| {
1982            assert_eq!(editor.text(cx), "/say-hello");
1983            // Hint is visible because argument was deleted
1984            assert_eq!(editor.display_text(cx), "/say-hello <name>");
1985
1986            // Delete last command letter
1987            editor.backspace(&editor::actions::Backspace, window, cx);
1988        });
1989
1990        cx.run_until_parked();
1991
1992        editor.update_in(&mut cx, |editor, _window, cx| {
1993            // Hint goes away once command no longer matches an available one
1994            assert_eq!(editor.text(cx), "/say-hell");
1995            assert_eq!(editor.display_text(cx), "/say-hell");
1996            assert!(!editor.has_visible_completions_menu());
1997        });
1998    }
1999
2000    #[gpui::test]
2001    async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2002        init_test(cx);
2003
2004        let app_state = cx.update(AppState::test);
2005
2006        cx.update(|cx| {
2007            editor::init(cx);
2008            workspace::init(app_state.clone(), cx);
2009        });
2010
2011        app_state
2012            .fs
2013            .as_fake()
2014            .insert_tree(
2015                path!("/dir"),
2016                json!({
2017                    "editor": "",
2018                    "a": {
2019                        "one.txt": "1",
2020                        "two.txt": "2",
2021                        "three.txt": "3",
2022                        "four.txt": "4"
2023                    },
2024                    "b": {
2025                        "five.txt": "5",
2026                        "six.txt": "6",
2027                        "seven.txt": "7",
2028                        "eight.txt": "8",
2029                    },
2030                    "x.png": "",
2031                }),
2032            )
2033            .await;
2034
2035        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2036        let window =
2037            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2038        let workspace = window
2039            .read_with(cx, |mw, _| mw.workspace().clone())
2040            .unwrap();
2041
2042        let worktree = project.update(cx, |project, cx| {
2043            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2044            assert_eq!(worktrees.len(), 1);
2045            worktrees.pop().unwrap()
2046        });
2047        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2048
2049        let mut cx = VisualTestContext::from_window(window.into(), cx);
2050
2051        let paths = vec![
2052            rel_path("a/one.txt"),
2053            rel_path("a/two.txt"),
2054            rel_path("a/three.txt"),
2055            rel_path("a/four.txt"),
2056            rel_path("b/five.txt"),
2057            rel_path("b/six.txt"),
2058            rel_path("b/seven.txt"),
2059            rel_path("b/eight.txt"),
2060        ];
2061
2062        let slash = PathStyle::local().primary_separator();
2063
2064        let mut opened_editors = Vec::new();
2065        for path in paths {
2066            let buffer = workspace
2067                .update_in(&mut cx, |workspace, window, cx| {
2068                    workspace.open_path(
2069                        ProjectPath {
2070                            worktree_id,
2071                            path: path.into(),
2072                        },
2073                        None,
2074                        false,
2075                        window,
2076                        cx,
2077                    )
2078                })
2079                .await
2080                .unwrap();
2081            opened_editors.push(buffer);
2082        }
2083
2084        let thread_store = cx.new(|cx| ThreadStore::new(cx));
2085        let history = cx
2086            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2087        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2088
2089        let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2090            let workspace_handle = cx.weak_entity();
2091            let message_editor = cx.new(|cx| {
2092                MessageEditor::new(
2093                    workspace_handle,
2094                    project.downgrade(),
2095                    Some(thread_store),
2096                    history.downgrade(),
2097                    None,
2098                    prompt_capabilities.clone(),
2099                    Default::default(),
2100                    "Test Agent".into(),
2101                    "Test",
2102                    EditorMode::AutoHeight {
2103                        max_lines: None,
2104                        min_lines: 1,
2105                    },
2106                    window,
2107                    cx,
2108                )
2109            });
2110            workspace.active_pane().update(cx, |pane, cx| {
2111                pane.add_item(
2112                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2113                    true,
2114                    true,
2115                    None,
2116                    window,
2117                    cx,
2118                );
2119            });
2120            message_editor.read(cx).focus_handle(cx).focus(window, cx);
2121            let editor = message_editor.read(cx).editor().clone();
2122            (message_editor, editor)
2123        });
2124
2125        cx.simulate_input("Lorem @");
2126
2127        editor.update_in(&mut cx, |editor, window, cx| {
2128            assert_eq!(editor.text(cx), "Lorem @");
2129            assert!(editor.has_visible_completions_menu());
2130
2131            assert_eq!(
2132                current_completion_labels(editor),
2133                &[
2134                    format!("eight.txt b{slash}"),
2135                    format!("seven.txt b{slash}"),
2136                    format!("six.txt b{slash}"),
2137                    format!("five.txt b{slash}"),
2138                    "Files & Directories".into(),
2139                    "Symbols".into()
2140                ]
2141            );
2142            editor.set_text("", window, cx);
2143        });
2144
2145        prompt_capabilities.replace(
2146            acp::PromptCapabilities::new()
2147                .image(true)
2148                .audio(true)
2149                .embedded_context(true),
2150        );
2151
2152        cx.simulate_input("Lorem ");
2153
2154        editor.update(&mut cx, |editor, cx| {
2155            assert_eq!(editor.text(cx), "Lorem ");
2156            assert!(!editor.has_visible_completions_menu());
2157        });
2158
2159        cx.simulate_input("@");
2160
2161        editor.update(&mut cx, |editor, cx| {
2162            assert_eq!(editor.text(cx), "Lorem @");
2163            assert!(editor.has_visible_completions_menu());
2164            assert_eq!(
2165                current_completion_labels(editor),
2166                &[
2167                    format!("eight.txt b{slash}"),
2168                    format!("seven.txt b{slash}"),
2169                    format!("six.txt b{slash}"),
2170                    format!("five.txt b{slash}"),
2171                    "Files & Directories".into(),
2172                    "Symbols".into(),
2173                    "Threads".into(),
2174                    "Fetch".into()
2175                ]
2176            );
2177        });
2178
2179        // Select and confirm "File"
2180        editor.update_in(&mut cx, |editor, window, cx| {
2181            assert!(editor.has_visible_completions_menu());
2182            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2183            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2184            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2185            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2186            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2187        });
2188
2189        cx.run_until_parked();
2190
2191        editor.update(&mut cx, |editor, cx| {
2192            assert_eq!(editor.text(cx), "Lorem @file ");
2193            assert!(editor.has_visible_completions_menu());
2194        });
2195
2196        cx.simulate_input("one");
2197
2198        editor.update(&mut cx, |editor, cx| {
2199            assert_eq!(editor.text(cx), "Lorem @file one");
2200            assert!(editor.has_visible_completions_menu());
2201            assert_eq!(
2202                current_completion_labels(editor),
2203                vec![format!("one.txt a{slash}")]
2204            );
2205        });
2206
2207        editor.update_in(&mut cx, |editor, window, cx| {
2208            assert!(editor.has_visible_completions_menu());
2209            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2210        });
2211
2212        let url_one = MentionUri::File {
2213            abs_path: path!("/dir/a/one.txt").into(),
2214        }
2215        .to_uri()
2216        .to_string();
2217        editor.update(&mut cx, |editor, cx| {
2218            let text = editor.text(cx);
2219            assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2220            assert!(!editor.has_visible_completions_menu());
2221            assert_eq!(fold_ranges(editor, cx).len(), 1);
2222        });
2223
2224        let contents = message_editor
2225            .update(&mut cx, |message_editor, cx| {
2226                message_editor
2227                    .mention_set()
2228                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2229            })
2230            .await
2231            .unwrap()
2232            .into_values()
2233            .collect::<Vec<_>>();
2234
2235        {
2236            let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2237                panic!("Unexpected mentions");
2238            };
2239            pretty_assertions::assert_eq!(content, "1");
2240            pretty_assertions::assert_eq!(
2241                uri,
2242                &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2243            );
2244        }
2245
2246        cx.simulate_input(" ");
2247
2248        editor.update(&mut cx, |editor, cx| {
2249            let text = editor.text(cx);
2250            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  "));
2251            assert!(!editor.has_visible_completions_menu());
2252            assert_eq!(fold_ranges(editor, cx).len(), 1);
2253        });
2254
2255        cx.simulate_input("Ipsum ");
2256
2257        editor.update(&mut cx, |editor, cx| {
2258            let text = editor.text(cx);
2259            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum "),);
2260            assert!(!editor.has_visible_completions_menu());
2261            assert_eq!(fold_ranges(editor, cx).len(), 1);
2262        });
2263
2264        cx.simulate_input("@file ");
2265
2266        editor.update(&mut cx, |editor, cx| {
2267            let text = editor.text(cx);
2268            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum @file "),);
2269            assert!(editor.has_visible_completions_menu());
2270            assert_eq!(fold_ranges(editor, cx).len(), 1);
2271        });
2272
2273        editor.update_in(&mut cx, |editor, window, cx| {
2274            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2275        });
2276
2277        cx.run_until_parked();
2278
2279        let contents = message_editor
2280            .update(&mut cx, |message_editor, cx| {
2281                message_editor
2282                    .mention_set()
2283                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2284            })
2285            .await
2286            .unwrap()
2287            .into_values()
2288            .collect::<Vec<_>>();
2289
2290        let url_eight = MentionUri::File {
2291            abs_path: path!("/dir/b/eight.txt").into(),
2292        }
2293        .to_uri()
2294        .to_string();
2295
2296        {
2297            let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2298                panic!("Unexpected mentions");
2299            };
2300            pretty_assertions::assert_eq!(content, "8");
2301            pretty_assertions::assert_eq!(
2302                uri,
2303                &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2304            );
2305        }
2306
2307        editor.update(&mut cx, |editor, cx| {
2308            assert_eq!(
2309                editor.text(cx),
2310                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) ")
2311            );
2312            assert!(!editor.has_visible_completions_menu());
2313            assert_eq!(fold_ranges(editor, cx).len(), 2);
2314        });
2315
2316        let plain_text_language = Arc::new(language::Language::new(
2317            language::LanguageConfig {
2318                name: "Plain Text".into(),
2319                matcher: language::LanguageMatcher {
2320                    path_suffixes: vec!["txt".to_string()],
2321                    ..Default::default()
2322                },
2323                ..Default::default()
2324            },
2325            None,
2326        ));
2327
2328        // Register the language and fake LSP
2329        let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2330        language_registry.add(plain_text_language);
2331
2332        let mut fake_language_servers = language_registry.register_fake_lsp(
2333            "Plain Text",
2334            language::FakeLspAdapter {
2335                capabilities: lsp::ServerCapabilities {
2336                    workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2337                    ..Default::default()
2338                },
2339                ..Default::default()
2340            },
2341        );
2342
2343        // Open the buffer to trigger LSP initialization
2344        let buffer = project
2345            .update(&mut cx, |project, cx| {
2346                project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2347            })
2348            .await
2349            .unwrap();
2350
2351        // Register the buffer with language servers
2352        let _handle = project.update(&mut cx, |project, cx| {
2353            project.register_buffer_with_language_servers(&buffer, cx)
2354        });
2355
2356        cx.run_until_parked();
2357
2358        let fake_language_server = fake_language_servers.next().await.unwrap();
2359        fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2360            move |_, _| async move {
2361                Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2362                    #[allow(deprecated)]
2363                    lsp::SymbolInformation {
2364                        name: "MySymbol".into(),
2365                        location: lsp::Location {
2366                            uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2367                            range: lsp::Range::new(
2368                                lsp::Position::new(0, 0),
2369                                lsp::Position::new(0, 1),
2370                            ),
2371                        },
2372                        kind: lsp::SymbolKind::CONSTANT,
2373                        tags: None,
2374                        container_name: None,
2375                        deprecated: None,
2376                    },
2377                ])))
2378            },
2379        );
2380
2381        cx.simulate_input("@symbol ");
2382
2383        editor.update(&mut cx, |editor, cx| {
2384            assert_eq!(
2385                editor.text(cx),
2386                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) @symbol ")
2387            );
2388            assert!(editor.has_visible_completions_menu());
2389            assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2390        });
2391
2392        editor.update_in(&mut cx, |editor, window, cx| {
2393            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2394        });
2395
2396        let symbol = MentionUri::Symbol {
2397            abs_path: path!("/dir/a/one.txt").into(),
2398            name: "MySymbol".into(),
2399            line_range: 0..=0,
2400        };
2401
2402        let contents = message_editor
2403            .update(&mut cx, |message_editor, cx| {
2404                message_editor
2405                    .mention_set()
2406                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2407            })
2408            .await
2409            .unwrap()
2410            .into_values()
2411            .collect::<Vec<_>>();
2412
2413        {
2414            let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2415                panic!("Unexpected mentions");
2416            };
2417            pretty_assertions::assert_eq!(content, "1");
2418            pretty_assertions::assert_eq!(uri, &symbol);
2419        }
2420
2421        cx.run_until_parked();
2422
2423        editor.read_with(&cx, |editor, cx| {
2424            assert_eq!(
2425                editor.text(cx),
2426                format!(
2427                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2428                    symbol.to_uri(),
2429                )
2430            );
2431        });
2432
2433        // Try to mention an "image" file that will fail to load
2434        cx.simulate_input("@file x.png");
2435
2436        editor.update(&mut cx, |editor, cx| {
2437            assert_eq!(
2438                editor.text(cx),
2439                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2440            );
2441            assert!(editor.has_visible_completions_menu());
2442            assert_eq!(current_completion_labels(editor), &["x.png "]);
2443        });
2444
2445        editor.update_in(&mut cx, |editor, window, cx| {
2446            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2447        });
2448
2449        // Getting the message contents fails
2450        message_editor
2451            .update(&mut cx, |message_editor, cx| {
2452                message_editor
2453                    .mention_set()
2454                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2455            })
2456            .await
2457            .expect_err("Should fail to load x.png");
2458
2459        cx.run_until_parked();
2460
2461        // Mention was removed
2462        editor.read_with(&cx, |editor, cx| {
2463            assert_eq!(
2464                editor.text(cx),
2465                format!(
2466                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2467                    symbol.to_uri()
2468                )
2469            );
2470        });
2471
2472        // Once more
2473        cx.simulate_input("@file x.png");
2474
2475        editor.update(&mut cx, |editor, cx| {
2476                    assert_eq!(
2477                        editor.text(cx),
2478                        format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2479                    );
2480                    assert!(editor.has_visible_completions_menu());
2481                    assert_eq!(current_completion_labels(editor), &["x.png "]);
2482                });
2483
2484        editor.update_in(&mut cx, |editor, window, cx| {
2485            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2486        });
2487
2488        // This time don't immediately get the contents, just let the confirmed completion settle
2489        cx.run_until_parked();
2490
2491        // Mention was removed
2492        editor.read_with(&cx, |editor, cx| {
2493            assert_eq!(
2494                editor.text(cx),
2495                format!(
2496                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2497                    symbol.to_uri()
2498                )
2499            );
2500        });
2501
2502        // Now getting the contents succeeds, because the invalid mention was removed
2503        let contents = message_editor
2504            .update(&mut cx, |message_editor, cx| {
2505                message_editor
2506                    .mention_set()
2507                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2508            })
2509            .await
2510            .unwrap();
2511        assert_eq!(contents.len(), 3);
2512    }
2513
2514    fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2515        let snapshot = editor.buffer().read(cx).snapshot(cx);
2516        editor.display_map.update(cx, |display_map, cx| {
2517            display_map
2518                .snapshot(cx)
2519                .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2520                .map(|fold| fold.range.to_point(&snapshot))
2521                .collect()
2522        })
2523    }
2524
2525    fn current_completion_labels(editor: &Editor) -> Vec<String> {
2526        let completions = editor.current_completions().expect("Missing completions");
2527        completions
2528            .into_iter()
2529            .map(|completion| completion.label.text)
2530            .collect::<Vec<_>>()
2531    }
2532
2533    fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2534        let completions = editor.current_completions().expect("Missing completions");
2535        completions
2536            .into_iter()
2537            .map(|completion| {
2538                (
2539                    completion.label.text,
2540                    completion
2541                        .documentation
2542                        .map(|d| d.text().to_string())
2543                        .unwrap_or_default(),
2544                )
2545            })
2546            .collect::<Vec<_>>()
2547    }
2548
2549    #[gpui::test]
2550    async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2551        init_test(cx);
2552
2553        let fs = FakeFs::new(cx.executor());
2554
2555        // Create a large file that exceeds AUTO_OUTLINE_SIZE
2556        // Using plain text without a configured language, so no outline is available
2557        const LINE: &str = "This is a line of text in the file\n";
2558        let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2559        assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2560
2561        // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2562        let small_content = "fn small_function() { /* small */ }\n";
2563        assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2564
2565        fs.insert_tree(
2566            "/project",
2567            json!({
2568                "large_file.txt": large_content.clone(),
2569                "small_file.txt": small_content,
2570            }),
2571        )
2572        .await;
2573
2574        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2575
2576        let (multi_workspace, cx) =
2577            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2578        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2579
2580        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2581        let history = cx
2582            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2583
2584        let message_editor = cx.update(|window, cx| {
2585            cx.new(|cx| {
2586                let editor = MessageEditor::new(
2587                    workspace.downgrade(),
2588                    project.downgrade(),
2589                    thread_store.clone(),
2590                    history.downgrade(),
2591                    None,
2592                    Default::default(),
2593                    Default::default(),
2594                    "Test Agent".into(),
2595                    "Test",
2596                    EditorMode::AutoHeight {
2597                        min_lines: 1,
2598                        max_lines: None,
2599                    },
2600                    window,
2601                    cx,
2602                );
2603                // Enable embedded context so files are actually included
2604                editor
2605                    .prompt_capabilities
2606                    .replace(acp::PromptCapabilities::new().embedded_context(true));
2607                editor
2608            })
2609        });
2610
2611        // Test large file mention
2612        // Get the absolute path using the project's worktree
2613        let large_file_abs_path = project.read_with(cx, |project, cx| {
2614            let worktree = project.worktrees(cx).next().unwrap();
2615            let worktree_root = worktree.read(cx).abs_path();
2616            worktree_root.join("large_file.txt")
2617        });
2618        let large_file_task = message_editor.update(cx, |editor, cx| {
2619            editor.mention_set().update(cx, |set, cx| {
2620                set.confirm_mention_for_file(large_file_abs_path, true, cx)
2621            })
2622        });
2623
2624        let large_file_mention = large_file_task.await.unwrap();
2625        match large_file_mention {
2626            Mention::Text { content, .. } => {
2627                // Should contain some of the content but not all of it
2628                assert!(
2629                    content.contains(LINE),
2630                    "Should contain some of the file content"
2631                );
2632                assert!(
2633                    !content.contains(&LINE.repeat(100)),
2634                    "Should not contain the full file"
2635                );
2636                // Should be much smaller than original
2637                assert!(
2638                    content.len() < large_content.len() / 10,
2639                    "Should be significantly truncated"
2640                );
2641            }
2642            _ => panic!("Expected Text mention for large file"),
2643        }
2644
2645        // Test small file mention
2646        // Get the absolute path using the project's worktree
2647        let small_file_abs_path = project.read_with(cx, |project, cx| {
2648            let worktree = project.worktrees(cx).next().unwrap();
2649            let worktree_root = worktree.read(cx).abs_path();
2650            worktree_root.join("small_file.txt")
2651        });
2652        let small_file_task = message_editor.update(cx, |editor, cx| {
2653            editor.mention_set().update(cx, |set, cx| {
2654                set.confirm_mention_for_file(small_file_abs_path, true, cx)
2655            })
2656        });
2657
2658        let small_file_mention = small_file_task.await.unwrap();
2659        match small_file_mention {
2660            Mention::Text { content, .. } => {
2661                // Should contain the full actual content
2662                assert_eq!(content, small_content);
2663            }
2664            _ => panic!("Expected Text mention for small file"),
2665        }
2666    }
2667
2668    #[gpui::test]
2669    async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2670        init_test(cx);
2671        cx.update(LanguageModelRegistry::test);
2672
2673        let fs = FakeFs::new(cx.executor());
2674        fs.insert_tree("/project", json!({"file": ""})).await;
2675        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2676
2677        let (multi_workspace, cx) =
2678            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2679        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2680
2681        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2682        let history = cx
2683            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2684
2685        // Create a thread metadata to insert as summary
2686        let thread_metadata = AgentSessionInfo {
2687            session_id: acp::SessionId::new("thread-123"),
2688            cwd: None,
2689            title: Some("Previous Conversation".into()),
2690            updated_at: Some(chrono::Utc::now()),
2691            meta: None,
2692        };
2693
2694        let message_editor = cx.update(|window, cx| {
2695            cx.new(|cx| {
2696                let mut editor = MessageEditor::new(
2697                    workspace.downgrade(),
2698                    project.downgrade(),
2699                    thread_store.clone(),
2700                    history.downgrade(),
2701                    None,
2702                    Default::default(),
2703                    Default::default(),
2704                    "Test Agent".into(),
2705                    "Test",
2706                    EditorMode::AutoHeight {
2707                        min_lines: 1,
2708                        max_lines: None,
2709                    },
2710                    window,
2711                    cx,
2712                );
2713                editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2714                editor
2715            })
2716        });
2717
2718        // Construct expected values for verification
2719        let expected_uri = MentionUri::Thread {
2720            id: thread_metadata.session_id.clone(),
2721            name: thread_metadata.title.as_ref().unwrap().to_string(),
2722        };
2723        let expected_title = thread_metadata.title.as_ref().unwrap();
2724        let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2725
2726        message_editor.read_with(cx, |editor, cx| {
2727            let text = editor.text(cx);
2728
2729            assert!(
2730                text.contains(&expected_link),
2731                "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2732                expected_link,
2733                text
2734            );
2735
2736            let mentions = editor.mention_set().read(cx).mentions();
2737            assert_eq!(
2738                mentions.len(),
2739                1,
2740                "Expected exactly one mention after inserting thread summary"
2741            );
2742
2743            assert!(
2744                mentions.contains(&expected_uri),
2745                "Expected mentions to contain the thread URI"
2746            );
2747        });
2748    }
2749
2750    #[gpui::test]
2751    async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2752        init_test(cx);
2753        cx.update(LanguageModelRegistry::test);
2754
2755        let fs = FakeFs::new(cx.executor());
2756        fs.insert_tree("/project", json!({"file": ""})).await;
2757        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2758
2759        let (multi_workspace, cx) =
2760            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2761        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2762
2763        let thread_store = None;
2764        let history = cx
2765            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2766
2767        let thread_metadata = AgentSessionInfo {
2768            session_id: acp::SessionId::new("thread-123"),
2769            cwd: None,
2770            title: Some("Previous Conversation".into()),
2771            updated_at: Some(chrono::Utc::now()),
2772            meta: None,
2773        };
2774
2775        let message_editor = cx.update(|window, cx| {
2776            cx.new(|cx| {
2777                let mut editor = MessageEditor::new(
2778                    workspace.downgrade(),
2779                    project.downgrade(),
2780                    thread_store.clone(),
2781                    history.downgrade(),
2782                    None,
2783                    Default::default(),
2784                    Default::default(),
2785                    "Test Agent".into(),
2786                    "Test",
2787                    EditorMode::AutoHeight {
2788                        min_lines: 1,
2789                        max_lines: None,
2790                    },
2791                    window,
2792                    cx,
2793                );
2794                editor.insert_thread_summary(thread_metadata, window, cx);
2795                editor
2796            })
2797        });
2798
2799        message_editor.read_with(cx, |editor, cx| {
2800            assert!(
2801                editor.text(cx).is_empty(),
2802                "Expected thread summary to be skipped for external agents"
2803            );
2804            assert!(
2805                editor.mention_set().read(cx).mentions().is_empty(),
2806                "Expected no mentions when thread summary is skipped"
2807            );
2808        });
2809    }
2810
2811    #[gpui::test]
2812    async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2813        init_test(cx);
2814
2815        let fs = FakeFs::new(cx.executor());
2816        fs.insert_tree("/project", json!({"file": ""})).await;
2817        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2818
2819        let (multi_workspace, cx) =
2820            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2821        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2822
2823        let thread_store = None;
2824        let history = cx
2825            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2826
2827        let message_editor = cx.update(|window, cx| {
2828            cx.new(|cx| {
2829                MessageEditor::new(
2830                    workspace.downgrade(),
2831                    project.downgrade(),
2832                    thread_store.clone(),
2833                    history.downgrade(),
2834                    None,
2835                    Default::default(),
2836                    Default::default(),
2837                    "Test Agent".into(),
2838                    "Test",
2839                    EditorMode::AutoHeight {
2840                        min_lines: 1,
2841                        max_lines: None,
2842                    },
2843                    window,
2844                    cx,
2845                )
2846            })
2847        });
2848
2849        message_editor.update(cx, |editor, _cx| {
2850            editor
2851                .prompt_capabilities
2852                .replace(acp::PromptCapabilities::new().embedded_context(true));
2853        });
2854
2855        let supported_modes = {
2856            let app = cx.app.borrow();
2857            message_editor.supported_modes(&app)
2858        };
2859
2860        assert!(
2861            !supported_modes.contains(&PromptContextType::Thread),
2862            "Expected thread mode to be hidden when thread mentions are disabled"
2863        );
2864    }
2865
2866    #[gpui::test]
2867    async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2868        init_test(cx);
2869
2870        let fs = FakeFs::new(cx.executor());
2871        fs.insert_tree("/project", json!({"file": ""})).await;
2872        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2873
2874        let (multi_workspace, cx) =
2875            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2876        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2877
2878        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2879        let history = cx
2880            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2881
2882        let message_editor = cx.update(|window, cx| {
2883            cx.new(|cx| {
2884                MessageEditor::new(
2885                    workspace.downgrade(),
2886                    project.downgrade(),
2887                    thread_store.clone(),
2888                    history.downgrade(),
2889                    None,
2890                    Default::default(),
2891                    Default::default(),
2892                    "Test Agent".into(),
2893                    "Test",
2894                    EditorMode::AutoHeight {
2895                        min_lines: 1,
2896                        max_lines: None,
2897                    },
2898                    window,
2899                    cx,
2900                )
2901            })
2902        });
2903
2904        message_editor.update(cx, |editor, _cx| {
2905            editor
2906                .prompt_capabilities
2907                .replace(acp::PromptCapabilities::new().embedded_context(true));
2908        });
2909
2910        let supported_modes = {
2911            let app = cx.app.borrow();
2912            message_editor.supported_modes(&app)
2913        };
2914
2915        assert!(
2916            supported_modes.contains(&PromptContextType::Thread),
2917            "Expected thread mode to be visible when enabled"
2918        );
2919    }
2920
2921    #[gpui::test]
2922    async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2923        init_test(cx);
2924
2925        let fs = FakeFs::new(cx.executor());
2926        fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2927            .await;
2928        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2929
2930        let (multi_workspace, cx) =
2931            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2932        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2933
2934        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2935        let history = cx
2936            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2937
2938        let message_editor = cx.update(|window, cx| {
2939            cx.new(|cx| {
2940                MessageEditor::new(
2941                    workspace.downgrade(),
2942                    project.downgrade(),
2943                    thread_store.clone(),
2944                    history.downgrade(),
2945                    None,
2946                    Default::default(),
2947                    Default::default(),
2948                    "Test Agent".into(),
2949                    "Test",
2950                    EditorMode::AutoHeight {
2951                        min_lines: 1,
2952                        max_lines: None,
2953                    },
2954                    window,
2955                    cx,
2956                )
2957            })
2958        });
2959        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2960
2961        cx.run_until_parked();
2962
2963        editor.update_in(cx, |editor, window, cx| {
2964            editor.set_text("  \u{A0}してhello world  ", window, cx);
2965        });
2966
2967        let (content, _) = message_editor
2968            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2969            .await
2970            .unwrap();
2971
2972        assert_eq!(content, vec!["してhello world".into()]);
2973    }
2974
2975    #[gpui::test]
2976    async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2977        init_test(cx);
2978
2979        let fs = FakeFs::new(cx.executor());
2980
2981        let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2982
2983        fs.insert_tree(
2984            "/project",
2985            json!({
2986                "src": {
2987                    "main.rs": file_content,
2988                }
2989            }),
2990        )
2991        .await;
2992
2993        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2994
2995        let (multi_workspace, cx) =
2996            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2997        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2998
2999        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3000        let history = cx
3001            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3002
3003        let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3004            let workspace_handle = cx.weak_entity();
3005            let message_editor = cx.new(|cx| {
3006                MessageEditor::new(
3007                    workspace_handle,
3008                    project.downgrade(),
3009                    thread_store.clone(),
3010                    history.downgrade(),
3011                    None,
3012                    Default::default(),
3013                    Default::default(),
3014                    "Test Agent".into(),
3015                    "Test",
3016                    EditorMode::AutoHeight {
3017                        max_lines: None,
3018                        min_lines: 1,
3019                    },
3020                    window,
3021                    cx,
3022                )
3023            });
3024            workspace.active_pane().update(cx, |pane, cx| {
3025                pane.add_item(
3026                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3027                    true,
3028                    true,
3029                    None,
3030                    window,
3031                    cx,
3032                );
3033            });
3034            message_editor.read(cx).focus_handle(cx).focus(window, cx);
3035            let editor = message_editor.read(cx).editor().clone();
3036            (message_editor, editor)
3037        });
3038
3039        cx.simulate_input("What is in @file main");
3040
3041        editor.update_in(cx, |editor, window, cx| {
3042            assert!(editor.has_visible_completions_menu());
3043            assert_eq!(editor.text(cx), "What is in @file main");
3044            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3045        });
3046
3047        let content = message_editor
3048            .update(cx, |editor, cx| editor.contents(false, cx))
3049            .await
3050            .unwrap()
3051            .0;
3052
3053        let main_rs_uri = if cfg!(windows) {
3054            "file:///C:/project/src/main.rs"
3055        } else {
3056            "file:///project/src/main.rs"
3057        };
3058
3059        // When embedded context is `false` we should get a resource link
3060        pretty_assertions::assert_eq!(
3061            content,
3062            vec![
3063                "What is in ".into(),
3064                acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3065            ]
3066        );
3067
3068        message_editor.update(cx, |editor, _cx| {
3069            editor
3070                .prompt_capabilities
3071                .replace(acp::PromptCapabilities::new().embedded_context(true))
3072        });
3073
3074        let content = message_editor
3075            .update(cx, |editor, cx| editor.contents(false, cx))
3076            .await
3077            .unwrap()
3078            .0;
3079
3080        // When embedded context is `true` we should get a resource
3081        pretty_assertions::assert_eq!(
3082            content,
3083            vec![
3084                "What is in ".into(),
3085                acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3086                    acp::EmbeddedResourceResource::TextResourceContents(
3087                        acp::TextResourceContents::new(file_content, main_rs_uri)
3088                    )
3089                ))
3090            ]
3091        );
3092    }
3093
3094    #[gpui::test]
3095    async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3096        init_test(cx);
3097
3098        let app_state = cx.update(AppState::test);
3099
3100        cx.update(|cx| {
3101            editor::init(cx);
3102            workspace::init(app_state.clone(), cx);
3103        });
3104
3105        app_state
3106            .fs
3107            .as_fake()
3108            .insert_tree(
3109                path!("/dir"),
3110                json!({
3111                    "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3112                }),
3113            )
3114            .await;
3115
3116        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3117        let window =
3118            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3119        let workspace = window
3120            .read_with(cx, |mw, _| mw.workspace().clone())
3121            .unwrap();
3122
3123        let worktree = project.update(cx, |project, cx| {
3124            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3125            assert_eq!(worktrees.len(), 1);
3126            worktrees.pop().unwrap()
3127        });
3128        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3129
3130        let mut cx = VisualTestContext::from_window(window.into(), cx);
3131
3132        // Open a regular editor with the created file, and select a portion of
3133        // the text that will be used for the selections that are meant to be
3134        // inserted in the agent panel.
3135        let editor = workspace
3136            .update_in(&mut cx, |workspace, window, cx| {
3137                workspace.open_path(
3138                    ProjectPath {
3139                        worktree_id,
3140                        path: rel_path("test.txt").into(),
3141                    },
3142                    None,
3143                    false,
3144                    window,
3145                    cx,
3146                )
3147            })
3148            .await
3149            .unwrap()
3150            .downcast::<Editor>()
3151            .unwrap();
3152
3153        editor.update_in(&mut cx, |editor, window, cx| {
3154            editor.change_selections(Default::default(), window, cx, |selections| {
3155                selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3156            });
3157        });
3158
3159        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3160        let history = cx
3161            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3162
3163        // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3164        // to ensure we have a fixed viewport, so we can eventually actually
3165        // place the cursor outside of the visible area.
3166        let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3167            let workspace_handle = cx.weak_entity();
3168            let message_editor = cx.new(|cx| {
3169                MessageEditor::new(
3170                    workspace_handle,
3171                    project.downgrade(),
3172                    thread_store.clone(),
3173                    history.downgrade(),
3174                    None,
3175                    Default::default(),
3176                    Default::default(),
3177                    "Test Agent".into(),
3178                    "Test",
3179                    EditorMode::full(),
3180                    window,
3181                    cx,
3182                )
3183            });
3184            workspace.active_pane().update(cx, |pane, cx| {
3185                pane.add_item(
3186                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3187                    true,
3188                    true,
3189                    None,
3190                    window,
3191                    cx,
3192                );
3193            });
3194
3195            message_editor
3196        });
3197
3198        message_editor.update_in(&mut cx, |message_editor, window, cx| {
3199            message_editor.editor.update(cx, |editor, cx| {
3200                // Update the Agent Panel's Message Editor text to have 100
3201                // lines, ensuring that the cursor is set at line 90 and that we
3202                // then scroll all the way to the top, so the cursor's position
3203                // remains off screen.
3204                let mut lines = String::new();
3205                for _ in 1..=100 {
3206                    lines.push_str(&"Another line in the agent panel's message editor\n");
3207                }
3208                editor.set_text(lines.as_str(), window, cx);
3209                editor.change_selections(Default::default(), window, cx, |selections| {
3210                    selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3211                });
3212                editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3213            });
3214        });
3215
3216        cx.run_until_parked();
3217
3218        // Before proceeding, let's assert that the cursor is indeed off screen,
3219        // otherwise the rest of the test doesn't make sense.
3220        message_editor.update_in(&mut cx, |message_editor, window, cx| {
3221            message_editor.editor.update(cx, |editor, cx| {
3222                let snapshot = editor.snapshot(window, cx);
3223                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3224                let scroll_top = snapshot.scroll_position().y as u32;
3225                let visible_lines = editor.visible_line_count().unwrap() as u32;
3226                let visible_range = scroll_top..(scroll_top + visible_lines);
3227
3228                assert!(!visible_range.contains(&cursor_row));
3229            })
3230        });
3231
3232        // Now let's insert the selection in the Agent Panel's editor and
3233        // confirm that, after the insertion, the cursor is now in the visible
3234        // range.
3235        message_editor.update_in(&mut cx, |message_editor, window, cx| {
3236            message_editor.insert_selections(window, cx);
3237        });
3238
3239        cx.run_until_parked();
3240
3241        message_editor.update_in(&mut cx, |message_editor, window, cx| {
3242            message_editor.editor.update(cx, |editor, cx| {
3243                let snapshot = editor.snapshot(window, cx);
3244                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3245                let scroll_top = snapshot.scroll_position().y as u32;
3246                let visible_lines = editor.visible_line_count().unwrap() as u32;
3247                let visible_range = scroll_top..(scroll_top + visible_lines);
3248
3249                assert!(visible_range.contains(&cursor_row));
3250            })
3251        });
3252    }
3253
3254    #[gpui::test]
3255    async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3256        init_test(cx);
3257
3258        let app_state = cx.update(AppState::test);
3259
3260        cx.update(|cx| {
3261            editor::init(cx);
3262            workspace::init(app_state.clone(), cx);
3263        });
3264
3265        app_state
3266            .fs
3267            .as_fake()
3268            .insert_tree(path!("/dir"), json!({}))
3269            .await;
3270
3271        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3272        let window =
3273            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3274        let workspace = window
3275            .read_with(cx, |mw, _| mw.workspace().clone())
3276            .unwrap();
3277
3278        let mut cx = VisualTestContext::from_window(window.into(), cx);
3279
3280        let thread_store = cx.new(|cx| ThreadStore::new(cx));
3281        let history = cx
3282            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3283
3284        let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3285            let workspace_handle = cx.weak_entity();
3286            let message_editor = cx.new(|cx| {
3287                MessageEditor::new(
3288                    workspace_handle,
3289                    project.downgrade(),
3290                    Some(thread_store),
3291                    history.downgrade(),
3292                    None,
3293                    Default::default(),
3294                    Default::default(),
3295                    "Test Agent".into(),
3296                    "Test",
3297                    EditorMode::AutoHeight {
3298                        max_lines: None,
3299                        min_lines: 1,
3300                    },
3301                    window,
3302                    cx,
3303                )
3304            });
3305            workspace.active_pane().update(cx, |pane, cx| {
3306                pane.add_item(
3307                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3308                    true,
3309                    true,
3310                    None,
3311                    window,
3312                    cx,
3313                );
3314            });
3315            message_editor.read(cx).focus_handle(cx).focus(window, cx);
3316            let editor = message_editor.read(cx).editor().clone();
3317            (message_editor, editor)
3318        });
3319
3320        editor.update_in(&mut cx, |editor, window, cx| {
3321            editor.set_text("😄😄", window, cx);
3322        });
3323
3324        cx.run_until_parked();
3325
3326        message_editor.update_in(&mut cx, |message_editor, window, cx| {
3327            message_editor.insert_context_type("file", window, cx);
3328        });
3329
3330        cx.run_until_parked();
3331
3332        editor.update(&mut cx, |editor, cx| {
3333            assert_eq!(editor.text(cx), "😄😄@file");
3334        });
3335    }
3336}