mention_set.rs

   1use acp_thread::{MentionUri, selection_name};
   2use agent::{ThreadStore, outline};
   3use agent_client_protocol as acp;
   4use agent_servers::{AgentServer, AgentServerDelegate};
   5use anyhow::{Context as _, Result, anyhow};
   6use assistant_slash_commands::{codeblock_fence_for_path, collect_diagnostics_output};
   7use collections::{HashMap, HashSet};
   8use editor::{
   9    Anchor, Editor, EditorSnapshot, ExcerptId, FoldPlaceholder, ToOffset,
  10    display_map::{Crease, CreaseId, CreaseMetadata, FoldId},
  11    scroll::Autoscroll,
  12};
  13use futures::{AsyncReadExt as _, FutureExt as _, future::Shared};
  14use gpui::{
  15    AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, Image, ImageFormat, Img,
  16    SharedString, Task, WeakEntity,
  17};
  18use http_client::{AsyncBody, HttpClientWithUrl};
  19use itertools::Either;
  20use language::Buffer;
  21use language_model::LanguageModelImage;
  22use multi_buffer::MultiBufferRow;
  23use postage::stream::Stream as _;
  24use project::{Project, ProjectItem, ProjectPath, Worktree};
  25use prompt_store::{PromptId, PromptStore};
  26use rope::Point;
  27use std::{
  28    cell::RefCell,
  29    ffi::OsStr,
  30    fmt::Write,
  31    ops::{Range, RangeInclusive},
  32    path::{Path, PathBuf},
  33    rc::Rc,
  34    sync::Arc,
  35};
  36use text::OffsetRangeExt;
  37use ui::{Disclosure, Toggleable, prelude::*};
  38use util::{ResultExt, debug_panic, rel_path::RelPath};
  39use workspace::{Workspace, notifications::NotifyResultExt as _};
  40
  41use crate::ui::MentionCrease;
  42
  43pub type MentionTask = Shared<Task<Result<Mention, String>>>;
  44
  45#[derive(Debug, Clone, Eq, PartialEq)]
  46pub enum Mention {
  47    Text {
  48        content: String,
  49        tracked_buffers: Vec<Entity<Buffer>>,
  50    },
  51    Image(MentionImage),
  52    Link,
  53}
  54
  55#[derive(Clone, Debug, Eq, PartialEq)]
  56pub struct MentionImage {
  57    pub data: SharedString,
  58    pub format: ImageFormat,
  59}
  60
  61pub struct MentionSet {
  62    project: WeakEntity<Project>,
  63    thread_store: Option<Entity<ThreadStore>>,
  64    prompt_store: Option<Entity<PromptStore>>,
  65    mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
  66}
  67
  68impl MentionSet {
  69    pub fn new(
  70        project: WeakEntity<Project>,
  71        thread_store: Option<Entity<ThreadStore>>,
  72        prompt_store: Option<Entity<PromptStore>>,
  73    ) -> Self {
  74        Self {
  75            project,
  76            thread_store,
  77            prompt_store,
  78            mentions: HashMap::default(),
  79        }
  80    }
  81
  82    pub fn contents(
  83        &self,
  84        full_mention_content: bool,
  85        cx: &mut App,
  86    ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
  87        let Some(project) = self.project.upgrade() else {
  88            return Task::ready(Err(anyhow!("Project not found")));
  89        };
  90        let mentions = self.mentions.clone();
  91        cx.spawn(async move |cx| {
  92            let mut contents = HashMap::default();
  93            for (crease_id, (mention_uri, task)) in mentions {
  94                let content = if full_mention_content
  95                    && let MentionUri::Directory { abs_path } = &mention_uri
  96                {
  97                    cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))
  98                        .await?
  99                } else {
 100                    task.await.map_err(|e| anyhow!("{e}"))?
 101                };
 102
 103                contents.insert(crease_id, (mention_uri, content));
 104            }
 105            Ok(contents)
 106        })
 107    }
 108
 109    pub fn remove_invalid(&mut self, snapshot: &EditorSnapshot) {
 110        for (crease_id, crease) in snapshot.crease_snapshot.creases() {
 111            if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
 112                self.mentions.remove(&crease_id);
 113            }
 114        }
 115    }
 116
 117    pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) {
 118        self.mentions.insert(crease_id, (uri, task));
 119    }
 120
 121    /// Creates the appropriate confirmation task for a mention based on its URI type.
 122    /// This is used when pasting mention links to properly load their content.
 123    pub fn confirm_mention_for_uri(
 124        &mut self,
 125        mention_uri: MentionUri,
 126        supports_images: bool,
 127        http_client: Arc<HttpClientWithUrl>,
 128        cx: &mut Context<Self>,
 129    ) -> Task<Result<Mention>> {
 130        match mention_uri {
 131            MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, http_client, cx),
 132            MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
 133            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
 134            MentionUri::TextThread { .. } => {
 135                Task::ready(Err(anyhow!("Text thread mentions are no longer supported")))
 136            }
 137            MentionUri::File { abs_path } => {
 138                self.confirm_mention_for_file(abs_path, supports_images, cx)
 139            }
 140            MentionUri::Symbol {
 141                abs_path,
 142                line_range,
 143                ..
 144            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
 145            MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
 146            MentionUri::Diagnostics {
 147                include_errors,
 148                include_warnings,
 149            } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
 150            MentionUri::GitDiff { base_ref } => {
 151                self.confirm_mention_for_git_diff(base_ref.into(), cx)
 152            }
 153            MentionUri::Selection {
 154                abs_path: Some(abs_path),
 155                line_range,
 156            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
 157            MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
 158                "Untitled buffer selection mentions are not supported for paste"
 159            ))),
 160            MentionUri::PastedImage
 161            | MentionUri::TerminalSelection { .. }
 162            | MentionUri::MergeConflict { .. } => {
 163                Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
 164            }
 165        }
 166    }
 167
 168    pub fn remove_mention(&mut self, crease_id: &CreaseId) {
 169        self.mentions.remove(crease_id);
 170    }
 171
 172    pub fn creases(&self) -> HashSet<CreaseId> {
 173        self.mentions.keys().cloned().collect()
 174    }
 175
 176    pub fn mentions(&self) -> HashSet<MentionUri> {
 177        self.mentions.values().map(|(uri, _)| uri.clone()).collect()
 178    }
 179
 180    pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
 181        self.mentions = mentions;
 182    }
 183
 184    pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
 185        self.mentions.drain()
 186    }
 187
 188    #[cfg(test)]
 189    pub fn has_thread_store(&self) -> bool {
 190        self.thread_store.is_some()
 191    }
 192
 193    pub fn confirm_mention_completion(
 194        &mut self,
 195        crease_text: SharedString,
 196        start: text::Anchor,
 197        content_len: usize,
 198        mention_uri: MentionUri,
 199        supports_images: bool,
 200        editor: Entity<Editor>,
 201        workspace: &Entity<Workspace>,
 202        window: &mut Window,
 203        cx: &mut Context<Self>,
 204    ) -> Task<()> {
 205        let Some(project) = self.project.upgrade() else {
 206            return Task::ready(());
 207        };
 208
 209        let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
 210        let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else {
 211            return Task::ready(());
 212        };
 213        let excerpt_id = start_anchor.excerpt_id;
 214        let end_anchor = snapshot.buffer_snapshot().anchor_before(
 215            start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize,
 216        );
 217
 218        let crease = if let MentionUri::File { abs_path } = &mention_uri
 219            && let Some(extension) = abs_path.extension()
 220            && let Some(extension) = extension.to_str()
 221            && Img::extensions().contains(&extension)
 222            && !extension.contains("svg")
 223        {
 224            let Some(project_path) = project
 225                .read(cx)
 226                .project_path_for_absolute_path(&abs_path, cx)
 227            else {
 228                log::error!("project path not found");
 229                return Task::ready(());
 230            };
 231            let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx));
 232            let image = cx
 233                .spawn(async move |_, cx| {
 234                    let image = image_task.await.map_err(|e| e.to_string())?;
 235                    let image = image.update(cx, |image, _| image.image.clone());
 236                    Ok(image)
 237                })
 238                .shared();
 239            insert_crease_for_mention(
 240                excerpt_id,
 241                start,
 242                content_len,
 243                mention_uri.name().into(),
 244                IconName::Image.path().into(),
 245                mention_uri.tooltip_text(),
 246                Some(mention_uri.clone()),
 247                Some(workspace.downgrade()),
 248                Some(image),
 249                editor.clone(),
 250                window,
 251                cx,
 252            )
 253        } else {
 254            insert_crease_for_mention(
 255                excerpt_id,
 256                start,
 257                content_len,
 258                crease_text,
 259                mention_uri.icon_path(cx),
 260                mention_uri.tooltip_text(),
 261                Some(mention_uri.clone()),
 262                Some(workspace.downgrade()),
 263                None,
 264                editor.clone(),
 265                window,
 266                cx,
 267            )
 268        };
 269        let Some((crease_id, tx)) = crease else {
 270            return Task::ready(());
 271        };
 272
 273        let task = match mention_uri.clone() {
 274            MentionUri::Fetch { url } => {
 275                self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx)
 276            }
 277            MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
 278            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
 279            MentionUri::TextThread { .. } => {
 280                Task::ready(Err(anyhow!("Text thread mentions are no longer supported")))
 281            }
 282            MentionUri::File { abs_path } => {
 283                self.confirm_mention_for_file(abs_path, supports_images, cx)
 284            }
 285            MentionUri::Symbol {
 286                abs_path,
 287                line_range,
 288                ..
 289            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
 290            MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
 291            MentionUri::Diagnostics {
 292                include_errors,
 293                include_warnings,
 294            } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
 295            MentionUri::PastedImage => {
 296                debug_panic!("pasted image URI should not be included in completions");
 297                Task::ready(Err(anyhow!(
 298                    "pasted imaged URI should not be included in completions"
 299                )))
 300            }
 301            MentionUri::Selection { .. } => {
 302                debug_panic!("unexpected selection URI");
 303                Task::ready(Err(anyhow!("unexpected selection URI")))
 304            }
 305            MentionUri::TerminalSelection { .. } => {
 306                debug_panic!("unexpected terminal URI");
 307                Task::ready(Err(anyhow!("unexpected terminal URI")))
 308            }
 309            MentionUri::GitDiff { base_ref } => {
 310                self.confirm_mention_for_git_diff(base_ref.into(), cx)
 311            }
 312            MentionUri::MergeConflict { .. } => {
 313                debug_panic!("unexpected merge conflict URI");
 314                Task::ready(Err(anyhow!("unexpected merge conflict URI")))
 315            }
 316        };
 317        let task = cx
 318            .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
 319            .shared();
 320        self.mentions.insert(crease_id, (mention_uri, task.clone()));
 321
 322        // Notify the user if we failed to load the mentioned context
 323        let workspace = workspace.downgrade();
 324        cx.spawn(async move |this, mut cx| {
 325            let result = task.await.notify_workspace_async_err(workspace, &mut cx);
 326            drop(tx);
 327            if result.is_none() {
 328                this.update(cx, |this, cx| {
 329                    editor.update(cx, |editor, cx| {
 330                        // Remove mention
 331                        editor.edit([(start_anchor..end_anchor, "")], cx);
 332                    });
 333                    this.mentions.remove(&crease_id);
 334                })
 335                .ok();
 336            }
 337        })
 338    }
 339
 340    pub fn confirm_mention_for_file(
 341        &self,
 342        abs_path: PathBuf,
 343        supports_images: bool,
 344        cx: &mut Context<Self>,
 345    ) -> Task<Result<Mention>> {
 346        let Some(project) = self.project.upgrade() else {
 347            return Task::ready(Err(anyhow!("project not found")));
 348        };
 349
 350        let Some(project_path) = project
 351            .read(cx)
 352            .project_path_for_absolute_path(&abs_path, cx)
 353        else {
 354            return Task::ready(Err(anyhow!("project path not found")));
 355        };
 356        let extension = abs_path
 357            .extension()
 358            .and_then(OsStr::to_str)
 359            .unwrap_or_default();
 360
 361        if Img::extensions().contains(&extension) && !extension.contains("svg") {
 362            if !supports_images {
 363                return Task::ready(Err(anyhow!("This model does not support images yet")));
 364            }
 365            let task = project.update(cx, |project, cx| project.open_image(project_path, cx));
 366            return cx.spawn(async move |_, cx| {
 367                let image = task.await?;
 368                let image = image.update(cx, |image, _| image.image.clone());
 369                let image = cx
 370                    .update(|cx| LanguageModelImage::from_image(image, cx))
 371                    .await;
 372                if let Some(image) = image {
 373                    Ok(Mention::Image(MentionImage {
 374                        data: image.source,
 375                        format: LanguageModelImage::FORMAT,
 376                    }))
 377                } else {
 378                    Err(anyhow!("Failed to convert image"))
 379                }
 380            });
 381        }
 382
 383        let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
 384        cx.spawn(async move |_, cx| {
 385            let buffer = buffer.await?;
 386            let buffer_content = outline::get_buffer_content_or_outline(
 387                buffer.clone(),
 388                Some(&abs_path.to_string_lossy()),
 389                &cx,
 390            )
 391            .await?;
 392
 393            Ok(Mention::Text {
 394                content: buffer_content.text,
 395                tracked_buffers: vec![buffer],
 396            })
 397        })
 398    }
 399
 400    fn confirm_mention_for_fetch(
 401        &self,
 402        url: url::Url,
 403        http_client: Arc<HttpClientWithUrl>,
 404        cx: &mut Context<Self>,
 405    ) -> Task<Result<Mention>> {
 406        cx.background_executor().spawn(async move {
 407            let content = fetch_url_content(http_client, url.to_string()).await?;
 408            Ok(Mention::Text {
 409                content,
 410                tracked_buffers: Vec::new(),
 411            })
 412        })
 413    }
 414
 415    fn confirm_mention_for_symbol(
 416        &self,
 417        abs_path: PathBuf,
 418        line_range: RangeInclusive<u32>,
 419        cx: &mut Context<Self>,
 420    ) -> Task<Result<Mention>> {
 421        let Some(project) = self.project.upgrade() else {
 422            return Task::ready(Err(anyhow!("project not found")));
 423        };
 424        let Some(project_path) = project
 425            .read(cx)
 426            .project_path_for_absolute_path(&abs_path, cx)
 427        else {
 428            return Task::ready(Err(anyhow!("project path not found")));
 429        };
 430        let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
 431        cx.spawn(async move |_, cx| {
 432            let buffer = buffer.await?;
 433            let mention = buffer.update(cx, |buffer, cx| {
 434                let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
 435                let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
 436                let content = buffer.text_for_range(start..end).collect();
 437                Mention::Text {
 438                    content,
 439                    tracked_buffers: vec![cx.entity()],
 440                }
 441            });
 442            Ok(mention)
 443        })
 444    }
 445
 446    fn confirm_mention_for_rule(
 447        &mut self,
 448        id: PromptId,
 449        cx: &mut Context<Self>,
 450    ) -> Task<Result<Mention>> {
 451        let Some(prompt_store) = self.prompt_store.as_ref() else {
 452            return Task::ready(Err(anyhow!("Missing prompt store")));
 453        };
 454        let prompt = prompt_store.read(cx).load(id, cx);
 455        cx.spawn(async move |_, _| {
 456            let prompt = prompt.await?;
 457            Ok(Mention::Text {
 458                content: prompt,
 459                tracked_buffers: Vec::new(),
 460            })
 461        })
 462    }
 463
 464    pub fn confirm_mention_for_selection(
 465        &mut self,
 466        source_range: Range<text::Anchor>,
 467        selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
 468        editor: Entity<Editor>,
 469        window: &mut Window,
 470        cx: &mut Context<Self>,
 471    ) {
 472        let Some(project) = self.project.upgrade() else {
 473            return;
 474        };
 475
 476        let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
 477        let Some(start) = snapshot.as_singleton_anchor(source_range.start) else {
 478            return;
 479        };
 480
 481        let offset = start.to_offset(&snapshot);
 482
 483        for (buffer, selection_range, range_to_fold) in selections {
 484            let range = snapshot.anchor_after(offset + range_to_fold.start)
 485                ..snapshot.anchor_after(offset + range_to_fold.end);
 486
 487            let abs_path = buffer
 488                .read(cx)
 489                .project_path(cx)
 490                .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx));
 491            let snapshot = buffer.read(cx).snapshot();
 492
 493            let text = snapshot
 494                .text_for_range(selection_range.clone())
 495                .collect::<String>();
 496            let point_range = selection_range.to_point(&snapshot);
 497            let line_range = point_range.start.row..=point_range.end.row;
 498
 499            let uri = MentionUri::Selection {
 500                abs_path: abs_path.clone(),
 501                line_range: line_range.clone(),
 502            };
 503            let crease = crease_for_mention(
 504                selection_name(abs_path.as_deref(), &line_range).into(),
 505                uri.icon_path(cx),
 506                uri.tooltip_text(),
 507                range,
 508                editor.downgrade(),
 509            );
 510
 511            let crease_id = editor.update(cx, |editor, cx| {
 512                let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
 513                editor.fold_creases(vec![crease], false, window, cx);
 514                crease_ids.first().copied().unwrap()
 515            });
 516
 517            self.mentions.insert(
 518                crease_id,
 519                (
 520                    uri,
 521                    Task::ready(Ok(Mention::Text {
 522                        content: text,
 523                        tracked_buffers: vec![buffer],
 524                    }))
 525                    .shared(),
 526                ),
 527            );
 528        }
 529
 530        // Take this explanation with a grain of salt but, with creases being
 531        // inserted, GPUI's recomputes the editor layout in the next frames, so
 532        // directly calling `editor.request_autoscroll` wouldn't work as
 533        // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
 534        // ensure that the layout has been recalculated so that the autoscroll
 535        // request actually shows the cursor's new position.
 536        cx.on_next_frame(window, move |_, window, cx| {
 537            cx.on_next_frame(window, move |_, _, cx| {
 538                editor.update(cx, |editor, cx| {
 539                    editor.request_autoscroll(Autoscroll::fit(), cx)
 540                });
 541            });
 542        });
 543    }
 544
 545    fn confirm_mention_for_thread(
 546        &mut self,
 547        id: acp::SessionId,
 548        cx: &mut Context<Self>,
 549    ) -> Task<Result<Mention>> {
 550        let Some(thread_store) = self.thread_store.clone() else {
 551            return Task::ready(Err(anyhow!(
 552                "Thread mentions are only supported for the native agent"
 553            )));
 554        };
 555        let Some(project) = self.project.upgrade() else {
 556            return Task::ready(Err(anyhow!("project not found")));
 557        };
 558
 559        let server = Rc::new(agent::NativeAgentServer::new(
 560            project.read(cx).fs().clone(),
 561            thread_store,
 562        ));
 563        let delegate =
 564            AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None);
 565        let connection = server.connect(delegate, project.clone(), cx);
 566        cx.spawn(async move |_, cx| {
 567            let agent = connection.await?;
 568            let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
 569            let summary = agent
 570                .0
 571                .update(cx, |agent, cx| {
 572                    agent.thread_summary(id, project.clone(), cx)
 573                })
 574                .await?;
 575            Ok(Mention::Text {
 576                content: summary.to_string(),
 577                tracked_buffers: Vec::new(),
 578            })
 579        })
 580    }
 581
 582    fn confirm_mention_for_diagnostics(
 583        &self,
 584        include_errors: bool,
 585        include_warnings: bool,
 586        cx: &mut Context<Self>,
 587    ) -> Task<Result<Mention>> {
 588        let Some(project) = self.project.upgrade() else {
 589            return Task::ready(Err(anyhow!("project not found")));
 590        };
 591
 592        let diagnostics_task = collect_diagnostics_output(
 593            project,
 594            assistant_slash_commands::Options {
 595                include_errors,
 596                include_warnings,
 597                path_matcher: None,
 598            },
 599            cx,
 600        );
 601        cx.spawn(async move |_, _| {
 602            let output = diagnostics_task.await?;
 603            let content = output
 604                .map(|output| output.text)
 605                .unwrap_or_else(|| "No diagnostics found.".into());
 606            Ok(Mention::Text {
 607                content,
 608                tracked_buffers: Vec::new(),
 609            })
 610        })
 611    }
 612
 613    pub fn confirm_mention_for_git_diff(
 614        &self,
 615        base_ref: SharedString,
 616        cx: &mut Context<Self>,
 617    ) -> Task<Result<Mention>> {
 618        let Some(project) = self.project.upgrade() else {
 619            return Task::ready(Err(anyhow!("project not found")));
 620        };
 621
 622        let Some(repo) = project.read(cx).active_repository(cx) else {
 623            return Task::ready(Err(anyhow!("no active repository")));
 624        };
 625
 626        let diff_receiver = repo.update(cx, |repo, cx| {
 627            repo.diff(
 628                git::repository::DiffType::MergeBase { base_ref: base_ref },
 629                cx,
 630            )
 631        });
 632
 633        cx.spawn(async move |_, _| {
 634            let diff_text = diff_receiver.await??;
 635            if diff_text.is_empty() {
 636                Ok(Mention::Text {
 637                    content: "No changes found in branch diff.".into(),
 638                    tracked_buffers: Vec::new(),
 639                })
 640            } else {
 641                Ok(Mention::Text {
 642                    content: diff_text,
 643                    tracked_buffers: Vec::new(),
 644                })
 645            }
 646        })
 647    }
 648}
 649
 650#[cfg(test)]
 651mod tests {
 652    use super::*;
 653
 654    use fs::FakeFs;
 655    use gpui::TestAppContext;
 656    use project::Project;
 657    use prompt_store;
 658    use release_channel;
 659    use semver::Version;
 660    use serde_json::json;
 661    use settings::SettingsStore;
 662    use std::path::Path;
 663    use theme;
 664    use util::path;
 665
 666    fn init_test(cx: &mut TestAppContext) {
 667        let settings_store = cx.update(SettingsStore::test);
 668        cx.set_global(settings_store);
 669        cx.update(|cx| {
 670            theme::init(theme::LoadThemes::JustBase, cx);
 671            release_channel::init(Version::new(0, 0, 0), cx);
 672            prompt_store::init(cx);
 673        });
 674    }
 675
 676    #[gpui::test]
 677    async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
 678        init_test(cx);
 679
 680        let fs = FakeFs::new(cx.executor());
 681        fs.insert_tree("/project", json!({"file": ""})).await;
 682        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
 683        let thread_store = None;
 684        let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
 685
 686        let task = mention_set.update(cx, |mention_set, cx| {
 687            mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
 688        });
 689
 690        let error = task.await.unwrap_err();
 691        assert!(
 692            error
 693                .to_string()
 694                .contains("Thread mentions are only supported for the native agent"),
 695            "Unexpected error: {error:#}"
 696        );
 697    }
 698
 699    #[gpui::test]
 700    async fn test_selection_mentions_supported_for_paste(cx: &mut TestAppContext) {
 701        init_test(cx);
 702
 703        let fs = FakeFs::new(cx.executor());
 704        fs.insert_tree(
 705            "/project",
 706            json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
 707        )
 708        .await;
 709        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
 710        let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None, None));
 711
 712        let mention_task = mention_set.update(cx, |mention_set, cx| {
 713            let http_client = project.read(cx).client().http_client();
 714            mention_set.confirm_mention_for_uri(
 715                MentionUri::Selection {
 716                    abs_path: Some(path!("/project/file.rs").into()),
 717                    line_range: 1..=2,
 718                },
 719                false,
 720                http_client,
 721                cx,
 722            )
 723        });
 724
 725        let mention = mention_task.await.unwrap();
 726        match mention {
 727            Mention::Text {
 728                content,
 729                tracked_buffers,
 730            } => {
 731                assert_eq!(content, "line 2\nline 3\n");
 732                assert_eq!(tracked_buffers.len(), 1);
 733            }
 734            other => panic!("Expected selection mention to resolve as text, got {other:?}"),
 735        }
 736    }
 737}
 738
 739/// Inserts a list of images into the editor as context mentions.
 740/// This is the shared implementation used by both paste and file picker operations.
 741pub(crate) async fn insert_images_as_context(
 742    images: Vec<(gpui::Image, SharedString)>,
 743    editor: Entity<Editor>,
 744    mention_set: Entity<MentionSet>,
 745    workspace: WeakEntity<Workspace>,
 746    cx: &mut gpui::AsyncWindowContext,
 747) {
 748    if images.is_empty() {
 749        return;
 750    }
 751
 752    let replacement_text = MentionUri::PastedImage.as_link().to_string();
 753
 754    for (image, name) in images {
 755        let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
 756            .update_in(cx, |editor, window, cx| {
 757                let snapshot = editor.snapshot(window, cx);
 758                let (excerpt_id, _, buffer_snapshot) =
 759                    snapshot.buffer_snapshot().as_singleton().unwrap();
 760
 761                let cursor_anchor = editor.selections.newest_anchor().start.text_anchor;
 762                let text_anchor = cursor_anchor.bias_left(&buffer_snapshot);
 763                let multibuffer_anchor = snapshot
 764                    .buffer_snapshot()
 765                    .anchor_in_excerpt(excerpt_id, text_anchor);
 766                editor.insert(&format!("{replacement_text} "), window, cx);
 767                (excerpt_id, text_anchor, multibuffer_anchor)
 768            })
 769            .ok()
 770        else {
 771            break;
 772        };
 773
 774        let content_len = replacement_text.len();
 775        let Some(start_anchor) = multibuffer_anchor else {
 776            continue;
 777        };
 778        let end_anchor = editor.update(cx, |editor, cx| {
 779            let snapshot = editor.buffer().read(cx).snapshot(cx);
 780            snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
 781        });
 782        let image = Arc::new(image);
 783        let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
 784            insert_crease_for_mention(
 785                excerpt_id,
 786                text_anchor,
 787                content_len,
 788                name.clone(),
 789                IconName::Image.path().into(),
 790                None,
 791                None,
 792                None,
 793                Some(Task::ready(Ok(image.clone())).shared()),
 794                editor.clone(),
 795                window,
 796                cx,
 797            )
 798        }) else {
 799            continue;
 800        };
 801        let task = cx
 802            .spawn(async move |cx| {
 803                let image = cx
 804                    .update(|_, cx| LanguageModelImage::from_image(image, cx))
 805                    .map_err(|e| e.to_string())?
 806                    .await;
 807                drop(tx);
 808                if let Some(image) = image {
 809                    Ok(Mention::Image(MentionImage {
 810                        data: image.source,
 811                        format: LanguageModelImage::FORMAT,
 812                    }))
 813                } else {
 814                    Err("Failed to convert image".into())
 815                }
 816            })
 817            .shared();
 818
 819        mention_set.update(cx, |mention_set, _cx| {
 820            mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
 821        });
 822
 823        if task
 824            .await
 825            .notify_workspace_async_err(workspace.clone(), cx)
 826            .is_none()
 827        {
 828            editor.update(cx, |editor, cx| {
 829                editor.edit([(start_anchor..end_anchor, "")], cx);
 830            });
 831            mention_set.update(cx, |mention_set, _cx| {
 832                mention_set.remove_mention(&crease_id)
 833            });
 834        }
 835    }
 836}
 837
 838fn image_format_from_external_content(format: image::ImageFormat) -> Option<ImageFormat> {
 839    match format {
 840        image::ImageFormat::Png => Some(ImageFormat::Png),
 841        image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg),
 842        image::ImageFormat::WebP => Some(ImageFormat::Webp),
 843        image::ImageFormat::Gif => Some(ImageFormat::Gif),
 844        image::ImageFormat::Bmp => Some(ImageFormat::Bmp),
 845        image::ImageFormat::Tiff => Some(ImageFormat::Tiff),
 846        image::ImageFormat::Ico => Some(ImageFormat::Ico),
 847        _ => None,
 848    }
 849}
 850
 851pub(crate) fn load_external_image_from_path(
 852    path: &Path,
 853    default_name: &SharedString,
 854) -> Option<(Image, SharedString)> {
 855    let content = std::fs::read(path).ok()?;
 856    let format = image::guess_format(&content)
 857        .ok()
 858        .and_then(image_format_from_external_content)?;
 859    let name = path
 860        .file_name()
 861        .and_then(|name| name.to_str())
 862        .map(|name| SharedString::from(name.to_owned()))
 863        .unwrap_or_else(|| default_name.clone());
 864
 865    Some((Image::from_bytes(format, content), name))
 866}
 867
 868pub(crate) fn paste_images_as_context(
 869    editor: Entity<Editor>,
 870    mention_set: Entity<MentionSet>,
 871    workspace: WeakEntity<Workspace>,
 872    window: &mut Window,
 873    cx: &mut App,
 874) -> Option<Task<()>> {
 875    let clipboard = cx.read_from_clipboard()?;
 876
 877    // Only handle paste if the first clipboard entry is an image or file path.
 878    // If text comes first, return None so the caller falls through to text paste.
 879    // This respects the priority order set by the source application.
 880    if matches!(
 881        clipboard.entries().first(),
 882        Some(ClipboardEntry::String(_)) | None
 883    ) {
 884        return None;
 885    }
 886
 887    Some(window.spawn(cx, async move |mut cx| {
 888        use itertools::Itertools;
 889        let default_name: SharedString = MentionUri::PastedImage.name().into();
 890        let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard
 891            .into_entries()
 892            .filter_map(|entry| match entry {
 893                ClipboardEntry::Image(image) => Some(Either::Left((image, default_name.clone()))),
 894                ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
 895                _ => None,
 896            })
 897            .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
 898
 899        if !paths.is_empty() {
 900            images.extend(
 901                cx.background_spawn(async move {
 902                    paths
 903                        .into_iter()
 904                        .flat_map(|paths| paths.paths().to_owned())
 905                        .filter_map(|path| load_external_image_from_path(&path, &default_name))
 906                        .collect::<Vec<_>>()
 907                })
 908                .await,
 909            );
 910        }
 911
 912        if !images.is_empty() {
 913            insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
 914        }
 915    }))
 916}
 917
 918pub(crate) fn insert_crease_for_mention(
 919    excerpt_id: ExcerptId,
 920    anchor: text::Anchor,
 921    content_len: usize,
 922    crease_label: SharedString,
 923    crease_icon: SharedString,
 924    crease_tooltip: Option<SharedString>,
 925    mention_uri: Option<MentionUri>,
 926    workspace: Option<WeakEntity<Workspace>>,
 927    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
 928    editor: Entity<Editor>,
 929    window: &mut Window,
 930    cx: &mut App,
 931) -> Option<(CreaseId, postage::barrier::Sender)> {
 932    let (tx, rx) = postage::barrier::channel();
 933
 934    let crease_id = editor.update(cx, |editor, cx| {
 935        let snapshot = editor.buffer().read(cx).snapshot(cx);
 936
 937        let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
 938
 939        let start = start.bias_right(&snapshot);
 940        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
 941
 942        let placeholder = FoldPlaceholder {
 943            render: render_mention_fold_button(
 944                crease_label.clone(),
 945                crease_icon.clone(),
 946                crease_tooltip,
 947                mention_uri.clone(),
 948                workspace.clone(),
 949                start..end,
 950                rx,
 951                image,
 952                cx.weak_entity(),
 953                cx,
 954            ),
 955            merge_adjacent: false,
 956            ..Default::default()
 957        };
 958
 959        let crease = Crease::Inline {
 960            range: start..end,
 961            placeholder,
 962            render_toggle: None,
 963            render_trailer: None,
 964            metadata: Some(CreaseMetadata {
 965                label: crease_label,
 966                icon_path: crease_icon,
 967            }),
 968        };
 969
 970        let ids = editor.insert_creases(vec![crease.clone()], cx);
 971        editor.fold_creases(vec![crease], false, window, cx);
 972
 973        Some(ids[0])
 974    })?;
 975
 976    Some((crease_id, tx))
 977}
 978
 979pub(crate) fn crease_for_mention(
 980    label: SharedString,
 981    icon_path: SharedString,
 982    tooltip: Option<SharedString>,
 983    range: Range<Anchor>,
 984    editor_entity: WeakEntity<Editor>,
 985) -> Crease<Anchor> {
 986    let placeholder = FoldPlaceholder {
 987        render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity),
 988        merge_adjacent: false,
 989        ..Default::default()
 990    };
 991
 992    let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
 993
 994    Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
 995        .with_metadata(CreaseMetadata { icon_path, label })
 996}
 997
 998fn render_fold_icon_button(
 999    icon_path: SharedString,
1000    label: SharedString,
1001    tooltip: Option<SharedString>,
1002    editor: WeakEntity<Editor>,
1003) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1004    Arc::new({
1005        move |fold_id, fold_range, cx| {
1006            let is_in_text_selection = editor
1007                .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
1008                .unwrap_or_default();
1009
1010            MentionCrease::new(fold_id, icon_path.clone(), label.clone())
1011                .is_toggled(is_in_text_selection)
1012                .when_some(tooltip.clone(), |this, tooltip_text| {
1013                    this.tooltip(tooltip_text)
1014                })
1015                .into_any_element()
1016        }
1017    })
1018}
1019
1020fn fold_toggle(
1021    name: &'static str,
1022) -> impl Fn(
1023    MultiBufferRow,
1024    bool,
1025    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
1026    &mut Window,
1027    &mut App,
1028) -> AnyElement {
1029    move |row, is_folded, fold, _window, _cx| {
1030        Disclosure::new((name, row.0 as u64), !is_folded)
1031            .toggle_state(is_folded)
1032            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1033            .into_any_element()
1034    }
1035}
1036
1037fn full_mention_for_directory(
1038    project: &Entity<Project>,
1039    abs_path: &Path,
1040    cx: &mut App,
1041) -> Task<Result<Mention>> {
1042    fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1043        let mut files = Vec::new();
1044
1045        for entry in worktree.child_entries(path) {
1046            if entry.is_dir() {
1047                files.extend(collect_files_in_path(worktree, &entry.path));
1048            } else if entry.is_file() {
1049                files.push((
1050                    entry.path.clone(),
1051                    worktree
1052                        .full_path(&entry.path)
1053                        .to_string_lossy()
1054                        .to_string(),
1055                ));
1056            }
1057        }
1058
1059        files
1060    }
1061
1062    let Some(project_path) = project
1063        .read(cx)
1064        .project_path_for_absolute_path(&abs_path, cx)
1065    else {
1066        return Task::ready(Err(anyhow!("project path not found")));
1067    };
1068    let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1069        return Task::ready(Err(anyhow!("project entry not found")));
1070    };
1071    let directory_path = entry.path.clone();
1072    let worktree_id = project_path.worktree_id;
1073    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1074        return Task::ready(Err(anyhow!("worktree not found")));
1075    };
1076    let project = project.clone();
1077    cx.spawn(async move |cx| {
1078        let file_paths = worktree.read_with(cx, |worktree, _cx| {
1079            collect_files_in_path(worktree, &directory_path)
1080        });
1081        let descendants_future = cx.update(|cx| {
1082            futures::future::join_all(file_paths.into_iter().map(
1083                |(worktree_path, full_path): (Arc<RelPath>, String)| {
1084                    let rel_path = worktree_path
1085                        .strip_prefix(&directory_path)
1086                        .log_err()
1087                        .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1088
1089                    let open_task = project.update(cx, |project, cx| {
1090                        project.buffer_store().update(cx, |buffer_store, cx| {
1091                            let project_path = ProjectPath {
1092                                worktree_id,
1093                                path: worktree_path,
1094                            };
1095                            buffer_store.open_buffer(project_path, cx)
1096                        })
1097                    });
1098
1099                    cx.spawn(async move |cx| {
1100                        let buffer = open_task.await.log_err()?;
1101                        let buffer_content = outline::get_buffer_content_or_outline(
1102                            buffer.clone(),
1103                            Some(&full_path),
1104                            &cx,
1105                        )
1106                        .await
1107                        .ok()?;
1108
1109                        Some((rel_path, full_path, buffer_content.text, buffer))
1110                    })
1111                },
1112            ))
1113        });
1114
1115        let contents = cx
1116            .background_spawn(async move {
1117                let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
1118                    .await
1119                    .into_iter()
1120                    .flatten()
1121                    .map(|(rel_path, full_path, rope, buffer)| {
1122                        ((rel_path, full_path, rope), buffer)
1123                    })
1124                    .unzip();
1125                Mention::Text {
1126                    content: render_directory_contents(contents),
1127                    tracked_buffers,
1128                }
1129            })
1130            .await;
1131        anyhow::Ok(contents)
1132    })
1133}
1134
1135fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1136    let mut output = String::new();
1137    for (_relative_path, full_path, content) in entries {
1138        let fence = codeblock_fence_for_path(Some(&full_path), None);
1139        write!(output, "\n{fence}\n{content}\n```").unwrap();
1140    }
1141    output
1142}
1143
1144fn render_mention_fold_button(
1145    label: SharedString,
1146    icon: SharedString,
1147    tooltip: Option<SharedString>,
1148    mention_uri: Option<MentionUri>,
1149    workspace: Option<WeakEntity<Workspace>>,
1150    range: Range<Anchor>,
1151    mut loading_finished: postage::barrier::Receiver,
1152    image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1153    editor: WeakEntity<Editor>,
1154    cx: &mut App,
1155) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1156    let loading = cx.new(|cx| {
1157        let loading = cx.spawn(async move |this, cx| {
1158            loading_finished.recv().await;
1159            this.update(cx, |this: &mut LoadingContext, cx| {
1160                this.loading = None;
1161                cx.notify();
1162            })
1163            .ok();
1164        });
1165        LoadingContext {
1166            id: cx.entity_id(),
1167            label,
1168            icon,
1169            tooltip,
1170            mention_uri: mention_uri.clone(),
1171            workspace: workspace.clone(),
1172            range,
1173            editor,
1174            loading: Some(loading),
1175            image: image_task.clone(),
1176        }
1177    });
1178    Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1179}
1180
1181struct LoadingContext {
1182    id: EntityId,
1183    label: SharedString,
1184    icon: SharedString,
1185    tooltip: Option<SharedString>,
1186    mention_uri: Option<MentionUri>,
1187    workspace: Option<WeakEntity<Workspace>>,
1188    range: Range<Anchor>,
1189    editor: WeakEntity<Editor>,
1190    loading: Option<Task<()>>,
1191    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1192}
1193
1194impl Render for LoadingContext {
1195    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1196        let is_in_text_selection = self
1197            .editor
1198            .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1199            .unwrap_or_default();
1200
1201        let id = ElementId::from(("loading_context", self.id));
1202
1203        MentionCrease::new(id, self.icon.clone(), self.label.clone())
1204            .mention_uri(self.mention_uri.clone())
1205            .workspace(self.workspace.clone())
1206            .is_toggled(is_in_text_selection)
1207            .is_loading(self.loading.is_some())
1208            .when_some(self.tooltip.clone(), |this, tooltip_text| {
1209                this.tooltip(tooltip_text)
1210            })
1211            .when_some(self.image.clone(), |this, image_task| {
1212                this.image_preview(move |_, cx| {
1213                    let image = image_task.peek().cloned().transpose().ok().flatten();
1214                    let image_task = image_task.clone();
1215                    cx.new::<ImageHover>(|cx| ImageHover {
1216                        image,
1217                        _task: cx.spawn(async move |this, cx| {
1218                            if let Ok(image) = image_task.clone().await {
1219                                this.update(cx, |this, cx| {
1220                                    if this.image.replace(image).is_none() {
1221                                        cx.notify();
1222                                    }
1223                                })
1224                                .ok();
1225                            }
1226                        }),
1227                    })
1228                    .into()
1229                })
1230            })
1231    }
1232}
1233
1234struct ImageHover {
1235    image: Option<Arc<Image>>,
1236    _task: Task<()>,
1237}
1238
1239impl Render for ImageHover {
1240    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1241        if let Some(image) = self.image.clone() {
1242            div()
1243                .p_1p5()
1244                .elevation_2(cx)
1245                .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1246                .into_any_element()
1247        } else {
1248            gpui::Empty.into_any_element()
1249        }
1250    }
1251}
1252
1253async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1254    #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1255    enum ContentType {
1256        Html,
1257        Plaintext,
1258        Json,
1259    }
1260    use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1261
1262    let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1263        format!("https://{url}")
1264    } else {
1265        url
1266    };
1267
1268    let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1269    let mut body = Vec::new();
1270    response
1271        .body_mut()
1272        .read_to_end(&mut body)
1273        .await
1274        .context("error reading response body")?;
1275
1276    if response.status().is_client_error() {
1277        let text = String::from_utf8_lossy(body.as_slice());
1278        anyhow::bail!(
1279            "status error {}, response: {text:?}",
1280            response.status().as_u16()
1281        );
1282    }
1283
1284    let Some(content_type) = response.headers().get("content-type") else {
1285        anyhow::bail!("missing Content-Type header");
1286    };
1287    let content_type = content_type
1288        .to_str()
1289        .context("invalid Content-Type header")?;
1290    let content_type = match content_type {
1291        "text/html" => ContentType::Html,
1292        "text/plain" => ContentType::Plaintext,
1293        "application/json" => ContentType::Json,
1294        _ => ContentType::Html,
1295    };
1296
1297    match content_type {
1298        ContentType::Html => {
1299            let mut handlers: Vec<TagHandler> = vec![
1300                Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1301                Rc::new(RefCell::new(markdown::ParagraphHandler)),
1302                Rc::new(RefCell::new(markdown::HeadingHandler)),
1303                Rc::new(RefCell::new(markdown::ListHandler)),
1304                Rc::new(RefCell::new(markdown::TableHandler::new())),
1305                Rc::new(RefCell::new(markdown::StyledTextHandler)),
1306            ];
1307            if url.contains("wikipedia.org") {
1308                use html_to_markdown::structure::wikipedia;
1309
1310                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1311                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1312                handlers.push(Rc::new(
1313                    RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1314                ));
1315            } else {
1316                handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1317            }
1318            convert_html_to_markdown(&body[..], &mut handlers)
1319        }
1320        ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1321        ContentType::Json => {
1322            let json: serde_json::Value = serde_json::from_slice(&body)?;
1323
1324            Ok(format!(
1325                "```json\n{}\n```",
1326                serde_json::to_string_pretty(&json)?
1327            ))
1328        }
1329    }
1330}