message_editor.rs

   1use crate::QueueMessage;
   2use crate::acp::AcpThreadHistory;
   3use crate::{
   4    ChatWithFollow,
   5    completion_provider::{
   6        PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
   7        PromptContextType, SlashCommandCompletion,
   8    },
   9    mention_set::{
  10        Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
  11    },
  12};
  13use acp_thread::{AgentSessionInfo, MentionUri};
  14use agent::ThreadStore;
  15use agent_client_protocol as acp;
  16use anyhow::{Result, anyhow};
  17use collections::HashSet;
  18use editor::{
  19    Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
  20    EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
  21    MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
  22    scroll::Autoscroll,
  23};
  24use futures::{FutureExt as _, future::join_all};
  25use gpui::{
  26    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
  27    KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
  28};
  29use language::{Buffer, Language, language_settings::InlayHintKind};
  30use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
  31use prompt_store::PromptStore;
  32use rope::Point;
  33use settings::Settings;
  34use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
  35use theme::ThemeSettings;
  36use ui::{ContextMenu, prelude::*};
  37use util::{ResultExt, debug_panic};
  38use workspace::{CollaboratorId, Workspace};
  39use zed_actions::agent::{Chat, PasteRaw};
  40
  41pub struct MessageEditor {
  42    mention_set: Entity<MentionSet>,
  43    editor: Entity<Editor>,
  44    workspace: WeakEntity<Workspace>,
  45    prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
  46    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
  47    agent_name: SharedString,
  48    thread_store: Option<Entity<ThreadStore>>,
  49    _subscriptions: Vec<Subscription>,
  50    _parse_slash_command_task: Task<()>,
  51}
  52
  53#[derive(Clone, Copy, Debug)]
  54pub enum MessageEditorEvent {
  55    Send,
  56    Queue,
  57    Cancel,
  58    Focus,
  59    LostFocus,
  60}
  61
  62impl EventEmitter<MessageEditorEvent> for MessageEditor {}
  63
  64const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
  65
  66impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
  67    fn supports_images(&self, cx: &App) -> bool {
  68        self.read(cx).prompt_capabilities.borrow().image
  69    }
  70
  71    fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
  72        let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
  73        if self.read(cx).prompt_capabilities.borrow().embedded_context {
  74            if self.read(cx).thread_store.is_some() {
  75                supported.push(PromptContextType::Thread);
  76            }
  77            supported.extend(&[PromptContextType::Fetch, PromptContextType::Rules]);
  78        }
  79        supported
  80    }
  81
  82    fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
  83        self.read(cx)
  84            .available_commands
  85            .borrow()
  86            .iter()
  87            .map(|cmd| crate::completion_provider::AvailableCommand {
  88                name: cmd.name.clone().into(),
  89                description: cmd.description.clone().into(),
  90                requires_argument: cmd.input.is_some(),
  91            })
  92            .collect()
  93    }
  94
  95    fn confirm_command(&self, cx: &mut App) {
  96        self.update(cx, |this, cx| this.send(cx));
  97    }
  98}
  99
 100impl MessageEditor {
 101    pub fn new(
 102        workspace: WeakEntity<Workspace>,
 103        project: WeakEntity<Project>,
 104        thread_store: Option<Entity<ThreadStore>>,
 105        history: WeakEntity<AcpThreadHistory>,
 106        prompt_store: Option<Entity<PromptStore>>,
 107        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
 108        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
 109        agent_name: SharedString,
 110        placeholder: &str,
 111        mode: EditorMode,
 112        window: &mut Window,
 113        cx: &mut Context<Self>,
 114    ) -> Self {
 115        let language = Language::new(
 116            language::LanguageConfig {
 117                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
 118                ..Default::default()
 119            },
 120            None,
 121        );
 122
 123        let editor = cx.new(|cx| {
 124            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
 125            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 126
 127            let mut editor = Editor::new(mode, buffer, None, window, cx);
 128            editor.set_placeholder_text(placeholder, window, cx);
 129            editor.set_show_indent_guides(false, cx);
 130            editor.set_show_completions_on_input(Some(true));
 131            editor.set_soft_wrap();
 132            editor.set_use_modal_editing(true);
 133            editor.set_context_menu_options(ContextMenuOptions {
 134                min_entries_visible: 12,
 135                max_entries_visible: 12,
 136                placement: Some(ContextMenuPlacement::Above),
 137            });
 138            editor.register_addon(MessageEditorAddon::new());
 139
 140            editor.set_custom_context_menu(|editor, _point, window, cx| {
 141                let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
 142
 143                Some(ContextMenu::build(window, cx, |menu, _, _| {
 144                    menu.action("Cut", Box::new(editor::actions::Cut))
 145                        .action_disabled_when(
 146                            !has_selection,
 147                            "Copy",
 148                            Box::new(editor::actions::Copy),
 149                        )
 150                        .action("Paste", Box::new(editor::actions::Paste))
 151                }))
 152            });
 153
 154            editor
 155        });
 156        let mention_set =
 157            cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
 158        let completion_provider = Rc::new(PromptCompletionProvider::new(
 159            cx.entity(),
 160            editor.downgrade(),
 161            mention_set.clone(),
 162            history,
 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 history = cx
1112            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1113
1114        let message_editor = cx.update(|window, cx| {
1115            cx.new(|cx| {
1116                MessageEditor::new(
1117                    workspace.downgrade(),
1118                    project.downgrade(),
1119                    thread_store.clone(),
1120                    history.downgrade(),
1121                    None,
1122                    Default::default(),
1123                    Default::default(),
1124                    "Test Agent".into(),
1125                    "Test",
1126                    EditorMode::AutoHeight {
1127                        min_lines: 1,
1128                        max_lines: None,
1129                    },
1130                    window,
1131                    cx,
1132                )
1133            })
1134        });
1135        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1136
1137        cx.run_until_parked();
1138
1139        let excerpt_id = editor.update(cx, |editor, cx| {
1140            editor
1141                .buffer()
1142                .read(cx)
1143                .excerpt_ids()
1144                .into_iter()
1145                .next()
1146                .unwrap()
1147        });
1148        let completions = editor.update_in(cx, |editor, window, cx| {
1149            editor.set_text("Hello @file ", window, cx);
1150            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1151            let completion_provider = editor.completion_provider().unwrap();
1152            completion_provider.completions(
1153                excerpt_id,
1154                &buffer,
1155                text::Anchor::MAX,
1156                CompletionContext {
1157                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1158                    trigger_character: Some("@".into()),
1159                },
1160                window,
1161                cx,
1162            )
1163        });
1164        let [_, completion]: [_; 2] = completions
1165            .await
1166            .unwrap()
1167            .into_iter()
1168            .flat_map(|response| response.completions)
1169            .collect::<Vec<_>>()
1170            .try_into()
1171            .unwrap();
1172
1173        editor.update_in(cx, |editor, window, cx| {
1174            let snapshot = editor.buffer().read(cx).snapshot(cx);
1175            let range = snapshot
1176                .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1177                .unwrap();
1178            editor.edit([(range, completion.new_text)], cx);
1179            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1180        });
1181
1182        cx.run_until_parked();
1183
1184        // Backspace over the inserted crease (and the following space).
1185        editor.update_in(cx, |editor, window, cx| {
1186            editor.backspace(&Default::default(), window, cx);
1187            editor.backspace(&Default::default(), window, cx);
1188        });
1189
1190        let (content, _) = message_editor
1191            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1192            .await
1193            .unwrap();
1194
1195        // We don't send a resource link for the deleted crease.
1196        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1197    }
1198
1199    #[gpui::test]
1200    async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1201        init_test(cx);
1202        let fs = FakeFs::new(cx.executor());
1203        fs.insert_tree(
1204            "/test",
1205            json!({
1206                ".zed": {
1207                    "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1208                },
1209                "src": {
1210                    "main.rs": "fn main() {}",
1211                },
1212            }),
1213        )
1214        .await;
1215
1216        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1217        let thread_store = 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 history = cx
1225            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1226        let workspace_handle = workspace.downgrade();
1227        let message_editor = workspace.update_in(cx, |_, window, cx| {
1228            cx.new(|cx| {
1229                MessageEditor::new(
1230                    workspace_handle.clone(),
1231                    project.downgrade(),
1232                    thread_store.clone(),
1233                    history.downgrade(),
1234                    None,
1235                    prompt_capabilities.clone(),
1236                    available_commands.clone(),
1237                    "Claude Code".into(),
1238                    "Test",
1239                    EditorMode::AutoHeight {
1240                        min_lines: 1,
1241                        max_lines: None,
1242                    },
1243                    window,
1244                    cx,
1245                )
1246            })
1247        });
1248        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1249
1250        // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1251        editor.update_in(cx, |editor, window, cx| {
1252            editor.set_text("/file test.txt", window, cx);
1253        });
1254
1255        let contents_result = message_editor
1256            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1257            .await;
1258
1259        // Should fail because available_commands is empty (no commands supported)
1260        assert!(contents_result.is_err());
1261        let error_message = contents_result.unwrap_err().to_string();
1262        assert!(error_message.contains("not supported by Claude Code"));
1263        assert!(error_message.contains("Available commands: none"));
1264
1265        // Now simulate Claude providing its list of available commands (which doesn't include file)
1266        available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1267
1268        // Test that unsupported slash commands trigger an error when we have a list of available commands
1269        editor.update_in(cx, |editor, window, cx| {
1270            editor.set_text("/file test.txt", window, cx);
1271        });
1272
1273        let contents_result = message_editor
1274            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1275            .await;
1276
1277        assert!(contents_result.is_err());
1278        let error_message = contents_result.unwrap_err().to_string();
1279        assert!(error_message.contains("not supported by Claude Code"));
1280        assert!(error_message.contains("/file"));
1281        assert!(error_message.contains("Available commands: /help"));
1282
1283        // Test that supported commands work fine
1284        editor.update_in(cx, |editor, window, cx| {
1285            editor.set_text("/help", window, cx);
1286        });
1287
1288        let contents_result = message_editor
1289            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1290            .await;
1291
1292        // Should succeed because /help is in available_commands
1293        assert!(contents_result.is_ok());
1294
1295        // Test that regular text works fine
1296        editor.update_in(cx, |editor, window, cx| {
1297            editor.set_text("Hello Claude!", window, cx);
1298        });
1299
1300        let (content, _) = message_editor
1301            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1302            .await
1303            .unwrap();
1304
1305        assert_eq!(content.len(), 1);
1306        if let acp::ContentBlock::Text(text) = &content[0] {
1307            assert_eq!(text.text, "Hello Claude!");
1308        } else {
1309            panic!("Expected ContentBlock::Text");
1310        }
1311
1312        // Test that @ mentions still work
1313        editor.update_in(cx, |editor, window, cx| {
1314            editor.set_text("Check this @", window, cx);
1315        });
1316
1317        // The @ mention functionality should not be affected
1318        let (content, _) = message_editor
1319            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1320            .await
1321            .unwrap();
1322
1323        assert_eq!(content.len(), 1);
1324        if let acp::ContentBlock::Text(text) = &content[0] {
1325            assert_eq!(text.text, "Check this @");
1326        } else {
1327            panic!("Expected ContentBlock::Text");
1328        }
1329    }
1330
1331    struct MessageEditorItem(Entity<MessageEditor>);
1332
1333    impl Item for MessageEditorItem {
1334        type Event = ();
1335
1336        fn include_in_nav_history() -> bool {
1337            false
1338        }
1339
1340        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1341            "Test".into()
1342        }
1343    }
1344
1345    impl EventEmitter<()> for MessageEditorItem {}
1346
1347    impl Focusable for MessageEditorItem {
1348        fn focus_handle(&self, cx: &App) -> FocusHandle {
1349            self.0.read(cx).focus_handle(cx)
1350        }
1351    }
1352
1353    impl Render for MessageEditorItem {
1354        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1355            self.0.clone().into_any_element()
1356        }
1357    }
1358
1359    #[gpui::test]
1360    async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1361        init_test(cx);
1362
1363        let app_state = cx.update(AppState::test);
1364
1365        cx.update(|cx| {
1366            editor::init(cx);
1367            workspace::init(app_state.clone(), cx);
1368        });
1369
1370        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1371        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1372        let workspace = window.root(cx).unwrap();
1373
1374        let mut cx = VisualTestContext::from_window(*window, cx);
1375
1376        let thread_store = None;
1377        let history = cx
1378            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1379        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1380        let available_commands = Rc::new(RefCell::new(vec![
1381            acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1382            acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1383                acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1384                    "<name>",
1385                )),
1386            ),
1387        ]));
1388
1389        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1390            let workspace_handle = cx.weak_entity();
1391            let message_editor = cx.new(|cx| {
1392                MessageEditor::new(
1393                    workspace_handle,
1394                    project.downgrade(),
1395                    thread_store.clone(),
1396                    history.downgrade(),
1397                    None,
1398                    prompt_capabilities.clone(),
1399                    available_commands.clone(),
1400                    "Test Agent".into(),
1401                    "Test",
1402                    EditorMode::AutoHeight {
1403                        max_lines: None,
1404                        min_lines: 1,
1405                    },
1406                    window,
1407                    cx,
1408                )
1409            });
1410            workspace.active_pane().update(cx, |pane, cx| {
1411                pane.add_item(
1412                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1413                    true,
1414                    true,
1415                    None,
1416                    window,
1417                    cx,
1418                );
1419            });
1420            message_editor.read(cx).focus_handle(cx).focus(window, cx);
1421            message_editor.read(cx).editor().clone()
1422        });
1423
1424        cx.simulate_input("/");
1425
1426        editor.update_in(&mut cx, |editor, window, cx| {
1427            assert_eq!(editor.text(cx), "/");
1428            assert!(editor.has_visible_completions_menu());
1429
1430            assert_eq!(
1431                current_completion_labels_with_documentation(editor),
1432                &[
1433                    ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1434                    ("say-hello".into(), "Say hello to whoever you want".into())
1435                ]
1436            );
1437            editor.set_text("", window, cx);
1438        });
1439
1440        cx.simulate_input("/qui");
1441
1442        editor.update_in(&mut cx, |editor, window, cx| {
1443            assert_eq!(editor.text(cx), "/qui");
1444            assert!(editor.has_visible_completions_menu());
1445
1446            assert_eq!(
1447                current_completion_labels_with_documentation(editor),
1448                &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1449            );
1450            editor.set_text("", window, cx);
1451        });
1452
1453        editor.update_in(&mut cx, |editor, window, cx| {
1454            assert!(editor.has_visible_completions_menu());
1455            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1456        });
1457
1458        cx.run_until_parked();
1459
1460        editor.update_in(&mut cx, |editor, window, cx| {
1461            assert_eq!(editor.display_text(cx), "/quick-math ");
1462            assert!(!editor.has_visible_completions_menu());
1463            editor.set_text("", window, cx);
1464        });
1465
1466        cx.simulate_input("/say");
1467
1468        editor.update_in(&mut cx, |editor, _window, cx| {
1469            assert_eq!(editor.display_text(cx), "/say");
1470            assert!(editor.has_visible_completions_menu());
1471
1472            assert_eq!(
1473                current_completion_labels_with_documentation(editor),
1474                &[("say-hello".into(), "Say hello to whoever you want".into())]
1475            );
1476        });
1477
1478        editor.update_in(&mut cx, |editor, window, cx| {
1479            assert!(editor.has_visible_completions_menu());
1480            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1481        });
1482
1483        cx.run_until_parked();
1484
1485        editor.update_in(&mut cx, |editor, _window, cx| {
1486            assert_eq!(editor.text(cx), "/say-hello ");
1487            assert_eq!(editor.display_text(cx), "/say-hello <name>");
1488            assert!(!editor.has_visible_completions_menu());
1489        });
1490
1491        cx.simulate_input("GPT5");
1492
1493        cx.run_until_parked();
1494
1495        editor.update_in(&mut cx, |editor, window, cx| {
1496            assert_eq!(editor.text(cx), "/say-hello GPT5");
1497            assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1498            assert!(!editor.has_visible_completions_menu());
1499
1500            // Delete argument
1501            for _ in 0..5 {
1502                editor.backspace(&editor::actions::Backspace, window, cx);
1503            }
1504        });
1505
1506        cx.run_until_parked();
1507
1508        editor.update_in(&mut cx, |editor, window, cx| {
1509            assert_eq!(editor.text(cx), "/say-hello");
1510            // Hint is visible because argument was deleted
1511            assert_eq!(editor.display_text(cx), "/say-hello <name>");
1512
1513            // Delete last command letter
1514            editor.backspace(&editor::actions::Backspace, window, cx);
1515        });
1516
1517        cx.run_until_parked();
1518
1519        editor.update_in(&mut cx, |editor, _window, cx| {
1520            // Hint goes away once command no longer matches an available one
1521            assert_eq!(editor.text(cx), "/say-hell");
1522            assert_eq!(editor.display_text(cx), "/say-hell");
1523            assert!(!editor.has_visible_completions_menu());
1524        });
1525    }
1526
1527    #[gpui::test]
1528    async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1529        init_test(cx);
1530
1531        let app_state = cx.update(AppState::test);
1532
1533        cx.update(|cx| {
1534            editor::init(cx);
1535            workspace::init(app_state.clone(), cx);
1536        });
1537
1538        app_state
1539            .fs
1540            .as_fake()
1541            .insert_tree(
1542                path!("/dir"),
1543                json!({
1544                    "editor": "",
1545                    "a": {
1546                        "one.txt": "1",
1547                        "two.txt": "2",
1548                        "three.txt": "3",
1549                        "four.txt": "4"
1550                    },
1551                    "b": {
1552                        "five.txt": "5",
1553                        "six.txt": "6",
1554                        "seven.txt": "7",
1555                        "eight.txt": "8",
1556                    },
1557                    "x.png": "",
1558                }),
1559            )
1560            .await;
1561
1562        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1563        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1564        let workspace = window.root(cx).unwrap();
1565
1566        let worktree = project.update(cx, |project, cx| {
1567            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1568            assert_eq!(worktrees.len(), 1);
1569            worktrees.pop().unwrap()
1570        });
1571        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1572
1573        let mut cx = VisualTestContext::from_window(*window, cx);
1574
1575        let paths = vec![
1576            rel_path("a/one.txt"),
1577            rel_path("a/two.txt"),
1578            rel_path("a/three.txt"),
1579            rel_path("a/four.txt"),
1580            rel_path("b/five.txt"),
1581            rel_path("b/six.txt"),
1582            rel_path("b/seven.txt"),
1583            rel_path("b/eight.txt"),
1584        ];
1585
1586        let slash = PathStyle::local().primary_separator();
1587
1588        let mut opened_editors = Vec::new();
1589        for path in paths {
1590            let buffer = workspace
1591                .update_in(&mut cx, |workspace, window, cx| {
1592                    workspace.open_path(
1593                        ProjectPath {
1594                            worktree_id,
1595                            path: path.into(),
1596                        },
1597                        None,
1598                        false,
1599                        window,
1600                        cx,
1601                    )
1602                })
1603                .await
1604                .unwrap();
1605            opened_editors.push(buffer);
1606        }
1607
1608        let thread_store = cx.new(|cx| ThreadStore::new(cx));
1609        let history = cx
1610            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1611        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1612
1613        let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1614            let workspace_handle = cx.weak_entity();
1615            let message_editor = cx.new(|cx| {
1616                MessageEditor::new(
1617                    workspace_handle,
1618                    project.downgrade(),
1619                    Some(thread_store),
1620                    history.downgrade(),
1621                    None,
1622                    prompt_capabilities.clone(),
1623                    Default::default(),
1624                    "Test Agent".into(),
1625                    "Test",
1626                    EditorMode::AutoHeight {
1627                        max_lines: None,
1628                        min_lines: 1,
1629                    },
1630                    window,
1631                    cx,
1632                )
1633            });
1634            workspace.active_pane().update(cx, |pane, cx| {
1635                pane.add_item(
1636                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1637                    true,
1638                    true,
1639                    None,
1640                    window,
1641                    cx,
1642                );
1643            });
1644            message_editor.read(cx).focus_handle(cx).focus(window, cx);
1645            let editor = message_editor.read(cx).editor().clone();
1646            (message_editor, editor)
1647        });
1648
1649        cx.simulate_input("Lorem @");
1650
1651        editor.update_in(&mut cx, |editor, window, cx| {
1652            assert_eq!(editor.text(cx), "Lorem @");
1653            assert!(editor.has_visible_completions_menu());
1654
1655            assert_eq!(
1656                current_completion_labels(editor),
1657                &[
1658                    format!("eight.txt b{slash}"),
1659                    format!("seven.txt b{slash}"),
1660                    format!("six.txt b{slash}"),
1661                    format!("five.txt b{slash}"),
1662                    "Files & Directories".into(),
1663                    "Symbols".into()
1664                ]
1665            );
1666            editor.set_text("", window, cx);
1667        });
1668
1669        prompt_capabilities.replace(
1670            acp::PromptCapabilities::new()
1671                .image(true)
1672                .audio(true)
1673                .embedded_context(true),
1674        );
1675
1676        cx.simulate_input("Lorem ");
1677
1678        editor.update(&mut cx, |editor, cx| {
1679            assert_eq!(editor.text(cx), "Lorem ");
1680            assert!(!editor.has_visible_completions_menu());
1681        });
1682
1683        cx.simulate_input("@");
1684
1685        editor.update(&mut cx, |editor, cx| {
1686            assert_eq!(editor.text(cx), "Lorem @");
1687            assert!(editor.has_visible_completions_menu());
1688            assert_eq!(
1689                current_completion_labels(editor),
1690                &[
1691                    format!("eight.txt b{slash}"),
1692                    format!("seven.txt b{slash}"),
1693                    format!("six.txt b{slash}"),
1694                    format!("five.txt b{slash}"),
1695                    "Files & Directories".into(),
1696                    "Symbols".into(),
1697                    "Threads".into(),
1698                    "Fetch".into()
1699                ]
1700            );
1701        });
1702
1703        // Select and confirm "File"
1704        editor.update_in(&mut cx, |editor, window, cx| {
1705            assert!(editor.has_visible_completions_menu());
1706            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1707            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1708            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1709            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1710            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1711        });
1712
1713        cx.run_until_parked();
1714
1715        editor.update(&mut cx, |editor, cx| {
1716            assert_eq!(editor.text(cx), "Lorem @file ");
1717            assert!(editor.has_visible_completions_menu());
1718        });
1719
1720        cx.simulate_input("one");
1721
1722        editor.update(&mut cx, |editor, cx| {
1723            assert_eq!(editor.text(cx), "Lorem @file one");
1724            assert!(editor.has_visible_completions_menu());
1725            assert_eq!(
1726                current_completion_labels(editor),
1727                vec![format!("one.txt a{slash}")]
1728            );
1729        });
1730
1731        editor.update_in(&mut cx, |editor, window, cx| {
1732            assert!(editor.has_visible_completions_menu());
1733            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1734        });
1735
1736        let url_one = MentionUri::File {
1737            abs_path: path!("/dir/a/one.txt").into(),
1738        }
1739        .to_uri()
1740        .to_string();
1741        editor.update(&mut cx, |editor, cx| {
1742            let text = editor.text(cx);
1743            assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1744            assert!(!editor.has_visible_completions_menu());
1745            assert_eq!(fold_ranges(editor, cx).len(), 1);
1746        });
1747
1748        let contents = message_editor
1749            .update(&mut cx, |message_editor, cx| {
1750                message_editor
1751                    .mention_set()
1752                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1753            })
1754            .await
1755            .unwrap()
1756            .into_values()
1757            .collect::<Vec<_>>();
1758
1759        {
1760            let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1761                panic!("Unexpected mentions");
1762            };
1763            pretty_assertions::assert_eq!(content, "1");
1764            pretty_assertions::assert_eq!(
1765                uri,
1766                &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1767            );
1768        }
1769
1770        cx.simulate_input(" ");
1771
1772        editor.update(&mut cx, |editor, cx| {
1773            let text = editor.text(cx);
1774            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  "));
1775            assert!(!editor.has_visible_completions_menu());
1776            assert_eq!(fold_ranges(editor, cx).len(), 1);
1777        });
1778
1779        cx.simulate_input("Ipsum ");
1780
1781        editor.update(&mut cx, |editor, cx| {
1782            let text = editor.text(cx);
1783            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum "),);
1784            assert!(!editor.has_visible_completions_menu());
1785            assert_eq!(fold_ranges(editor, cx).len(), 1);
1786        });
1787
1788        cx.simulate_input("@file ");
1789
1790        editor.update(&mut cx, |editor, cx| {
1791            let text = editor.text(cx);
1792            assert_eq!(text, format!("Lorem [@one.txt]({url_one})  Ipsum @file "),);
1793            assert!(editor.has_visible_completions_menu());
1794            assert_eq!(fold_ranges(editor, cx).len(), 1);
1795        });
1796
1797        editor.update_in(&mut cx, |editor, window, cx| {
1798            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1799        });
1800
1801        cx.run_until_parked();
1802
1803        let contents = message_editor
1804            .update(&mut cx, |message_editor, cx| {
1805                message_editor
1806                    .mention_set()
1807                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1808            })
1809            .await
1810            .unwrap()
1811            .into_values()
1812            .collect::<Vec<_>>();
1813
1814        let url_eight = MentionUri::File {
1815            abs_path: path!("/dir/b/eight.txt").into(),
1816        }
1817        .to_uri()
1818        .to_string();
1819
1820        {
1821            let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1822                panic!("Unexpected mentions");
1823            };
1824            pretty_assertions::assert_eq!(content, "8");
1825            pretty_assertions::assert_eq!(
1826                uri,
1827                &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1828            );
1829        }
1830
1831        editor.update(&mut cx, |editor, cx| {
1832            assert_eq!(
1833                editor.text(cx),
1834                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) ")
1835            );
1836            assert!(!editor.has_visible_completions_menu());
1837            assert_eq!(fold_ranges(editor, cx).len(), 2);
1838        });
1839
1840        let plain_text_language = Arc::new(language::Language::new(
1841            language::LanguageConfig {
1842                name: "Plain Text".into(),
1843                matcher: language::LanguageMatcher {
1844                    path_suffixes: vec!["txt".to_string()],
1845                    ..Default::default()
1846                },
1847                ..Default::default()
1848            },
1849            None,
1850        ));
1851
1852        // Register the language and fake LSP
1853        let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1854        language_registry.add(plain_text_language);
1855
1856        let mut fake_language_servers = language_registry.register_fake_lsp(
1857            "Plain Text",
1858            language::FakeLspAdapter {
1859                capabilities: lsp::ServerCapabilities {
1860                    workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1861                    ..Default::default()
1862                },
1863                ..Default::default()
1864            },
1865        );
1866
1867        // Open the buffer to trigger LSP initialization
1868        let buffer = project
1869            .update(&mut cx, |project, cx| {
1870                project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1871            })
1872            .await
1873            .unwrap();
1874
1875        // Register the buffer with language servers
1876        let _handle = project.update(&mut cx, |project, cx| {
1877            project.register_buffer_with_language_servers(&buffer, cx)
1878        });
1879
1880        cx.run_until_parked();
1881
1882        let fake_language_server = fake_language_servers.next().await.unwrap();
1883        fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1884            move |_, _| async move {
1885                Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1886                    #[allow(deprecated)]
1887                    lsp::SymbolInformation {
1888                        name: "MySymbol".into(),
1889                        location: lsp::Location {
1890                            uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1891                            range: lsp::Range::new(
1892                                lsp::Position::new(0, 0),
1893                                lsp::Position::new(0, 1),
1894                            ),
1895                        },
1896                        kind: lsp::SymbolKind::CONSTANT,
1897                        tags: None,
1898                        container_name: None,
1899                        deprecated: None,
1900                    },
1901                ])))
1902            },
1903        );
1904
1905        cx.simulate_input("@symbol ");
1906
1907        editor.update(&mut cx, |editor, cx| {
1908            assert_eq!(
1909                editor.text(cx),
1910                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) @symbol ")
1911            );
1912            assert!(editor.has_visible_completions_menu());
1913            assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1914        });
1915
1916        editor.update_in(&mut cx, |editor, window, cx| {
1917            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1918        });
1919
1920        let symbol = MentionUri::Symbol {
1921            abs_path: path!("/dir/a/one.txt").into(),
1922            name: "MySymbol".into(),
1923            line_range: 0..=0,
1924        };
1925
1926        let contents = message_editor
1927            .update(&mut cx, |message_editor, cx| {
1928                message_editor
1929                    .mention_set()
1930                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1931            })
1932            .await
1933            .unwrap()
1934            .into_values()
1935            .collect::<Vec<_>>();
1936
1937        {
1938            let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1939                panic!("Unexpected mentions");
1940            };
1941            pretty_assertions::assert_eq!(content, "1");
1942            pretty_assertions::assert_eq!(uri, &symbol);
1943        }
1944
1945        cx.run_until_parked();
1946
1947        editor.read_with(&cx, |editor, cx| {
1948            assert_eq!(
1949                editor.text(cx),
1950                format!(
1951                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1952                    symbol.to_uri(),
1953                )
1954            );
1955        });
1956
1957        // Try to mention an "image" file that will fail to load
1958        cx.simulate_input("@file x.png");
1959
1960        editor.update(&mut cx, |editor, cx| {
1961            assert_eq!(
1962                editor.text(cx),
1963                format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1964            );
1965            assert!(editor.has_visible_completions_menu());
1966            assert_eq!(current_completion_labels(editor), &["x.png "]);
1967        });
1968
1969        editor.update_in(&mut cx, |editor, window, cx| {
1970            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1971        });
1972
1973        // Getting the message contents fails
1974        message_editor
1975            .update(&mut cx, |message_editor, cx| {
1976                message_editor
1977                    .mention_set()
1978                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1979            })
1980            .await
1981            .expect_err("Should fail to load x.png");
1982
1983        cx.run_until_parked();
1984
1985        // Mention was removed
1986        editor.read_with(&cx, |editor, cx| {
1987            assert_eq!(
1988                editor.text(cx),
1989                format!(
1990                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1991                    symbol.to_uri()
1992                )
1993            );
1994        });
1995
1996        // Once more
1997        cx.simulate_input("@file x.png");
1998
1999        editor.update(&mut cx, |editor, cx| {
2000                    assert_eq!(
2001                        editor.text(cx),
2002                        format!("Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2003                    );
2004                    assert!(editor.has_visible_completions_menu());
2005                    assert_eq!(current_completion_labels(editor), &["x.png "]);
2006                });
2007
2008        editor.update_in(&mut cx, |editor, window, cx| {
2009            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2010        });
2011
2012        // This time don't immediately get the contents, just let the confirmed completion settle
2013        cx.run_until_parked();
2014
2015        // Mention was removed
2016        editor.read_with(&cx, |editor, cx| {
2017            assert_eq!(
2018                editor.text(cx),
2019                format!(
2020                    "Lorem [@one.txt]({url_one})  Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2021                    symbol.to_uri()
2022                )
2023            );
2024        });
2025
2026        // Now getting the contents succeeds, because the invalid mention was removed
2027        let contents = message_editor
2028            .update(&mut cx, |message_editor, cx| {
2029                message_editor
2030                    .mention_set()
2031                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2032            })
2033            .await
2034            .unwrap();
2035        assert_eq!(contents.len(), 3);
2036    }
2037
2038    fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2039        let snapshot = editor.buffer().read(cx).snapshot(cx);
2040        editor.display_map.update(cx, |display_map, cx| {
2041            display_map
2042                .snapshot(cx)
2043                .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2044                .map(|fold| fold.range.to_point(&snapshot))
2045                .collect()
2046        })
2047    }
2048
2049    fn current_completion_labels(editor: &Editor) -> Vec<String> {
2050        let completions = editor.current_completions().expect("Missing completions");
2051        completions
2052            .into_iter()
2053            .map(|completion| completion.label.text)
2054            .collect::<Vec<_>>()
2055    }
2056
2057    fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2058        let completions = editor.current_completions().expect("Missing completions");
2059        completions
2060            .into_iter()
2061            .map(|completion| {
2062                (
2063                    completion.label.text,
2064                    completion
2065                        .documentation
2066                        .map(|d| d.text().to_string())
2067                        .unwrap_or_default(),
2068                )
2069            })
2070            .collect::<Vec<_>>()
2071    }
2072
2073    #[gpui::test]
2074    async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2075        init_test(cx);
2076
2077        let fs = FakeFs::new(cx.executor());
2078
2079        // Create a large file that exceeds AUTO_OUTLINE_SIZE
2080        // Using plain text without a configured language, so no outline is available
2081        const LINE: &str = "This is a line of text in the file\n";
2082        let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2083        assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2084
2085        // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2086        let small_content = "fn small_function() { /* small */ }\n";
2087        assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2088
2089        fs.insert_tree(
2090            "/project",
2091            json!({
2092                "large_file.txt": large_content.clone(),
2093                "small_file.txt": small_content,
2094            }),
2095        )
2096        .await;
2097
2098        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2099
2100        let (workspace, cx) =
2101            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2102
2103        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2104        let history = cx
2105            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2106
2107        let message_editor = cx.update(|window, cx| {
2108            cx.new(|cx| {
2109                let editor = MessageEditor::new(
2110                    workspace.downgrade(),
2111                    project.downgrade(),
2112                    thread_store.clone(),
2113                    history.downgrade(),
2114                    None,
2115                    Default::default(),
2116                    Default::default(),
2117                    "Test Agent".into(),
2118                    "Test",
2119                    EditorMode::AutoHeight {
2120                        min_lines: 1,
2121                        max_lines: None,
2122                    },
2123                    window,
2124                    cx,
2125                );
2126                // Enable embedded context so files are actually included
2127                editor
2128                    .prompt_capabilities
2129                    .replace(acp::PromptCapabilities::new().embedded_context(true));
2130                editor
2131            })
2132        });
2133
2134        // Test large file mention
2135        // Get the absolute path using the project's worktree
2136        let large_file_abs_path = project.read_with(cx, |project, cx| {
2137            let worktree = project.worktrees(cx).next().unwrap();
2138            let worktree_root = worktree.read(cx).abs_path();
2139            worktree_root.join("large_file.txt")
2140        });
2141        let large_file_task = message_editor.update(cx, |editor, cx| {
2142            editor.mention_set().update(cx, |set, cx| {
2143                set.confirm_mention_for_file(large_file_abs_path, true, cx)
2144            })
2145        });
2146
2147        let large_file_mention = large_file_task.await.unwrap();
2148        match large_file_mention {
2149            Mention::Text { content, .. } => {
2150                // Should contain some of the content but not all of it
2151                assert!(
2152                    content.contains(LINE),
2153                    "Should contain some of the file content"
2154                );
2155                assert!(
2156                    !content.contains(&LINE.repeat(100)),
2157                    "Should not contain the full file"
2158                );
2159                // Should be much smaller than original
2160                assert!(
2161                    content.len() < large_content.len() / 10,
2162                    "Should be significantly truncated"
2163                );
2164            }
2165            _ => panic!("Expected Text mention for large file"),
2166        }
2167
2168        // Test small file mention
2169        // Get the absolute path using the project's worktree
2170        let small_file_abs_path = project.read_with(cx, |project, cx| {
2171            let worktree = project.worktrees(cx).next().unwrap();
2172            let worktree_root = worktree.read(cx).abs_path();
2173            worktree_root.join("small_file.txt")
2174        });
2175        let small_file_task = message_editor.update(cx, |editor, cx| {
2176            editor.mention_set().update(cx, |set, cx| {
2177                set.confirm_mention_for_file(small_file_abs_path, true, cx)
2178            })
2179        });
2180
2181        let small_file_mention = small_file_task.await.unwrap();
2182        match small_file_mention {
2183            Mention::Text { content, .. } => {
2184                // Should contain the full actual content
2185                assert_eq!(content, small_content);
2186            }
2187            _ => panic!("Expected Text mention for small file"),
2188        }
2189    }
2190
2191    #[gpui::test]
2192    async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2193        init_test(cx);
2194        cx.update(LanguageModelRegistry::test);
2195
2196        let fs = FakeFs::new(cx.executor());
2197        fs.insert_tree("/project", json!({"file": ""})).await;
2198        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2199
2200        let (workspace, cx) =
2201            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2202
2203        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2204        let history = cx
2205            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2206
2207        // Create a thread metadata to insert as summary
2208        let thread_metadata = AgentSessionInfo {
2209            session_id: acp::SessionId::new("thread-123"),
2210            cwd: None,
2211            title: Some("Previous Conversation".into()),
2212            updated_at: Some(chrono::Utc::now()),
2213            meta: None,
2214        };
2215
2216        let message_editor = cx.update(|window, cx| {
2217            cx.new(|cx| {
2218                let mut editor = MessageEditor::new(
2219                    workspace.downgrade(),
2220                    project.downgrade(),
2221                    thread_store.clone(),
2222                    history.downgrade(),
2223                    None,
2224                    Default::default(),
2225                    Default::default(),
2226                    "Test Agent".into(),
2227                    "Test",
2228                    EditorMode::AutoHeight {
2229                        min_lines: 1,
2230                        max_lines: None,
2231                    },
2232                    window,
2233                    cx,
2234                );
2235                editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2236                editor
2237            })
2238        });
2239
2240        // Construct expected values for verification
2241        let expected_uri = MentionUri::Thread {
2242            id: thread_metadata.session_id.clone(),
2243            name: thread_metadata.title.as_ref().unwrap().to_string(),
2244        };
2245        let expected_title = thread_metadata.title.as_ref().unwrap();
2246        let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2247
2248        message_editor.read_with(cx, |editor, cx| {
2249            let text = editor.text(cx);
2250
2251            assert!(
2252                text.contains(&expected_link),
2253                "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2254                expected_link,
2255                text
2256            );
2257
2258            let mentions = editor.mention_set().read(cx).mentions();
2259            assert_eq!(
2260                mentions.len(),
2261                1,
2262                "Expected exactly one mention after inserting thread summary"
2263            );
2264
2265            assert!(
2266                mentions.contains(&expected_uri),
2267                "Expected mentions to contain the thread URI"
2268            );
2269        });
2270    }
2271
2272    #[gpui::test]
2273    async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2274        init_test(cx);
2275        cx.update(LanguageModelRegistry::test);
2276
2277        let fs = FakeFs::new(cx.executor());
2278        fs.insert_tree("/project", json!({"file": ""})).await;
2279        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2280
2281        let (workspace, cx) =
2282            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2283
2284        let thread_store = None;
2285        let history = cx
2286            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2287
2288        let thread_metadata = AgentSessionInfo {
2289            session_id: acp::SessionId::new("thread-123"),
2290            cwd: None,
2291            title: Some("Previous Conversation".into()),
2292            updated_at: Some(chrono::Utc::now()),
2293            meta: None,
2294        };
2295
2296        let message_editor = cx.update(|window, cx| {
2297            cx.new(|cx| {
2298                let mut editor = MessageEditor::new(
2299                    workspace.downgrade(),
2300                    project.downgrade(),
2301                    thread_store.clone(),
2302                    history.downgrade(),
2303                    None,
2304                    Default::default(),
2305                    Default::default(),
2306                    "Test Agent".into(),
2307                    "Test",
2308                    EditorMode::AutoHeight {
2309                        min_lines: 1,
2310                        max_lines: None,
2311                    },
2312                    window,
2313                    cx,
2314                );
2315                editor.insert_thread_summary(thread_metadata, window, cx);
2316                editor
2317            })
2318        });
2319
2320        message_editor.read_with(cx, |editor, cx| {
2321            assert!(
2322                editor.text(cx).is_empty(),
2323                "Expected thread summary to be skipped for external agents"
2324            );
2325            assert!(
2326                editor.mention_set().read(cx).mentions().is_empty(),
2327                "Expected no mentions when thread summary is skipped"
2328            );
2329        });
2330    }
2331
2332    #[gpui::test]
2333    async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2334        init_test(cx);
2335
2336        let fs = FakeFs::new(cx.executor());
2337        fs.insert_tree("/project", json!({"file": ""})).await;
2338        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2339
2340        let (workspace, cx) =
2341            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2342
2343        let thread_store = None;
2344        let history = cx
2345            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2346
2347        let message_editor = cx.update(|window, cx| {
2348            cx.new(|cx| {
2349                MessageEditor::new(
2350                    workspace.downgrade(),
2351                    project.downgrade(),
2352                    thread_store.clone(),
2353                    history.downgrade(),
2354                    None,
2355                    Default::default(),
2356                    Default::default(),
2357                    "Test Agent".into(),
2358                    "Test",
2359                    EditorMode::AutoHeight {
2360                        min_lines: 1,
2361                        max_lines: None,
2362                    },
2363                    window,
2364                    cx,
2365                )
2366            })
2367        });
2368
2369        message_editor.update(cx, |editor, _cx| {
2370            editor
2371                .prompt_capabilities
2372                .replace(acp::PromptCapabilities::new().embedded_context(true));
2373        });
2374
2375        let supported_modes = {
2376            let app = cx.app.borrow();
2377            message_editor.supported_modes(&app)
2378        };
2379
2380        assert!(
2381            !supported_modes.contains(&PromptContextType::Thread),
2382            "Expected thread mode to be hidden when thread mentions are disabled"
2383        );
2384    }
2385
2386    #[gpui::test]
2387    async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2388        init_test(cx);
2389
2390        let fs = FakeFs::new(cx.executor());
2391        fs.insert_tree("/project", json!({"file": ""})).await;
2392        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2393
2394        let (workspace, cx) =
2395            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2396
2397        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2398        let history = cx
2399            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2400
2401        let message_editor = cx.update(|window, cx| {
2402            cx.new(|cx| {
2403                MessageEditor::new(
2404                    workspace.downgrade(),
2405                    project.downgrade(),
2406                    thread_store.clone(),
2407                    history.downgrade(),
2408                    None,
2409                    Default::default(),
2410                    Default::default(),
2411                    "Test Agent".into(),
2412                    "Test",
2413                    EditorMode::AutoHeight {
2414                        min_lines: 1,
2415                        max_lines: None,
2416                    },
2417                    window,
2418                    cx,
2419                )
2420            })
2421        });
2422
2423        message_editor.update(cx, |editor, _cx| {
2424            editor
2425                .prompt_capabilities
2426                .replace(acp::PromptCapabilities::new().embedded_context(true));
2427        });
2428
2429        let supported_modes = {
2430            let app = cx.app.borrow();
2431            message_editor.supported_modes(&app)
2432        };
2433
2434        assert!(
2435            supported_modes.contains(&PromptContextType::Thread),
2436            "Expected thread mode to be visible when enabled"
2437        );
2438    }
2439
2440    #[gpui::test]
2441    async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2442        init_test(cx);
2443
2444        let fs = FakeFs::new(cx.executor());
2445        fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2446            .await;
2447        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2448
2449        let (workspace, cx) =
2450            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2451
2452        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2453        let history = cx
2454            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2455
2456        let message_editor = cx.update(|window, cx| {
2457            cx.new(|cx| {
2458                MessageEditor::new(
2459                    workspace.downgrade(),
2460                    project.downgrade(),
2461                    thread_store.clone(),
2462                    history.downgrade(),
2463                    None,
2464                    Default::default(),
2465                    Default::default(),
2466                    "Test Agent".into(),
2467                    "Test",
2468                    EditorMode::AutoHeight {
2469                        min_lines: 1,
2470                        max_lines: None,
2471                    },
2472                    window,
2473                    cx,
2474                )
2475            })
2476        });
2477        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2478
2479        cx.run_until_parked();
2480
2481        editor.update_in(cx, |editor, window, cx| {
2482            editor.set_text("  \u{A0}してhello world  ", window, cx);
2483        });
2484
2485        let (content, _) = message_editor
2486            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2487            .await
2488            .unwrap();
2489
2490        assert_eq!(content, vec!["してhello world".into()]);
2491    }
2492
2493    #[gpui::test]
2494    async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2495        init_test(cx);
2496
2497        let fs = FakeFs::new(cx.executor());
2498
2499        let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2500
2501        fs.insert_tree(
2502            "/project",
2503            json!({
2504                "src": {
2505                    "main.rs": file_content,
2506                }
2507            }),
2508        )
2509        .await;
2510
2511        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2512
2513        let (workspace, cx) =
2514            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2515
2516        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2517        let history = cx
2518            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2519
2520        let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2521            let workspace_handle = cx.weak_entity();
2522            let message_editor = cx.new(|cx| {
2523                MessageEditor::new(
2524                    workspace_handle,
2525                    project.downgrade(),
2526                    thread_store.clone(),
2527                    history.downgrade(),
2528                    None,
2529                    Default::default(),
2530                    Default::default(),
2531                    "Test Agent".into(),
2532                    "Test",
2533                    EditorMode::AutoHeight {
2534                        max_lines: None,
2535                        min_lines: 1,
2536                    },
2537                    window,
2538                    cx,
2539                )
2540            });
2541            workspace.active_pane().update(cx, |pane, cx| {
2542                pane.add_item(
2543                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2544                    true,
2545                    true,
2546                    None,
2547                    window,
2548                    cx,
2549                );
2550            });
2551            message_editor.read(cx).focus_handle(cx).focus(window, cx);
2552            let editor = message_editor.read(cx).editor().clone();
2553            (message_editor, editor)
2554        });
2555
2556        cx.simulate_input("What is in @file main");
2557
2558        editor.update_in(cx, |editor, window, cx| {
2559            assert!(editor.has_visible_completions_menu());
2560            assert_eq!(editor.text(cx), "What is in @file main");
2561            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2562        });
2563
2564        let content = message_editor
2565            .update(cx, |editor, cx| editor.contents(false, cx))
2566            .await
2567            .unwrap()
2568            .0;
2569
2570        let main_rs_uri = if cfg!(windows) {
2571            "file:///C:/project/src/main.rs"
2572        } else {
2573            "file:///project/src/main.rs"
2574        };
2575
2576        // When embedded context is `false` we should get a resource link
2577        pretty_assertions::assert_eq!(
2578            content,
2579            vec![
2580                "What is in ".into(),
2581                acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2582            ]
2583        );
2584
2585        message_editor.update(cx, |editor, _cx| {
2586            editor
2587                .prompt_capabilities
2588                .replace(acp::PromptCapabilities::new().embedded_context(true))
2589        });
2590
2591        let content = message_editor
2592            .update(cx, |editor, cx| editor.contents(false, cx))
2593            .await
2594            .unwrap()
2595            .0;
2596
2597        // When embedded context is `true` we should get a resource
2598        pretty_assertions::assert_eq!(
2599            content,
2600            vec![
2601                "What is in ".into(),
2602                acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2603                    acp::EmbeddedResourceResource::TextResourceContents(
2604                        acp::TextResourceContents::new(file_content, main_rs_uri)
2605                    )
2606                ))
2607            ]
2608        );
2609    }
2610
2611    #[gpui::test]
2612    async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2613        init_test(cx);
2614
2615        let app_state = cx.update(AppState::test);
2616
2617        cx.update(|cx| {
2618            editor::init(cx);
2619            workspace::init(app_state.clone(), cx);
2620        });
2621
2622        app_state
2623            .fs
2624            .as_fake()
2625            .insert_tree(
2626                path!("/dir"),
2627                json!({
2628                    "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2629                }),
2630            )
2631            .await;
2632
2633        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2634        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2635        let workspace = window.root(cx).unwrap();
2636
2637        let worktree = project.update(cx, |project, cx| {
2638            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2639            assert_eq!(worktrees.len(), 1);
2640            worktrees.pop().unwrap()
2641        });
2642        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2643
2644        let mut cx = VisualTestContext::from_window(*window, cx);
2645
2646        // Open a regular editor with the created file, and select a portion of
2647        // the text that will be used for the selections that are meant to be
2648        // inserted in the agent panel.
2649        let editor = workspace
2650            .update_in(&mut cx, |workspace, window, cx| {
2651                workspace.open_path(
2652                    ProjectPath {
2653                        worktree_id,
2654                        path: rel_path("test.txt").into(),
2655                    },
2656                    None,
2657                    false,
2658                    window,
2659                    cx,
2660                )
2661            })
2662            .await
2663            .unwrap()
2664            .downcast::<Editor>()
2665            .unwrap();
2666
2667        editor.update_in(&mut cx, |editor, window, cx| {
2668            editor.change_selections(Default::default(), window, cx, |selections| {
2669                selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2670            });
2671        });
2672
2673        let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2674        let history = cx
2675            .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2676
2677        // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2678        // to ensure we have a fixed viewport, so we can eventually actually
2679        // place the cursor outside of the visible area.
2680        let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2681            let workspace_handle = cx.weak_entity();
2682            let message_editor = cx.new(|cx| {
2683                MessageEditor::new(
2684                    workspace_handle,
2685                    project.downgrade(),
2686                    thread_store.clone(),
2687                    history.downgrade(),
2688                    None,
2689                    Default::default(),
2690                    Default::default(),
2691                    "Test Agent".into(),
2692                    "Test",
2693                    EditorMode::full(),
2694                    window,
2695                    cx,
2696                )
2697            });
2698            workspace.active_pane().update(cx, |pane, cx| {
2699                pane.add_item(
2700                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2701                    true,
2702                    true,
2703                    None,
2704                    window,
2705                    cx,
2706                );
2707            });
2708
2709            message_editor
2710        });
2711
2712        message_editor.update_in(&mut cx, |message_editor, window, cx| {
2713            message_editor.editor.update(cx, |editor, cx| {
2714                // Update the Agent Panel's Message Editor text to have 100
2715                // lines, ensuring that the cursor is set at line 90 and that we
2716                // then scroll all the way to the top, so the cursor's position
2717                // remains off screen.
2718                let mut lines = String::new();
2719                for _ in 1..=100 {
2720                    lines.push_str(&"Another line in the agent panel's message editor\n");
2721                }
2722                editor.set_text(lines.as_str(), window, cx);
2723                editor.change_selections(Default::default(), window, cx, |selections| {
2724                    selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2725                });
2726                editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2727            });
2728        });
2729
2730        cx.run_until_parked();
2731
2732        // Before proceeding, let's assert that the cursor is indeed off screen,
2733        // otherwise the rest of the test doesn't make sense.
2734        message_editor.update_in(&mut cx, |message_editor, window, cx| {
2735            message_editor.editor.update(cx, |editor, cx| {
2736                let snapshot = editor.snapshot(window, cx);
2737                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2738                let scroll_top = snapshot.scroll_position().y as u32;
2739                let visible_lines = editor.visible_line_count().unwrap() as u32;
2740                let visible_range = scroll_top..(scroll_top + visible_lines);
2741
2742                assert!(!visible_range.contains(&cursor_row));
2743            })
2744        });
2745
2746        // Now let's insert the selection in the Agent Panel's editor and
2747        // confirm that, after the insertion, the cursor is now in the visible
2748        // range.
2749        message_editor.update_in(&mut cx, |message_editor, window, cx| {
2750            message_editor.insert_selections(window, cx);
2751        });
2752
2753        cx.run_until_parked();
2754
2755        message_editor.update_in(&mut cx, |message_editor, window, cx| {
2756            message_editor.editor.update(cx, |editor, cx| {
2757                let snapshot = editor.snapshot(window, cx);
2758                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2759                let scroll_top = snapshot.scroll_position().y as u32;
2760                let visible_lines = editor.visible_line_count().unwrap() as u32;
2761                let visible_range = scroll_top..(scroll_top + visible_lines);
2762
2763                assert!(visible_range.contains(&cursor_row));
2764            })
2765        });
2766    }
2767}