message_editor.rs

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