message_editor.rs

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