zed/crates/agent_ui/src/message_editor.rs
MartinYe1234 f78f6da255
Make file paths in backticks clickable in agent panel (#57303)
When the agent mentions a file path inside `backticks` (e.g. ``
`src/main.rs` `` or `` `src/main.rs:42` ``), the rendered code span now
becomes a clickable link in the agent panel. Clicking opens the
referenced file in the workspace, jumping to the right line and column
when present.

## How it works

- **Shared path resolution.** Extracted `OpenTarget` and the
workspace/worktree resolution logic out of
`terminal_view::terminal_path_like_target` into a new
`workspace::path_link` module so both the terminal and the agent panel
can use the same code. Includes a `sanitize_path_text` helper ported
from the terminal's URL/punctuation handling. Pure refactor — terminal
behavior is unchanged.
- **`markdown` crate hook.** Added
`MarkdownElement::on_code_span_link(callback)`. When the callback
returns `Some(url)` for a given code span's contents, the existing
`push_link` machinery wires up cmd-hover, hit testing, and the existing
`on_url_click` callback. When it returns `None`, the code span renders
as before. The hook is opt-in, so `markdown` stays workspace-agnostic.
- **Agent panel wiring.** `render_agent_markdown` constructs an
`AgentCodeSpanResolver` that snapshots the project's visible worktree
entries plus their file extensions. `try_resolve` does a cheap
synchronous heuristic check (path must contain `/`/`\` or end in an
extension present in the workspace, can't be a URL, can't be all digits,
etc.) and then looks the candidate up in the per-worktree
`HashSet<Arc<RelPath>>`. On a hit it returns a `MentionUri::File` or
`MentionUri::Selection` URI, which the existing `thread_view::open_link`
already knows how to open at the right line.

## Edge cases handled

- Code spans inside fenced code blocks stay plain (gated on
`builder.code_block_stack.is_empty()`, matching how regular markdown
links behave).
- Trailing prose punctuation (`` `src/main.rs.` ``) is stripped before
lookup.
- Identifiers like `` `String` ``, `` `await` ``, `` `npm run dev` ``
stay plain — they don't pass the path-like heuristic.
- Cross-platform path separators handled via the per-worktree
`PathStyle`.

## Tests

- `crates/markdown` — unit test asserting code spans become links when
the callback returns `Some`, and stay plain when it doesn't.
- `crates/agent_ui` — unit test for `AgentCodeSpanResolver::try_resolve`
covering hits with and without a `:line` suffix, misses, identifiers,
and trailing punctuation.
- Existing `terminal_view` tests cover the moved resolution code
(unchanged behavior).

## Notes

- There's currently a temporary `log::info!` in
`AgentCodeSpanResolver::try_resolve` that reports per-call worktree-walk
timing and a cumulative total. Kept in for now to verify the feature
isn't being called excessively during streaming renders. Can be removed
before merge.
- Resolution is sync-only against worktree entries; absolute paths
outside the workspace are not resolved (would require an async re-render
path).

Closes AI-277

Release Notes:

- Made file paths in `backticks` clickable in the agent panel; clicking
opens the referenced file at the given line when present.
2026-05-21 22:04:32 +00:00

5465 lines
194 KiB
Rust

use crate::DEFAULT_THREAD_TITLE;
use crate::SendImmediately;
use crate::{
ChatWithFollow,
completion_provider::{
AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider,
PromptCompletionProviderDelegate, PromptContextAction, PromptContextType,
SlashCommandCompletion,
},
mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
};
use acp_thread::MentionUri;
use agent::ThreadStore;
use agent_client_protocol::schema as acp;
use anyhow::{Result, anyhow};
use editor::{
Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
actions::{Copy, Cut, Paste},
code_context_menus::CodeContextMenu,
display_map::{CreaseId, CreaseSnapshot},
scroll::Autoscroll,
};
use futures::{FutureExt as _, future::join_all};
use gpui::{
AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, TextStyle,
WeakEntity,
};
use language::{Buffer, language_settings::InlayHintKind};
use parking_lot::RwLock;
use project::AgentId;
use project::{
CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
};
use prompt_store::PromptStore;
use rope::Point;
use settings::Settings;
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
use theme_settings::ThemeSettings;
use ui::{ContextMenu, prelude::*};
use util::paths::PathStyle;
use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, PasteRaw};
#[derive(Default)]
pub struct SessionCapabilities {
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
available_skills: Vec<AvailableSkill>,
}
impl SessionCapabilities {
pub fn new(
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
available_skills: Vec<AvailableSkill>,
) -> Self {
Self {
prompt_capabilities,
available_commands,
available_skills,
}
}
pub fn from_acp_commands(
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
) -> Self {
Self::new(prompt_capabilities, available_commands, Vec::new())
}
pub fn supports_images(&self) -> bool {
self.prompt_capabilities.image
}
pub fn supports_embedded_context(&self) -> bool {
self.prompt_capabilities.embedded_context
}
pub fn available_commands(&self) -> &[acp::AvailableCommand] {
&self.available_commands
}
pub fn available_skills(&self) -> &[AvailableSkill] {
&self.available_skills
}
pub fn has_slash_completions(&self) -> bool {
!self.available_commands.is_empty() || !self.available_skills.is_empty()
}
fn supported_modes(&self, has_thread_store: bool) -> Vec<PromptContextType> {
let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
if self.prompt_capabilities.embedded_context {
if has_thread_store {
supported.push(PromptContextType::Thread);
}
supported.extend(&[
PromptContextType::Diagnostics,
PromptContextType::Fetch,
PromptContextType::Skill,
PromptContextType::BranchDiff,
]);
}
supported
}
pub fn completion_commands(&self) -> Vec<AvailableCommand> {
self.available_commands
.iter()
.map(|command| AvailableCommand {
name: command.name.clone().into(),
description: command.description.clone().into(),
requires_argument: command.input.is_some(),
source: None,
})
.collect()
}
pub fn completion_skills(&self) -> Vec<AvailableSkill> {
self.available_skills.clone()
}
pub fn set_prompt_capabilities(&mut self, prompt_capabilities: acp::PromptCapabilities) {
self.prompt_capabilities = prompt_capabilities;
}
pub fn set_available_commands(&mut self, available_commands: Vec<acp::AvailableCommand>) {
self.available_commands = available_commands;
}
pub fn set_available_skills(&mut self, available_skills: Vec<AvailableSkill>) {
self.available_skills = available_skills;
}
}
pub type SharedSessionCapabilities = Arc<RwLock<SessionCapabilities>>;
struct MessageEditorCompletionDelegate {
session_capabilities: SharedSessionCapabilities,
has_thread_store: bool,
message_editor: WeakEntity<MessageEditor>,
}
impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate {
fn supports_images(&self, _cx: &App) -> bool {
self.session_capabilities.read().supports_images()
}
fn supported_modes(&self, _cx: &App) -> Vec<PromptContextType> {
self.session_capabilities
.read()
.supported_modes(self.has_thread_store)
}
fn available_commands(&self, _cx: &App) -> Vec<AvailableCommand> {
self.session_capabilities.read().completion_commands()
}
fn available_skills(&self, _cx: &App) -> Vec<AvailableSkill> {
self.session_capabilities.read().completion_skills()
}
fn slash_autocomplete_invoked(&self, cx: &mut App) {
// This may be called synchronously from inside a `MessageEditor`
// update (e.g. when pasting a slash command triggers completions),
// so we defer the emit to avoid a reentrant update panic.
let Some(editor) = self.message_editor.upgrade() else {
return;
};
cx.defer(move |cx| {
editor.update(cx, |_editor, cx| {
cx.emit(MessageEditorEvent::SlashAutocompleteOpened);
});
});
}
fn confirm_command(&self, cx: &mut App) {
let _ = self.message_editor.update(cx, |this, cx| this.send(cx));
}
}
pub struct MessageEditor {
mention_set: Entity<MentionSet>,
editor: Entity<Editor>,
workspace: WeakEntity<Workspace>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
thread_store: Option<Entity<ThreadStore>>,
_subscriptions: Vec<Subscription>,
_parse_slash_command_task: Task<()>,
}
#[derive(Clone, Debug)]
pub enum InputAttempt {
Text(Arc<str>),
Paste(ClipboardItem),
}
#[derive(Clone, Debug)]
pub enum MessageEditorEvent {
Send,
SendImmediately,
Cancel,
Focus,
LostFocus,
/// Emitted when the user opens slash-command autocomplete in this
/// editor. Used by `ThreadView` to fire the global-skills scan
/// trigger; see `NativeAgent::ensure_skills_scan_started`.
SlashAutocompleteOpened,
InputAttempted {
attempt: InputAttempt,
cursor_offset: usize,
},
}
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
enum MentionInsertPosition {
AtCursor,
EndOfBuffer,
}
fn insert_mention_for_project_path(
project_path: &ProjectPath,
position: MentionInsertPosition,
editor: &Entity<Editor>,
mention_set: &Entity<MentionSet>,
project: &Entity<Project>,
workspace: &Entity<Workspace>,
supports_images: bool,
window: &mut Window,
cx: &mut App,
) -> Option<Task<()>> {
let (file_name, mention_uri) = {
let project = project.read(cx);
let path_style = project.path_style(cx);
let entry = project.entry_for_path(project_path, cx)?;
let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
let abs_path = worktree.read(cx).absolutize(&project_path.path);
let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
&project_path.path,
worktree.read(cx).root_name(),
path_style,
);
let mention_uri = if entry.is_dir() {
MentionUri::Directory { abs_path }
} else {
MentionUri::File { abs_path }
};
(file_name, mention_uri)
};
let mention_text = mention_uri.as_link().to_string();
let content_len = mention_text.len();
let text_anchor = match position {
MentionInsertPosition::AtCursor => editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx);
let snapshot = buffer.snapshot(cx);
let buffer_snapshot = snapshot.as_singleton()?;
let text_anchor = snapshot
.anchor_to_buffer_anchor(editor.selections.newest_anchor().start)?
.0
.bias_left(&buffer_snapshot);
editor.insert(&mention_text, window, cx);
editor.insert(" ", window, cx);
Some(text_anchor)
}),
MentionInsertPosition::EndOfBuffer => {
let multi_buffer = editor.read(cx).buffer().clone();
let buffer = multi_buffer.read(cx).as_singleton()?;
let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
let new_text = format!("{mention_text} ");
editor.update(cx, |editor, cx| {
editor.edit(
[(
multi_buffer::Anchor::Max..multi_buffer::Anchor::Max,
new_text,
)],
cx,
);
});
Some(anchor)
}
}?;
Some(mention_set.update(cx, |mention_set, cx| {
mention_set.confirm_mention_completion(
file_name,
text_anchor,
content_len,
mention_uri,
supports_images,
editor.clone(),
workspace,
window,
cx,
)
}))
}
enum ResolvedPastedContextItem {
Image(gpui::Image, gpui::SharedString),
ProjectPath(ProjectPath),
}
async fn resolve_pasted_context_items(
project: Entity<Project>,
project_is_local: bool,
supports_images: bool,
entries: Vec<ClipboardEntry>,
cx: &mut gpui::AsyncWindowContext,
) -> (Vec<ResolvedPastedContextItem>, Vec<Entity<Worktree>>) {
let mut items = Vec::new();
let mut added_worktrees = Vec::new();
let default_image_name: SharedString = "Image".into();
for entry in entries {
match entry {
ClipboardEntry::String(_) => {}
ClipboardEntry::Image(image) => {
if supports_images {
items.push(ResolvedPastedContextItem::Image(
image,
default_image_name.clone(),
));
}
}
ClipboardEntry::ExternalPaths(paths) => {
for path in paths.paths().iter() {
if let Some((image, name)) = cx
.background_spawn({
let path = path.clone();
let default_image_name = default_image_name.clone();
async move {
crate::mention_set::load_external_image_from_path(
&path,
&default_image_name,
)
}
})
.await
{
if supports_images {
items.push(ResolvedPastedContextItem::Image(image, name));
}
continue;
}
if !project_is_local {
continue;
}
let path = path.clone();
let Ok(resolve_task) = cx.update({
let project = project.clone();
move |_, cx| Workspace::project_path_for_path(project, &path, false, cx)
}) else {
continue;
};
if let Some((worktree, project_path)) = resolve_task.await.log_err() {
added_worktrees.push(worktree);
items.push(ResolvedPastedContextItem::ProjectPath(project_path));
}
}
}
}
}
(items, added_worktrees)
}
fn insert_project_path_as_context(
project_path: ProjectPath,
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
workspace: WeakEntity<Workspace>,
supports_images: bool,
cx: &mut gpui::AsyncWindowContext,
) -> Option<Task<()>> {
let workspace = workspace.upgrade()?;
cx.update(move |window, cx| {
let project = workspace.read(cx).project().clone();
insert_mention_for_project_path(
&project_path,
MentionInsertPosition::AtCursor,
&editor,
&mention_set,
&project,
&workspace,
supports_images,
window,
cx,
)
})
.ok()
.flatten()
}
async fn insert_resolved_pasted_context_items(
items: Vec<ResolvedPastedContextItem>,
added_worktrees: Vec<Entity<Worktree>>,
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
workspace: WeakEntity<Workspace>,
supports_images: bool,
cx: &mut gpui::AsyncWindowContext,
) {
let mut path_mention_tasks = Vec::new();
for item in items {
match item {
ResolvedPastedContextItem::Image(image, name) => {
crate::mention_set::insert_images_as_context(
vec![(image, name)],
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
)
.await;
}
ResolvedPastedContextItem::ProjectPath(project_path) => {
if let Some(task) = insert_project_path_as_context(
project_path,
editor.clone(),
mention_set.clone(),
workspace.clone(),
supports_images,
cx,
) {
path_mention_tasks.push(task);
}
}
}
}
join_all(path_mention_tasks).await;
drop(added_worktrees);
}
impl MessageEditor {
pub fn new(
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
placeholder: &str,
mode: EditorMode,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let language_registry = project
.upgrade()
.map(|project| project.read(cx).languages().clone());
let editor = cx.new(|cx| {
let buffer = cx.new(|cx| {
let buffer = Buffer::local("", cx);
if let Some(language_registry) = language_registry.as_ref() {
buffer.set_language_registry(language_registry.clone());
}
buffer
});
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let mut editor = Editor::new(mode, buffer, None, window, cx);
editor.set_placeholder_text(placeholder, window, cx);
editor.set_show_indent_guides(false, cx);
editor.set_show_completions_on_input(Some(true));
editor.set_soft_wrap();
editor.disable_mouse_wheel_zoom();
editor.set_use_modal_editing(true);
editor.set_context_menu_options(ContextMenuOptions {
min_entries_visible: 12,
max_entries_visible: 12,
placement: None,
});
editor.register_addon(MessageEditorAddon::new());
editor.set_custom_context_menu(|editor, _point, window, cx| {
let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
Some(ContextMenu::build(window, cx, |menu, _, _| {
menu.action("Cut", Box::new(editor::actions::Cut))
.action_disabled_when(
!has_selection,
"Copy",
Box::new(editor::actions::Copy),
)
.action("Paste", Box::new(editor::actions::Paste))
.action("Paste as Plain Text", Box::new(PasteRaw))
}))
});
editor
});
let mention_set =
cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
let completion_provider = Rc::new(PromptCompletionProvider::new(
MessageEditorCompletionDelegate {
session_capabilities: session_capabilities.clone(),
has_thread_store: thread_store.is_some(),
message_editor: cx.weak_entity(),
},
editor.downgrade(),
mention_set.clone(),
workspace.clone(),
));
editor.update(cx, |editor, _cx| {
editor.set_completion_provider(Some(completion_provider.clone()))
});
cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
cx.emit(MessageEditorEvent::Focus)
})
.detach();
cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
cx.emit(MessageEditorEvent::LostFocus)
})
.detach();
let mut has_hint = false;
let mut subscriptions = Vec::new();
subscriptions.push(cx.subscribe_in(&editor, window, {
move |this, editor, event, window, cx| {
let input_attempted_text = match event {
EditorEvent::InputHandled { text, .. } => Some(text),
EditorEvent::InputIgnored { text } => Some(text),
_ => None,
};
if let Some(text) = input_attempted_text
&& editor.read(cx).read_only(cx)
&& !text.is_empty()
{
let editor = editor.read(cx);
let cursor_anchor = editor.selections.newest_anchor().head();
let cursor_offset = cursor_anchor
.to_offset(&editor.buffer().read(cx).snapshot(cx))
.0;
cx.emit(MessageEditorEvent::InputAttempted {
attempt: InputAttempt::Text(text.clone()),
cursor_offset,
});
}
if let EditorEvent::Edited { .. } = event
&& !editor.read(cx).read_only(cx)
{
editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(window, cx);
this.mention_set
.update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
let new_hints = this
.command_hint(snapshot.buffer())
.into_iter()
.collect::<Vec<_>>();
let has_new_hint = !new_hints.is_empty();
editor.splice_inlays(
if has_hint {
&[COMMAND_HINT_INLAY_ID]
} else {
&[]
},
new_hints,
cx,
);
has_hint = has_new_hint;
});
cx.notify();
}
}
}));
if let Some(language_registry) = language_registry {
let editor = editor.clone();
cx.spawn(async move |_, cx| {
let markdown = language_registry.language_for_name("Markdown").await?;
editor.update(cx, |editor, cx| {
if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
buffer.update(cx, |buffer, cx| {
buffer.set_language(Some(markdown), cx);
});
}
});
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
Self {
editor,
mention_set,
workspace,
session_capabilities,
agent_id,
thread_store,
_subscriptions: subscriptions,
_parse_slash_command_task: Task::ready(()),
}
}
pub fn set_session_capabilities(
&mut self,
session_capabilities: SharedSessionCapabilities,
_cx: &mut Context<Self>,
) {
self.session_capabilities = session_capabilities;
}
fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
let session_capabilities = self.session_capabilities.read();
let available_commands = session_capabilities.available_commands();
if available_commands.is_empty() {
return None;
}
let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
if parsed_command.argument.is_some() {
return None;
}
let command_name = parsed_command.command?;
let available_command = available_commands
.iter()
.find(|available_command| available_command.name == command_name)?;
let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
mut hint,
..
}) = available_command.input.clone()?
else {
return None;
};
let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
if hint_pos > snapshot.len() {
hint_pos = snapshot.len();
hint.insert(0, ' ');
}
let hint_pos = snapshot.anchor_after(hint_pos);
Some(Inlay::hint(
COMMAND_HINT_INLAY_ID,
hint_pos,
&InlayHint {
position: snapshot.anchor_to_buffer_anchor(hint_pos)?.0,
label: InlayHintLabel::String(hint),
kind: Some(InlayHintKind::Parameter),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: project::ResolveState::Resolved,
},
))
}
pub fn insert_thread_summary(
&mut self,
session_id: acp::SessionId,
title: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.thread_store.is_none() {
return;
}
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let thread_title = title
.filter(|title| !title.is_empty())
.unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
let uri = MentionUri::Thread {
id: session_id,
name: thread_title.to_string(),
};
let content = format!("{}\n", uri.as_link());
let content_len = content.len() - 1;
let start = self.editor.update(cx, |editor, cx| {
editor.set_text(content, window, cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
snapshot
.anchor_to_buffer_anchor(snapshot.anchor_before(Point::zero()))
.unwrap()
.0
});
let supports_images = self.session_capabilities.read().supports_images();
self.mention_set
.update(cx, |mention_set, cx| {
mention_set.confirm_mention_completion(
thread_title,
start,
content_len,
uri,
supports_images,
self.editor.clone(),
&workspace,
window,
cx,
)
})
.detach();
}
#[cfg(test)]
pub(crate) fn editor(&self) -> &Entity<Editor> {
&self.editor
}
pub fn is_empty(&self, cx: &App) -> bool {
self.editor.read(cx).text(cx).trim().is_empty()
}
pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
self.editor
.read(cx)
.context_menu()
.borrow()
.as_ref()
.is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
}
#[cfg(test)]
pub fn mention_set(&self) -> &Entity<MentionSet> {
&self.mention_set
}
fn validate_slash_commands(
text: &str,
available_commands: &[acp::AvailableCommand],
available_skills: &[AvailableSkill],
agent_id: &AgentId,
) -> Result<()> {
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
if parsed_command.source_range.start != 0 {
return Ok(());
}
if let Some(command_name) = parsed_command.command {
// Two acceptance paths:
//
// 1. Direct name match. Covers bare slash commands
// (`/help`), MCP prompts that were prefixed at the
// agent because of a server-name collision
// (`/github.create_pr`), and skills (whose bare name
// is registered for the unqualified `/<name>` form).
//
// 2. Trusted native skill scope qualifier `/<scope>:<name>`. The popup
// inserts this colon-separated form to disambiguate
// same-named skills, so the validator splits on the
// LAST `:` to recover scope + bare name. Skill
// names are restricted to `[a-z0-9-]+` (no colons),
// so the rightmost colon is always the scope/name
// boundary — this lets scope labels (e.g. worktree
// root names) themselves contain colons. The
// scope is allowed to be empty: `/:<name>` is the
// qualified form for a global skill (see
// `SkillSource::scope_prefix`). The validator then
// checks the `available_skills` slice for an entry
// whose `skill.name` matches the bare name and
// whose `skill.source` equals the typed scope
// (including empty for globals). Without this
// branch, every autocomplete pick of a same-named
// skill would be rejected as "not supported"
// before reaching the resolver.
let direct_match = available_commands
.iter()
.any(|available_command| available_command.name == command_name)
|| available_skills
.iter()
.any(|skill| skill.name.as_ref() == command_name);
let scope_match = !direct_match
&& command_name.rsplit_once(':').is_some_and(|(scope, bare)| {
!bare.is_empty()
&& available_skills.iter().any(|skill| {
skill.name.as_ref() == bare && skill.source.as_ref() == scope
})
});
if !direct_match && !scope_match {
return Err(anyhow!(indoc::formatdoc!(
"/{command_name} is not a recognized command in {agent_id}. \
Messages that start with `/` are interpreted as commands.
If you are trying to send a message and not run a command, \
try preceding the `/` with a space.
Available commands for {agent_id}: {commands}",
commands =
Self::format_available_commands(available_commands, available_skills),
)));
}
}
}
Ok(())
}
/// Render the available-commands list for error messages. Trusted native skills
/// are shown in their qualified `/<scope>:<name>` form so users
/// see the exact text the popup would insert — otherwise the
/// listing would contain confusing duplicates like `/foo, /foo`
/// when both a global and a project-local skill share a name.
/// Globals carry an empty scope and so render as `/:<name>`.
fn format_available_commands(
commands: &[acp::AvailableCommand],
skills: &[AvailableSkill],
) -> String {
if commands.is_empty() && skills.is_empty() {
return "none".to_string();
}
skills
.iter()
.map(|skill| format!("/{}:{}", skill.source, skill.name))
.chain(commands.iter().map(|command| format!("/{}", command.name)))
.collect::<Vec<_>>()
.join(", ")
}
pub fn contents(
&self,
full_mention_content: bool,
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let text = self.editor.read(cx).text(cx);
let (available_commands, available_skills) = {
let session_capabilities = self.session_capabilities.read();
(
session_capabilities.available_commands().to_vec(),
session_capabilities.available_skills().to_vec(),
)
};
let agent_id = self.agent_id.clone();
let build_task = self.build_content_blocks(full_mention_content, cx);
cx.spawn(async move |_, _cx| {
Self::validate_slash_commands(
&text,
&available_commands,
&available_skills,
&agent_id,
)?;
build_task.await
})
}
pub fn draft_contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
let build_task = self.build_content_blocks(false, cx);
cx.spawn(async move |_, _cx| {
let (blocks, _tracked_buffers) = build_task.await?;
Ok(blocks)
})
}
fn build_content_blocks(
&self,
full_mention_content: bool,
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let contents = self
.mention_set
.update(cx, |store, cx| store.contents(full_mention_content, cx));
let editor = self.editor.clone();
let supports_embedded_context =
self.session_capabilities.read().supports_embedded_context();
cx.spawn(async move |_, cx| {
let mut contents = contents.await?;
Ok(editor.update(cx, |editor, cx| {
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| {
contents
.remove(crease_id)
.map(|(uri, mention)| (uri, Some(mention)))
},
)
}))
})
}
/// Snapshots the editor's current draft into a list of `ContentBlock`s
/// without awaiting any pending mention resolution.
pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec<acp::ContentBlock> {
let editor = self.editor.read(cx);
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
let mention_set = self.mention_set.read(cx);
let supports_embedded_context =
self.session_capabilities.read().supports_embedded_context();
let (chunks, _tracked_buffers) = build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| mention_set.resolved_mention_for_crease(crease_id),
);
chunks
}
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.remove_creases(
self.mention_set.update(cx, |mention_set, _cx| {
mention_set
.clear()
.map(|(crease_id, _)| crease_id)
.collect::<Vec<_>>()
}),
cx,
)
});
}
pub fn send(&mut self, cx: &mut Context<Self>) {
if !self.is_empty(cx) {
self.editor.update(cx, |editor, cx| {
editor.clear_inlay_hints(cx);
});
}
cx.emit(MessageEditorEvent::Send)
}
pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.insert_context_prefix("@", window, cx);
}
pub fn insert_context_type(
&mut self,
context_keyword: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let prefix = format!("@{}", context_keyword);
self.insert_context_prefix(&prefix, window, cx);
}
fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.editor.clone();
let prefix = prefix.to_string();
cx.spawn_in(window, async move |_, cx| {
editor
.update_in(cx, |editor, window, cx| {
let menu_is_open =
editor.context_menu().borrow().as_ref().is_some_and(|menu| {
matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
});
let has_prefix = {
let snapshot = editor.display_snapshot(cx);
let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
let offset = cursor.to_offset(&snapshot);
let buffer_snapshot = snapshot.buffer_snapshot();
let prefix_char_count = prefix.chars().count();
buffer_snapshot
.reversed_chars_at(offset)
.take(prefix_char_count)
.eq(prefix.chars().rev())
};
if menu_is_open && has_prefix {
return;
}
editor.insert(&prefix, window, cx);
editor.show_completions(&editor::actions::ShowCompletions, window, cx);
})
.log_err();
})
.detach();
}
fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
self.send(cx);
}
fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
if self.is_empty(cx) {
return;
}
self.editor.update(cx, |editor, cx| {
editor.clear_inlay_hints(cx);
});
cx.emit(MessageEditorEvent::SendImmediately)
}
fn chat_with_follow(
&mut self,
_: &ChatWithFollow,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.workspace
.update(cx, |this, cx| {
this.follow(CollaboratorId::Agent, window, cx)
})
.log_err();
self.send(cx);
}
fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(MessageEditorEvent::Cancel)
}
pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
if self.editor.read(cx).read_only(cx) {
let editor = self.editor.read(cx);
let cursor_offset = editor
.selections
.newest_anchor()
.head()
.to_offset(&editor.buffer().read(cx).snapshot(cx))
.0;
cx.emit(MessageEditorEvent::InputAttempted {
attempt: InputAttempt::Paste(clipboard),
cursor_offset,
});
cx.stop_propagation();
return;
}
cx.stop_propagation();
self.paste_item(&clipboard, window, cx);
}
pub fn paste_item(
&mut self,
clipboard: &ClipboardItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let editor_clipboard_selections =
clipboard.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(text) => {
text.metadata_json::<Vec<editor::ClipboardSelection>>()
}
_ => None,
});
// Insert creases for pasted clipboard selections that:
// 1. Contain exactly one selection
// 2. Have an associated file path
// 3. Span multiple lines (not single-line selections)
// 4. Belong to a file that exists in the current project
let should_insert_creases = util::maybe!({
let selections = editor_clipboard_selections.as_ref()?;
if selections.len() > 1 {
return Some(false);
}
let selection = selections.first()?;
let file_path = selection.file_path.as_ref()?;
let line_range = selection.line_range.as_ref()?;
if line_range.start() == line_range.end() {
return Some(false);
}
Some(
workspace
.read(cx)
.project()
.read(cx)
.project_path_for_absolute_path(file_path, cx)
.is_some(),
)
})
.unwrap_or(false);
if should_insert_creases && let Some(selections) = editor_clipboard_selections {
let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
let (insertion_target, _) = snapshot
.anchor_to_buffer_anchor(self.editor.read(cx).selections.newest_anchor().start)
.unwrap();
let project = workspace.read(cx).project().clone();
for selection in selections {
if let (Some(file_path), Some(line_range)) =
(selection.file_path, selection.line_range)
{
let crease_text =
acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
let mention_uri = MentionUri::Selection {
abs_path: Some(file_path.clone()),
line_range: line_range.clone(),
column: None,
};
let mention_text = mention_uri.as_link().to_string();
let (text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx);
let snapshot = buffer.snapshot(cx);
let buffer_snapshot = snapshot.as_singleton().unwrap();
let text_anchor = insertion_target.bias_left(&buffer_snapshot);
editor.insert(&mention_text, window, cx);
editor.insert(" ", window, cx);
(text_anchor, mention_text.len())
});
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
text_anchor,
content_len,
crease_text.into(),
mention_uri.icon_path(cx),
mention_uri.tooltip_text(),
Some(mention_uri.clone()),
Some(self.workspace.clone()),
None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
drop(tx);
let mention_task = cx
.spawn({
let project = project.clone();
async move |_, cx| {
let project_path = project
.update(cx, |project, cx| {
project.project_path_for_absolute_path(&file_path, cx)
})
.ok_or_else(|| "project path not found".to_string())?;
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.map_err(|e| e.to_string())?;
Ok(buffer.update(cx, |buffer, cx| {
let start =
Point::new(*line_range.start(), 0).min(buffer.max_point());
let end = Point::new(*line_range.end() + 1, 0)
.min(buffer.max_point());
let content = buffer.text_for_range(start..end).collect();
Mention::Text {
content,
tracked_buffers: vec![cx.entity()],
}
}))
}
})
.shared();
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
mention_task,
crease_entity,
cx,
)
});
}
}
return;
}
// Handle text paste with potential markdown mention links before
// clipboard context entries so markdown text still pastes as text.
let clipboard_text = clipboard.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(text) => Some(text.text().to_string()),
_ => None,
});
if let Some(clipboard_text) = clipboard_text.as_deref() {
if clipboard_text.contains("[@") {
let selections_before = self.editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.selections
.disjoint_anchors()
.iter()
.map(|selection| {
(
selection.start.bias_left(&snapshot),
selection.end.bias_right(&snapshot),
)
})
.collect::<Vec<_>>()
});
self.editor.update(cx, |editor, cx| {
editor.insert(clipboard_text, window, cx);
});
let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
let path_style = workspace.read(cx).project().read(cx).path_style(cx);
let mut all_mentions = Vec::new();
for (start_anchor, end_anchor) in selections_before {
let start_offset = start_anchor.to_offset(&snapshot);
let end_offset = end_anchor.to_offset(&snapshot);
// Get the actual inserted text from the buffer (may differ due to auto-indent)
let inserted_text: String =
snapshot.text_for_range(start_offset..end_offset).collect();
let parsed_mentions = parse_mention_links(&inserted_text, path_style);
for (range, mention_uri) in parsed_mentions {
let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start);
let anchor = snapshot.anchor_before(mention_start_offset);
let content_len = range.end - range.start;
all_mentions.push((anchor, content_len, mention_uri));
}
}
if !all_mentions.is_empty() {
let supports_images = self.session_capabilities.read().supports_images();
let http_client = workspace.read(cx).client().http_client();
for (anchor, content_len, mention_uri) in all_mentions {
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
content_len,
mention_uri.name().into(),
mention_uri.icon_path(cx),
mention_uri.tooltip_text(),
Some(mention_uri.clone()),
Some(self.workspace.clone()),
None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
// Create the confirmation task based on the mention URI type.
// This properly loads file content, fetches URLs, etc.
let task = self.mention_set.update(cx, |mention_set, cx| {
mention_set.confirm_mention_for_uri(
mention_uri.clone(),
supports_images,
http_client.clone(),
cx,
)
});
let task = cx
.spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
.shared();
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
task.clone(),
crease_entity,
cx,
)
});
// Drop the tx after inserting to signal the crease is ready
drop(tx);
}
return;
}
}
}
if self.handle_pasted_context(clipboard, window, cx) {
return;
}
self.editor.update(cx, |editor, cx| {
editor.paste_item(clipboard, window, cx);
});
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else {
cx.propagate();
return;
};
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else {
cx.propagate();
return;
};
cx.stop_propagation();
self.editor.update(cx, |editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(ranges);
});
editor.insert("", window, cx);
});
});
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.editor.clone();
window.defer(cx, move |window, cx| {
editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
});
}
fn handle_pasted_context(
&mut self,
clipboard: &ClipboardItem,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if matches!(
clipboard.entries().first(),
Some(ClipboardEntry::String(_)) | None
) {
return false;
}
let Some(workspace) = self.workspace.upgrade() else {
return false;
};
let project = workspace.read(cx).project().clone();
let project_is_local = project.read(cx).is_local();
let supports_images = self.session_capabilities.read().supports_images();
if !project_is_local && !supports_images {
return false;
}
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
let workspace = self.workspace.clone();
let entries = clipboard.clone().into_entries().collect::<Vec<_>>();
window
.spawn(cx, async move |mut cx| {
let (items, added_worktrees) = resolve_pasted_context_items(
project,
project_is_local,
supports_images,
entries,
&mut cx,
)
.await;
insert_resolved_pasted_context_items(
items,
added_worktrees,
editor,
mention_set,
workspace,
supports_images,
&mut cx,
)
.await;
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
true
}
pub fn insert_dragged_files(
&mut self,
paths: Vec<project::ProjectPath>,
added_worktrees: Vec<Entity<Worktree>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let project = workspace.read(cx).project().clone();
let supports_images = self.session_capabilities.read().supports_images();
let mut tasks = Vec::new();
for path in paths {
if let Some(task) = insert_mention_for_project_path(
&path,
MentionInsertPosition::EndOfBuffer,
&self.editor,
&self.mention_set,
&project,
&workspace,
supports_images,
window,
cx,
) {
tasks.push(task);
}
}
cx.spawn(async move |_, _| {
join_all(tasks).await;
drop(added_worktrees);
})
.detach();
}
pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let project = workspace.read(cx).project().clone();
let Some(repo) = project.read(cx).active_repository(cx) else {
return;
};
let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
let weak_workspace = self.workspace.clone();
window
.spawn(cx, async move |cx| {
let base_ref: SharedString = default_branch_receiver
.await
.ok()
.and_then(|r| r.ok())
.flatten()
.ok_or_else(|| anyhow!("Could not determine default branch"))?;
cx.update(|window, cx| {
let mention_uri = MentionUri::GitDiff {
base_ref: base_ref.to_string(),
};
let mention_text = mention_uri.as_link().to_string();
let (text_anchor, content_len) = editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx);
let snapshot = buffer.snapshot(cx);
let buffer_snapshot = snapshot.as_singleton().unwrap();
let text_anchor = snapshot
.anchor_to_buffer_anchor(editor.selections.newest_anchor().start)
.unwrap()
.0
.bias_left(&buffer_snapshot);
editor.insert(&mention_text, window, cx);
editor.insert(" ", window, cx);
(text_anchor, mention_text.len())
});
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
text_anchor,
content_len,
mention_uri.name().into(),
mention_uri.icon_path(cx),
mention_uri.tooltip_text(),
Some(mention_uri.clone()),
Some(weak_workspace),
None,
editor,
window,
cx,
) else {
return;
};
drop(tx);
let confirm_task = mention_set.update(cx, |mention_set, cx| {
mention_set.confirm_mention_for_git_diff(base_ref, cx)
});
let mention_task = cx
.spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
.shared();
mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri,
mention_task,
crease_entity,
cx,
);
});
})
})
.detach_and_log_err(cx);
}
pub fn insert_skill_crease(
&mut self,
skill: &AvailableSkill,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let mention_uri = MentionUri::Skill {
name: skill.name.to_string(),
source: skill.source.to_string(),
skill_file_path: skill.skill_file_path.clone(),
};
let link_text = mention_uri.as_link().to_string();
let content_len = link_text.len();
let mention_text = format!("{} ", link_text);
let crease_text: SharedString = mention_uri.name().into();
let start_anchor = self.editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let buffer_snapshot = snapshot.as_singleton()?;
let cursor = editor.selections.newest_anchor().start;
let text_anchor = snapshot
.anchor_to_buffer_anchor(cursor)?
.0
.bias_left(buffer_snapshot);
editor.insert(&mention_text, window, cx);
Some(text_anchor)
});
let Some(start_anchor) = start_anchor else {
return;
};
self.mention_set
.update(cx, |mention_set, cx| {
mention_set.confirm_mention_completion(
crease_text,
start_anchor,
content_len,
mention_uri,
false,
self.editor.clone(),
&workspace,
window,
cx,
)
})
.detach();
}
pub(crate) fn insert_selections(
&mut self,
selection: AgentContextSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
let editor = self.editor.read(cx);
let editor_buffer = editor.buffer().read(cx);
let Some(buffer) = editor_buffer.as_singleton() else {
return;
};
let cursor_anchor = editor.selections.newest_anchor().head();
let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
let anchor = buffer.update(cx, |buffer, _cx| {
buffer.anchor_before(cursor_offset.0.min(buffer.len()))
});
let Some(completion) =
PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
PromptContextAction::AddSelections,
anchor..anchor,
self.editor.downgrade(),
self.mention_set.downgrade(),
Some(selection),
)
else {
return;
};
self.editor.update(cx, |message_editor, cx| {
message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
message_editor.request_autoscroll(Autoscroll::fit(), cx);
});
if let Some(confirm) = completion.confirm {
confirm(CompletionIntent::Complete, window, cx);
}
}
pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.session_capabilities.read().supports_images() {
return;
}
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
let workspace = self.workspace.clone();
let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
files: true,
directories: false,
multiple: true,
prompt: Some("Select Images".into()),
});
window
.spawn(cx, async move |cx| {
let paths = match paths_receiver.await {
Ok(Ok(Some(paths))) => paths,
_ => return Ok::<(), anyhow::Error>(()),
};
let default_image_name: SharedString = "Image".into();
let images = cx
.background_spawn(async move {
paths
.into_iter()
.filter_map(|path| {
crate::mention_set::load_external_image_from_path(
&path,
&default_image_name,
)
})
.collect::<Vec<_>>()
})
.await;
crate::mention_set::insert_images_as_context(
images,
editor,
mention_set,
workspace,
cx,
)
.await;
Ok(())
})
.detach_and_log_err(cx);
}
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
self.editor.update(cx, |message_editor, cx| {
message_editor.set_read_only(read_only);
cx.notify()
})
}
pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
if *editor.mode() != mode {
editor.set_mode(mode);
cx.notify()
}
});
}
pub fn set_message(
&mut self,
message: Vec<acp::ContentBlock>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.clear(window, cx);
self.insert_message_blocks(message, false, window, cx);
}
pub fn append_message(
&mut self,
message: Vec<acp::ContentBlock>,
separator: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if message.is_empty() {
return;
}
if let Some(separator) = separator
&& !separator.is_empty()
&& !self.is_empty(cx)
{
self.editor.update(cx, |editor, cx| {
editor.insert(separator, window, cx);
});
}
self.insert_message_blocks(message, true, window, cx);
}
fn insert_message_blocks(
&mut self,
message: Vec<acp::ContentBlock>,
append_to_existing: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let path_style = workspace.read(cx).project().read(cx).path_style(cx);
let mut text = String::new();
let mut mentions = Vec::new();
for chunk in message {
match chunk {
acp::ContentBlock::Text(text_content) => {
text.push_str(&text_content.text);
}
acp::ContentBlock::Resource(acp::EmbeddedResource {
resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
..
}) => {
let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
else {
continue;
};
let start = text.len();
write!(&mut text, "{}", mention_uri.as_link()).ok();
let end = text.len();
mentions.push((
start..end,
mention_uri,
Mention::Text {
content: resource.text,
tracked_buffers: Vec::new(),
},
));
}
acp::ContentBlock::ResourceLink(resource) => {
if let Some(mention_uri) =
MentionUri::parse(&resource.uri, path_style).log_err()
{
let start = text.len();
write!(&mut text, "{}", mention_uri.as_link()).ok();
let end = text.len();
mentions.push((start..end, mention_uri, Mention::Link));
}
}
acp::ContentBlock::Image(acp::ImageContent {
uri,
data,
mime_type,
..
}) => {
let mention_uri = if let Some(uri) = uri {
MentionUri::parse(&uri, path_style)
} else {
Ok(MentionUri::PastedImage {
name: "Image".to_string(),
})
};
let Some(mention_uri) = mention_uri.log_err() else {
continue;
};
let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
log::error!("failed to parse MIME type for image: {mime_type:?}");
continue;
};
let start = text.len();
write!(&mut text, "{}", mention_uri.as_link()).ok();
let end = text.len();
mentions.push((
start..end,
mention_uri,
Mention::Image(MentionImage {
data: data.into(),
format,
}),
));
}
_ => {}
}
}
if text.is_empty() && mentions.is_empty() {
return;
}
let insertion_start = if append_to_existing {
self.editor.read(cx).text(cx).len()
} else {
0
};
let snapshot = if append_to_existing {
self.editor.update(cx, |editor, cx| {
editor.insert(&text, window, cx);
editor.buffer().read(cx).snapshot(cx)
})
} else {
self.editor.update(cx, |editor, cx| {
editor.set_text(text, window, cx);
editor.buffer().read(cx).snapshot(cx)
})
};
for (range, mention_uri, mention) in mentions {
let adjusted_start = insertion_start + range.start;
let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
range.end - range.start,
mention_uri.name().into(),
mention_uri.icon_path(cx),
mention_uri.tooltip_text(),
Some(mention_uri.clone()),
Some(self.workspace.clone()),
None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
drop(tx);
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
Task::ready(Ok(mention)).shared(),
crease_entity,
cx,
)
});
}
cx.notify();
}
pub fn text(&self, cx: &App) -> String {
self.editor.read(cx).text(cx)
}
pub fn set_cursor_offset(
&mut self,
offset: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let offset = snapshot.clip_offset(MultiBufferOffset(offset), text::Bias::Left);
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([offset..offset]);
});
});
}
pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
if text.is_empty() {
return;
}
self.editor.update(cx, |editor, cx| {
editor.insert(text, window, cx);
});
}
pub fn set_placeholder_text(
&mut self,
placeholder: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.set_placeholder_text(placeholder, window, cx);
});
}
#[cfg(any(test, feature = "test-support"))]
pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.set_text(text, window, cx);
});
}
fn serialize_selection_with_mentions(
&self,
expand_empty_to_line: bool,
cx: &mut App,
) -> Option<(String, Vec<Range<MultiBufferOffset>>)> {
if self.mention_set.read(cx).is_empty() {
return None;
}
let display_snapshot = self
.editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
let editor = self.editor.read(cx);
if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) {
return None;
}
let snapshot = editor.buffer().read(cx).snapshot(cx);
let mention_set = self.mention_set.read(cx);
let mention_ranges = display_snapshot
.crease_snapshot
.crease_items_with_offsets(&snapshot)
.into_iter()
.filter_map(|(crease_id, range)| {
mention_set.mention_uri_for_crease(&crease_id).map(|uri| {
(
range.start.to_offset(&snapshot),
range.end.to_offset(&snapshot),
uri,
)
})
})
.collect::<Vec<_>>();
let line_mode = editor.selections.line_mode();
let max_point = snapshot.max_point();
let point_selections = editor.selections.all::<Point>(&display_snapshot);
let mut text = String::new();
let mut ranges = Vec::with_capacity(point_selections.len());
let mut has_mentions = false;
let mut is_first = true;
let mut prev_was_entire_line = false;
for mut selection in point_selections {
let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode;
if is_entire_line {
selection.start = Point::new(selection.start.row, 0);
if !selection.is_empty() && selection.end.column == 0 {
selection.end = min(max_point, selection.end);
} else {
selection.end = min(max_point, Point::new(selection.end.row + 1, 0));
}
}
let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
if is_first {
is_first = false;
} else if !prev_was_entire_line {
text.push('\n');
}
prev_was_entire_line = is_entire_line;
let mut cursor = range.start;
for (start, end, uri) in mention_ranges
.iter()
.filter(|(start, end, _)| *start < range.end && range.start < *end)
{
if cursor < *start {
text.extend(snapshot.text_for_range(cursor..*start));
}
write!(text, "{}", uri.as_link()).unwrap();
cursor = *end;
has_mentions = true;
}
if cursor < range.end {
text.extend(snapshot.text_for_range(cursor..range.end));
}
ranges.push(range);
}
has_mentions.then_some((text, ranges))
}
}
impl Focusable for MessageEditor {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Render for MessageEditor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.key_context("MessageEditor")
.on_action(cx.listener(Self::chat))
.on_action(cx.listener(Self::send_immediately))
.on_action(cx.listener(Self::chat_with_follow))
.on_action(cx.listener(Self::cancel))
.capture_action(cx.listener(Self::copy))
.capture_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::paste_raw))
.capture_action(cx.listener(Self::paste))
.flex_1()
.child({
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: cx.theme().colors().text,
font_family: settings.buffer_font.family.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_features: settings.buffer_font.features.clone(),
font_size: settings.agent_buffer_font_size(cx).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
};
EditorElement::new(
&self.editor,
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: text_style,
syntax: cx.theme().syntax().clone(),
inlay_hints_style: editor::make_inlay_hints_style(cx),
..Default::default()
},
)
})
}
}
pub struct MessageEditorAddon {}
impl MessageEditorAddon {
pub fn new() -> Self {
Self {}
}
}
impl Addon for MessageEditorAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
let settings = agent_settings::AgentSettings::get_global(cx);
if settings.use_modifier_to_send {
key_context.add("use_modifier_to_send");
}
}
}
/// Walks the editor's creases in order, interleaving plain-text chunks from
/// `text` with mention blocks produced from `resolve`.
fn build_chunks_from_creases(
text: &str,
crease_snapshot: &CreaseSnapshot,
buffer_snapshot: &MultiBufferSnapshot,
supports_embedded_context: bool,
mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option<Mention>)>,
) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
let mut ix = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(text.len(), |(i, _)| i);
let mut chunks = Vec::new();
let mut tracked_buffers = Vec::new();
for (crease_id, crease) in crease_snapshot.creases() {
let Some((uri, mention)) = resolve(&crease_id) else {
continue;
};
let crease_range = crease.range().to_offset(buffer_snapshot);
if crease_range.start.0 > ix {
chunks.push(text[ix..crease_range.start.0].into());
}
chunks.push(mention_to_content_block(
&uri,
mention.as_ref(),
supports_embedded_context,
&mut tracked_buffers,
));
ix = crease_range.end.0;
}
if ix < text.len() {
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
(chunks, tracked_buffers)
}
fn mention_to_content_block(
uri: &MentionUri,
mention: Option<&Mention>,
supports_embedded_context: bool,
tracked_buffers: &mut Vec<Entity<Buffer>>,
) -> acp::ContentBlock {
match mention {
Some(Mention::Text {
content,
tracked_buffers: mention_tracked_buffers,
}) => {
tracked_buffers.extend(mention_tracked_buffers.iter().cloned());
if supports_embedded_context {
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()),
),
))
} else {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
))
}
}
Some(Mention::Image(mention_image)) => acp::ContentBlock::Image(
acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type())
.uri(match uri {
MentionUri::File { .. } | MentionUri::PastedImage { .. } => {
Some(uri.to_uri().to_string())
}
other => {
debug_panic!("unexpected mention uri for image: {:?}", other);
None
}
}),
),
_ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
)),
}
}
/// Parses markdown mention links in the format `[@name](uri)` from text.
/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
let mut mentions = Vec::new();
let mut search_start = 0;
while let Some(link_start) = text[search_start..].find("[@") {
let absolute_start = search_start + link_start;
// Find the matching closing bracket for the name, handling nested brackets.
// Start at the '[' character so find_matching_bracket can track depth correctly.
let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
search_start = absolute_start + 2;
continue;
};
let name_end = absolute_start + name_end;
// Check for opening parenthesis immediately after
if text.get(name_end + 1..name_end + 2) != Some("(") {
search_start = name_end + 1;
continue;
}
// Find the matching closing parenthesis for the URI, handling nested parens
let uri_start = name_end + 2;
let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
search_start = uri_start;
continue;
};
let uri_end = name_end + 1 + uri_end_relative;
let link_end = uri_end + 1;
let uri_str = &text[uri_start..uri_end];
// Try to parse the URI as a MentionUri
if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
mentions.push((absolute_start..link_end, mention_uri));
}
search_start = link_end;
}
mentions
}
/// Finds the position of the matching closing bracket, handling nested brackets.
/// The input `text` should start with the opening bracket.
/// Returns the index of the matching closing bracket relative to `text`.
fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
let mut depth = 0;
for (index, character) in text.char_indices() {
if character == open {
depth += 1;
} else if character == close {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
}
None
}
#[cfg(test)]
mod tests {
use std::{ops::Range, path::Path, path::PathBuf, sync::Arc};
use acp_thread::MentionUri;
use agent::{ThreadStore, outline};
use agent_client_protocol::schema as acp;
use base64::Engine as _;
use editor::{
AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
actions::{Cut, Paste},
};
use fs::FakeFs;
use futures::{FutureExt as _, StreamExt as _};
use gpui::{
AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths,
FocusHandle, Focusable, Task, TestAppContext, VisualTestContext,
};
use language_model::LanguageModelRegistry;
use lsp::{CompletionContext, CompletionTriggerKind};
use parking_lot::RwLock;
use project::{AgentId, CompletionIntent, Project, ProjectPath};
use serde_json::{Value, json};
use text::Point;
use ui::{App, Context, IntoElement, Render, SharedString, Window};
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{AppState, Item, MultiWorkspace, Workspace};
use crate::completion_provider::{AgentContextSelection, AvailableSkill, PromptContextType};
use crate::{
conversation_view::tests::init_test,
mention_set::insert_crease_for_mention,
message_editor::{
Mention, MessageEditor, MessageEditorEvent, SessionCapabilities, parse_mention_links,
},
};
#[test]
fn test_session_capabilities_keep_commands_and_skills_separate() {
let skill_file_path = PathBuf::from("/tmp/SKILL.md");
let skill = AvailableSkill {
name: "deploy".into(),
description: "Deploy the app".into(),
source: "".into(),
skill_file_path: skill_file_path.clone(),
};
let session_capabilities = SessionCapabilities::new(
acp::PromptCapabilities::default(),
vec![acp::AvailableCommand::new("help", "Get help")],
vec![skill],
);
assert_eq!(session_capabilities.completion_commands().len(), 1);
let skills = session_capabilities.completion_skills();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name.as_ref(), "deploy");
assert_eq!(skills[0].skill_file_path, skill_file_path);
}
#[test]
fn test_validate_slash_commands_accepts_scope_qualified_skill() {
let agent_id = AgentId::from("Zed");
let make_skill = |name: &str, source: &str| AvailableSkill {
name: name.into(),
description: "desc".into(),
source: source.into(),
skill_file_path: PathBuf::from(format!("/tmp/{source}-{name}/SKILL.md")),
};
// Global skills carry an empty scope (so the popup inserts
// `/:<name>`); project-local skills carry their worktree root
// name. The empty-scope encoding means a worktree literally
// named `global` no longer collides with the global source.
let commands = vec![acp::AvailableCommand::new("help", "Get help")];
let skills = vec![make_skill("deploy", ""), make_skill("deploy", "zed")];
let no_skills = Vec::new();
// Bare name still works (current behavior — the resolver
// applies project-overrides-global for unqualified commands).
MessageEditor::validate_slash_commands("/deploy", &commands, &skills, &agent_id)
.expect("bare /deploy should validate when a skill named `deploy` exists");
MessageEditor::validate_slash_commands("/zed:deploy", &commands, &no_skills, &agent_id)
.expect_err("scope-qualified skills should require a first-class available skill");
// Scope-qualified forms both validate, each pointing at the
// matching source. `/:<name>` is the qualified form for a
// global skill; `/<worktree>:<name>` is the qualified form
// for a project-local skill.
MessageEditor::validate_slash_commands("/:deploy", &commands, &skills, &agent_id)
.expect("/:deploy should validate when a global skill named `deploy` exists");
MessageEditor::validate_slash_commands("/zed:deploy", &commands, &skills, &agent_id).expect(
"/zed:deploy should validate when a project skill named `deploy` exists in the `zed` worktree",
);
// Hand-typed `/global:<name>` is NOT an alias for `/:<name>`.
// It looks for a project-local skill from a worktree named
// `global`, and fails when no such worktree skill exists.
MessageEditor::validate_slash_commands("/global:deploy", &commands, &skills, &agent_id)
.expect_err(
"/global:deploy should fail when no worktree named `global` has a `deploy` skill",
);
// The `:` separator is what distinguishes a skill scope from
// an MCP server prefix — the dotted form `/zed.deploy` is an
// MCP-style lookup, which doesn't match here.
MessageEditor::validate_slash_commands("/zed.deploy", &commands, &skills, &agent_id)
.expect_err("/zed.deploy (dotted) should be treated as an MCP-style prefix and fail");
// Wrong scope is rejected so the resolver doesn't silently
// fall through when the user meant a skill. `zed:help` looks
// like a skill scope qualifier but no skill named `help`
// exists in the `zed` worktree (it's an MCP command).
let err =
MessageEditor::validate_slash_commands("/zed:help", &commands, &skills, &agent_id)
.expect_err(
"/zed:help should fail — `help` is an MCP command, not a worktree skill",
);
let err_message = err.to_string();
assert!(
err_message.contains("/zed:help"),
"error should mention the typed command: {err_message}"
);
// Error listing shows qualified forms for skills so users see
// the exact text the popup would have inserted. Globals
// render with an empty scope as `/:<name>`.
assert!(
err_message.contains("/:deploy"),
"error listing should show qualified global form: {err_message}"
);
assert!(
err_message.contains("/zed:deploy"),
"error listing should show qualified worktree form: {err_message}"
);
assert!(
err_message.contains("/help"),
"error listing should still show bare MCP commands: {err_message}"
);
// Slashes that appear mid-text (paths, URLs, pasted logs)
// should NOT be validated as commands.
MessageEditor::validate_slash_commands(
"check /docs for info",
&commands,
&skills,
&agent_id,
)
.expect("mid-text /docs should not be treated as a slash command");
MessageEditor::validate_slash_commands(
"see /usr/local/bin/foo",
&commands,
&skills,
&agent_id,
)
.expect("file paths containing slashes should not trigger validation");
}
#[test]
fn test_parse_mention_links() {
// Single file mention
let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0, 0..text.len());
assert!(matches!(mentions[0].1, MentionUri::File { .. }));
// Multiple mentions
let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 2);
// Text without mentions
let text = "Just some regular text without mentions";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 0);
// Malformed mentions (should be skipped)
let text = "[@incomplete](invalid://uri) and [@missing](";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 0);
// Mixed content with valid mention
let text = "Before [@valid](file:///path/to/file) after";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0.start, 7);
// HTTP URL mention (Fetch)
let text = "Check out [@docs](https://example.com/docs) for more info";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
// Directory mention (trailing slash)
let text = "[@src](file:///path/to/src/)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
// Multiple different mention types
let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 3);
assert!(matches!(mentions[0].1, MentionUri::File { .. }));
assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
// Adjacent mentions without separator
let text = "[@a](file:///a)[@b](file:///b)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 2);
// Regular markdown link (not a mention) should be ignored
let text = "[regular link](https://example.com)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 0);
// Incomplete mention link patterns
let text = "[@name] without url and [@name( malformed";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 0);
// Nested brackets in name portion
let text = "[@name [with brackets]](file:///path/to/file)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0, 0..text.len());
// Deeply nested brackets
let text = "[@outer [inner [deep]]](file:///path)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
// Unbalanced brackets should fail gracefully
let text = "[@unbalanced [bracket](file:///path)";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 0);
// Nested parentheses in URI (common in URLs with query params)
let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
let mentions = parse_mention_links(text, PathStyle::local());
assert_eq!(mentions.len(), 1);
if let MentionUri::Fetch { url } = &mentions[0].1 {
assert!(url.as_str().contains("Rust_(programming_language)"));
} else {
panic!("Expected Fetch URI");
}
}
#[gpui::test]
async fn test_at_mention_removal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
cx.run_until_parked();
let completions = editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello @file ", window, cx);
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
let completion_provider = editor.completion_provider().unwrap();
completion_provider.completions(
&buffer,
text::Anchor::max_for_buffer(buffer.read(cx).remote_id()),
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some("@".into()),
},
window,
cx,
)
});
let [_, completion]: [_; 2] = completions
.await
.unwrap()
.into_iter()
.flat_map(|response| response.completions)
.collect::<Vec<_>>()
.try_into()
.unwrap();
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let range = snapshot
.buffer_anchor_range_to_anchor_range(completion.replace_range)
.unwrap();
editor.edit([(range, completion.new_text)], cx);
(completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
});
cx.run_until_parked();
// Backspace over the inserted crease (and the following space).
editor.update_in(cx, |editor, window, cx| {
editor.backspace(&Default::default(), window, cx);
editor.backspace(&Default::default(), window, cx);
});
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
// We don't send a resource link for the deleted crease.
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
}
#[gpui::test]
async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/test",
json!({
".zed": {
"tasks.json": r#"[{"label": "test", "command": "echo"}]"#
},
"src": {
"main.rs": "fn main() {}",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let thread_store = None;
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![],
)));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace_handle.clone(),
project.downgrade(),
thread_store.clone(),
None,
session_capabilities.clone(),
"Claude Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
// Test that slash commands fail when no available_commands are set (empty list means no commands supported)
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
// Should fail because available_commands is empty (no commands supported)
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("is not a recognized command in Claude Agent"));
assert!(error_message.contains("Available commands for Claude Agent: none"));
// Now simulate Claude providing its list of available commands (which doesn't include file)
session_capabilities
.write()
.set_available_commands(vec![acp::AvailableCommand::new("help", "Get help")]);
// Test that unsupported slash commands trigger an error when we have a list of available commands
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("is not a recognized command in Claude Agent"));
assert!(error_message.contains("/file"));
assert!(error_message.contains("Available commands for Claude Agent: /help"));
// Test that supported commands work fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/help", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
// Should succeed because /help is in available_commands
assert!(contents_result.is_ok());
// Test that regular text works fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello Claude!", window, cx);
});
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Hello Claude!");
} else {
panic!("Expected ContentBlock::Text");
}
// Test that @ mentions still work
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Check this @", window, cx);
});
// The @ mention functionality should not be affected
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Check this @");
} else {
panic!("Expected ContentBlock::Text");
}
}
struct MessageEditorItem(Entity<MessageEditor>);
impl Item for MessageEditorItem {
type Event = ();
fn include_in_nav_history() -> bool {
false
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Test".into()
}
}
impl EventEmitter<()> for MessageEditorItem {}
impl Focusable for MessageEditorItem {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.0.read(cx).focus_handle(cx)
}
}
impl Render for MessageEditorItem {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.0.clone().into_any_element()
}
}
#[gpui::test]
async fn test_completion_provider_commands(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
"<name>",
)),
),
],
)));
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
message_editor.read(cx).editor().clone()
});
cx.simulate_input("/");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[
("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
("say-hello".into(), "Say hello to whoever you want".into())
]
);
editor.set_text("", window, cx);
});
cx.simulate_input("/qui");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/qui");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
);
editor.set_text("", window, cx);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.display_text(cx), "/quick-math ");
assert!(!editor.has_visible_completions_menu());
editor.set_text("", window, cx);
});
cx.simulate_input("/say");
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.display_text(cx), "/say");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("say-hello".into(), "Say hello to whoever you want".into())]
);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.text(cx), "/say-hello ");
assert_eq!(editor.display_text(cx), "/say-hello <name>");
assert!(!editor.has_visible_completions_menu());
});
cx.simulate_input("GPT5");
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello GPT5");
assert_eq!(editor.display_text(cx), "/say-hello GPT5");
assert!(!editor.has_visible_completions_menu());
// Delete argument
for _ in 0..5 {
editor.backspace(&editor::actions::Backspace, window, cx);
}
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello");
// Hint is visible because argument was deleted
assert_eq!(editor.display_text(cx), "/say-hello <name>");
// Delete last command letter
editor.backspace(&editor::actions::Backspace, window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
// Hint goes away once command no longer matches an available one
assert_eq!(editor.text(cx), "/say-hell");
assert_eq!(editor.display_text(cx), "/say-hell");
assert!(!editor.has_visible_completions_menu());
});
}
/// Opening slash-command autocomplete must emit
/// [`MessageEditorEvent::SlashAutocompleteOpened`]. `ThreadView`
/// subscribes to that event to fire the global-skills scan trigger
/// (see `NativeAgent::ensure_skills_scan_started`); without the
/// event the trigger never runs and lazily-discovered skills never
/// appear in autocomplete.
#[gpui::test]
async fn test_slash_autocomplete_emits_opened_event(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![acp::AvailableCommand::new("hello", "Say hello")],
)));
// Track every event emitted by the message editor across the
// lifetime of the test. We expect to see Focus (from the focus
// call below) and SlashAutocompleteOpened (from typing "/").
let received_events: Arc<parking_lot::Mutex<Vec<MessageEditorEvent>>> =
Arc::new(parking_lot::Mutex::new(Vec::new()));
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
None,
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
let received_events = received_events.clone();
cx.subscribe(
&message_editor,
move |_editor: &mut Workspace, _, event: &MessageEditorEvent, _cx| {
received_events.lock().push(event.clone());
},
)
.detach();
message_editor.read(cx).focus_handle(cx).focus(window, cx);
message_editor.read(cx).editor().clone()
});
cx.simulate_input("/");
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.text(cx), "/");
assert!(editor.has_visible_completions_menu());
});
let events = received_events.lock();
assert!(
events
.iter()
.any(|e| matches!(e, MessageEditorEvent::SlashAutocompleteOpened)),
"expected SlashAutocompleteOpened to have been emitted; saw events: {events:?}",
);
}
#[gpui::test]
async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/dir"),
json!({
"editor": "",
"a": {
"one.txt": "1",
"two.txt": "2",
"three.txt": "3",
"four.txt": "4"
},
"b": {
"five.txt": "5",
"six.txt": "6",
"seven.txt": "7",
"eight.txt": "8",
},
"x.png": "",
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let worktree = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
assert_eq!(worktrees.len(), 1);
worktrees.pop().unwrap()
});
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
let mut cx = VisualTestContext::from_window(window.into(), cx);
let paths = vec![
rel_path("a/one.txt"),
rel_path("a/two.txt"),
rel_path("a/three.txt"),
rel_path("a/four.txt"),
rel_path("b/five.txt"),
rel_path("b/six.txt"),
rel_path("b/seven.txt"),
rel_path("b/eight.txt"),
];
let slash = PathStyle::local().primary_separator();
let mut opened_editors = Vec::new();
for path in paths {
let buffer = workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_path(
ProjectPath {
worktree_id,
path: path.into(),
},
None,
false,
window,
cx,
)
})
.await
.unwrap();
opened_editors.push(buffer);
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![],
)));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
cx.simulate_input("Lorem @");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "Lorem @");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels(editor),
&[
format!("eight.txt b{slash}"),
format!("seven.txt b{slash}"),
format!("six.txt b{slash}"),
format!("five.txt b{slash}"),
"Files & Directories".into(),
"Symbols".into()
]
);
editor.set_text("", window, cx);
});
message_editor.update(&mut cx, |editor, _cx| {
editor.session_capabilities.write().set_prompt_capabilities(
acp::PromptCapabilities::new()
.image(true)
.audio(true)
.embedded_context(true),
);
});
cx.simulate_input("Lorem ");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem ");
assert!(!editor.has_visible_completions_menu());
});
cx.simulate_input("@");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels(editor),
&[
format!("eight.txt b{slash}"),
format!("seven.txt b{slash}"),
format!("six.txt b{slash}"),
format!("five.txt b{slash}"),
"Files & Directories".into(),
"Symbols".into(),
"Threads".into(),
"Fetch".into()
]
);
});
// Select and confirm "File"
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file ");
assert!(editor.has_visible_completions_menu());
});
cx.simulate_input("one");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file one");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels(editor),
vec![format!("one.txt a{slash}")]
);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
let url_one = MentionUri::File {
abs_path: path!("/dir/a/one.txt").into(),
}
.to_uri()
.to_string();
editor.update(&mut cx, |editor, cx| {
let text = editor.text(cx);
assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
assert!(!editor.has_visible_completions_menu());
assert_eq!(fold_ranges(editor, cx).len(), 1);
});
let contents = message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.unwrap()
.into_values()
.collect::<Vec<_>>();
{
let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
panic!("Unexpected mentions");
};
pretty_assertions::assert_eq!(content, "1");
pretty_assertions::assert_eq!(
uri,
&MentionUri::parse(&url_one, PathStyle::local()).unwrap()
);
}
cx.simulate_input(" ");
editor.update(&mut cx, |editor, cx| {
let text = editor.text(cx);
assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
assert!(!editor.has_visible_completions_menu());
assert_eq!(fold_ranges(editor, cx).len(), 1);
});
cx.simulate_input("Ipsum ");
editor.update(&mut cx, |editor, cx| {
let text = editor.text(cx);
assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
assert!(!editor.has_visible_completions_menu());
assert_eq!(fold_ranges(editor, cx).len(), 1);
});
cx.simulate_input("@file ");
editor.update(&mut cx, |editor, cx| {
let text = editor.text(cx);
assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
assert!(editor.has_visible_completions_menu());
assert_eq!(fold_ranges(editor, cx).len(), 1);
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
let contents = message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.unwrap()
.into_values()
.collect::<Vec<_>>();
let url_eight = MentionUri::File {
abs_path: path!("/dir/b/eight.txt").into(),
}
.to_uri()
.to_string();
{
let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
panic!("Unexpected mentions");
};
pretty_assertions::assert_eq!(content, "8");
pretty_assertions::assert_eq!(
uri,
&MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
);
}
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
);
assert!(!editor.has_visible_completions_menu());
assert_eq!(fold_ranges(editor, cx).len(), 2);
});
let plain_text_language = Arc::new(language::Language::new(
language::LanguageConfig {
name: "Plain Text".into(),
matcher: language::LanguageMatcher {
path_suffixes: vec!["txt".to_string()],
..Default::default()
},
..Default::default()
},
None,
));
// Register the language and fake LSP
let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
language_registry.add(plain_text_language);
let mut fake_language_servers = language_registry.register_fake_lsp(
"Plain Text",
language::FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
},
..Default::default()
},
);
// Open the buffer to trigger LSP initialization
let buffer = project
.update(&mut cx, |project, cx| {
project.open_local_buffer(path!("/dir/a/one.txt"), cx)
})
.await
.unwrap();
// Register the buffer with language servers
let _handle = project.update(&mut cx, |project, cx| {
project.register_buffer_with_language_servers(&buffer, cx)
});
cx.run_until_parked();
let fake_language_server = fake_language_servers.next().await.unwrap();
fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
move |_, _| async move {
Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
#[allow(deprecated)]
lsp::SymbolInformation {
name: "MySymbol".into(),
location: lsp::Location {
uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 1),
),
},
kind: lsp::SymbolKind::CONSTANT,
tags: None,
container_name: None,
deprecated: None,
},
])))
},
);
cx.simulate_input("@symbol ");
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
);
assert!(editor.has_visible_completions_menu());
assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
let symbol = MentionUri::Symbol {
abs_path: path!("/dir/a/one.txt").into(),
name: "MySymbol".into(),
line_range: 0..=0,
};
let contents = message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.unwrap()
.into_values()
.collect::<Vec<_>>();
{
let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
panic!("Unexpected mentions");
};
pretty_assertions::assert_eq!(content, "1");
pretty_assertions::assert_eq!(uri, &symbol);
}
cx.run_until_parked();
editor.read_with(&cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!(
"Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
symbol.to_uri(),
)
);
});
// Try to mention an "image" file that will fail to load
cx.simulate_input("@file x.png");
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
);
assert!(editor.has_visible_completions_menu());
assert_eq!(current_completion_labels(editor), &["x.png "]);
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
// Getting the message contents fails
message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.expect_err("Should fail to load x.png");
cx.run_until_parked();
// Mention was removed
editor.read_with(&cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!(
"Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
symbol.to_uri()
)
);
});
// Once more
cx.simulate_input("@file x.png");
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
);
assert!(editor.has_visible_completions_menu());
assert_eq!(current_completion_labels(editor), &["x.png "]);
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
// This time don't immediately get the contents, just let the confirmed completion settle
cx.run_until_parked();
// Mention was removed
editor.read_with(&cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!(
"Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
symbol.to_uri()
)
);
});
// Now getting the contents succeeds, because the invalid mention was removed
let contents = message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.unwrap();
assert_eq!(contents.len(), 3);
}
fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor.display_map.update(cx, |display_map, cx| {
display_map
.snapshot(cx)
.folds_in_range(MultiBufferOffset(0)..snapshot.len())
.map(|fold| fold.range.to_point(&snapshot))
.collect()
})
}
fn current_completion_labels(editor: &Editor) -> Vec<String> {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
.map(|completion| completion.label.text)
.collect::<Vec<_>>()
}
fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
.map(|completion| {
(
completion.label.text,
completion
.documentation
.map(|d| d.text().to_string())
.unwrap_or_default(),
)
})
.collect::<Vec<_>>()
}
#[gpui::test]
async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
// Create a large file that exceeds AUTO_OUTLINE_SIZE
// Using plain text without a configured language, so no outline is available
const LINE: &str = "This is a line of text in the file\n";
let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
// Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
let small_content = "fn small_function() { /* small */ }\n";
assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
fs.insert_tree(
"/project",
json!({
"large_file.txt": large_content.clone(),
"small_file.txt": small_content,
}),
)
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
let editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
);
// Enable embedded context so files are actually included
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
editor
})
});
// Test large file mention
// Get the absolute path using the project's worktree
let large_file_abs_path = project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap();
let worktree_root = worktree.read(cx).abs_path();
worktree_root.join("large_file.txt")
});
let large_file_task = message_editor.update(cx, |editor, cx| {
editor.mention_set().update(cx, |set, cx| {
set.confirm_mention_for_file(large_file_abs_path, true, cx)
})
});
let large_file_mention = large_file_task.await.unwrap();
match large_file_mention {
Mention::Text { content, .. } => {
// Should contain some of the content but not all of it
assert!(
content.contains(LINE),
"Should contain some of the file content"
);
assert!(
!content.contains(&LINE.repeat(100)),
"Should not contain the full file"
);
// Should be much smaller than original
assert!(
content.len() < large_content.len() / 10,
"Should be significantly truncated"
);
}
_ => panic!("Expected Text mention for large file"),
}
// Test small file mention
// Get the absolute path using the project's worktree
let small_file_abs_path = project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap();
let worktree_root = worktree.read(cx).abs_path();
worktree_root.join("small_file.txt")
});
let small_file_task = message_editor.update(cx, |editor, cx| {
editor.mention_set().update(cx, |set, cx| {
set.confirm_mention_for_file(small_file_abs_path, true, cx)
})
});
let small_file_mention = small_file_task.await.unwrap();
match small_file_mention {
Mention::Text { content, .. } => {
// Should contain the full actual content
assert_eq!(content, small_content);
}
_ => panic!("Expected Text mention for small file"),
}
}
#[gpui::test]
async fn test_insert_thread_summary(cx: &mut TestAppContext) {
init_test(cx);
cx.update(LanguageModelRegistry::test);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let session_id = acp::SessionId::new("thread-123");
let title = Some("Previous Conversation".into());
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
let mut editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
);
editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx);
editor
})
});
// Construct expected values for verification
let expected_uri = MentionUri::Thread {
id: session_id.clone(),
name: title.as_ref().unwrap().to_string(),
};
let expected_title = title.as_ref().unwrap();
let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
message_editor.read_with(cx, |editor, cx| {
let text = editor.text(cx);
assert!(
text.contains(&expected_link),
"Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
expected_link,
text
);
let mentions = editor.mention_set().read(cx).mentions();
assert_eq!(
mentions.len(),
1,
"Expected exactly one mention after inserting thread summary"
);
assert!(
mentions.contains(&expected_uri),
"Expected mentions to contain the thread URI"
);
});
}
#[gpui::test]
async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
init_test(cx);
cx.update(LanguageModelRegistry::test);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
let mut editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
);
editor.insert_thread_summary(
acp::SessionId::new("thread-123"),
Some("Previous Conversation".into()),
window,
cx,
);
editor
})
});
message_editor.read_with(cx, |editor, cx| {
assert!(
editor.text(cx).is_empty(),
"Expected thread summary to be skipped for external agents"
);
assert!(
editor.mention_set().read(cx).mentions().is_empty(),
"Expected no mentions when thread summary is skipped"
);
});
}
#[gpui::test]
async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
message_editor.update(cx, |editor, _cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
});
let supported_modes = {
let app = cx.app.borrow();
let _ = &app;
message_editor
.read(&app)
.session_capabilities
.read()
.supported_modes(false)
};
assert!(
!supported_modes.contains(&PromptContextType::Thread),
"Expected thread mode to be hidden when thread mentions are disabled"
);
}
#[gpui::test]
async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
message_editor.update(cx, |editor, _cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
});
let supported_modes = {
let app = cx.app.borrow();
let _ = &app;
message_editor
.read(&app)
.session_capabilities
.read()
.supported_modes(true)
};
assert!(
supported_modes.contains(&PromptContextType::Thread),
"Expected thread mode to be visible when enabled"
);
}
#[gpui::test]
async fn test_whitespace_trimming(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
editor.set_text(" \u{A0}してhello world ", window, cx);
});
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
assert_eq!(content, vec!["してhello world".into()]);
}
#[gpui::test]
async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
fs.insert_tree(
"/project",
json!({
"src": {
"main.rs": file_content,
}
}),
)
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
cx.simulate_input("What is in @file main");
editor.update_in(cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
assert_eq!(editor.text(cx), "What is in @file main");
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
let content = message_editor
.update(cx, |editor, cx| editor.contents(false, cx))
.await
.unwrap()
.0;
let main_rs_uri = if cfg!(windows) {
"file:///C:/project/src/main.rs"
} else {
"file:///project/src/main.rs"
};
// When embedded context is `false` we should get a resource link
pretty_assertions::assert_eq!(
content,
vec![
"What is in ".into(),
acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
]
);
message_editor.update(cx, |editor, _cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true))
});
let content = message_editor
.update(cx, |editor, cx| editor.contents(false, cx))
.await
.unwrap()
.0;
// When embedded context is `true` we should get a resource
pretty_assertions::assert_eq!(
content,
vec![
"What is in ".into(),
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(file_content, main_rs_uri)
)
))
]
);
}
#[gpui::test]
async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/dir"),
json!({
"test.txt": "line1\nline2\nline3\nline4\nline5\n",
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let worktree = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
assert_eq!(worktrees.len(), 1);
worktrees.pop().unwrap()
});
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
let mut cx = VisualTestContext::from_window(window.into(), cx);
// Open a regular editor with the created file, and select a portion of
// the text that will be used for the selections that are meant to be
// inserted in the agent panel.
let editor = workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_path(
ProjectPath {
worktree_id,
path: rel_path("test.txt").into(),
},
None,
false,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
});
});
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
// Create a new `MessageEditor`. The `EditorMode::full()` has to be used
// to ensure we have a fixed viewport, so we can eventually actually
// place the cursor outside of the visible area.
let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::full(),
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor
});
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
// Update the Agent Panel's Message Editor text to have 100
// lines, ensuring that the cursor is set at line 90 and that we
// then scroll all the way to the top, so the cursor's position
// remains off screen.
let mut lines = String::new();
for _ in 1..=100 {
lines.push_str(&"Another line in the agent panel's message editor\n");
}
editor.set_text(lines.as_str(), window, cx);
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
});
editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
});
});
cx.run_until_parked();
// Before proceeding, let's assert that the cursor is indeed off screen,
// otherwise the rest of the test doesn't make sense.
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(window, cx);
let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
let scroll_top = snapshot.scroll_position().y as u32;
let visible_lines = editor.visible_line_count().unwrap() as u32;
let visible_range = scroll_top..(scroll_top + visible_lines);
assert!(!visible_range.contains(&cursor_row));
})
});
let text_editor_selection = editor.update(&mut cx, |editor, cx| {
let multibuffer = editor.buffer().read(cx);
let buffer = multibuffer.as_singleton().unwrap();
let buffer_snapshot = buffer.read(cx).snapshot();
let start = buffer_snapshot.anchor_before(0);
let end = buffer_snapshot.anchor_after(5);
AgentContextSelection::Editor(vec![(buffer, start..end)])
});
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.insert_selections(text_editor_selection, window, cx);
});
cx.run_until_parked();
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(window, cx);
let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
let scroll_top = snapshot.scroll_position().y as u32;
let visible_lines = editor.visible_line_count().unwrap() as u32;
let visible_range = scroll_top..(scroll_top + visible_lines);
assert!(visible_range.contains(&cursor_row));
})
});
}
#[gpui::test]
async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(path!("/dir"), json!({}))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store.clone()),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.set_text("😄😄", window, cx);
});
cx.run_until_parked();
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.insert_context_type("file", window, cx);
});
cx.run_until_parked();
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "😄😄@file");
});
}
#[gpui::test]
async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), json!({"file.txt": "content"}))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
editor.update_in(&mut cx, |editor, window, cx| {
editor.set_text(
"AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA",
window,
cx,
);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([
MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large)
MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest)
]);
});
});
let mention_link = "[@f](file:///test.txt)";
cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into()));
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.paste(&Paste, window, cx);
});
let text = editor.update(&mut cx, |editor, cx| editor.text(cx));
assert!(
text.contains("[@f](file:///test.txt)"),
"Expected mention link to be pasted, got: {}",
text
);
}
#[gpui::test]
async fn test_copy_with_selection_mentions_serializes_links(cx: &mut TestAppContext) {
init_test(cx);
let (source_message_editor, _source_editor, mut cx) = setup_paste_test_message_editor(
json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
cx,
)
.await;
let workspace = source_message_editor.read_with(&cx, |message_editor, _| {
message_editor.workspace.upgrade().expect("workspace")
});
let project = workspace.read_with(&cx, |workspace, _| workspace.project().clone());
let source_text = "selection needs work\nselection looks fine";
let first_range = 0..9;
let second_start = "selection needs work\n".len();
let second_range = second_start..(second_start + "selection".len());
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.set_text(source_text, window, cx);
let snapshot = message_editor
.editor
.read(cx)
.buffer()
.read(cx)
.snapshot(cx);
for (range, uri, content) in [
(
first_range.clone(),
first_uri.clone(),
"line 1\nline 2\n".to_string(),
),
(
second_range.clone(),
second_uri.clone(),
"line 3\nline 4\n".to_string(),
),
] {
let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
snapshot
.anchor_to_buffer_anchor(
snapshot.anchor_before(MultiBufferOffset(range.start)),
)
.expect("selection mention anchor should map to a buffer")
.0,
range.len(),
uri.name().into(),
uri.icon_path(cx),
uri.tooltip_text(),
Some(uri.clone()),
Some(message_editor.workspace.clone()),
None,
message_editor.editor.clone(),
window,
cx,
) else {
panic!("expected mention crease insertion");
};
drop(tx);
message_editor.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
uri,
Task::ready(Ok(Mention::Text {
content,
tracked_buffers: Vec::new(),
}))
.shared(),
None,
cx,
);
});
}
let buffer_len = snapshot.len();
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
});
});
});
let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| {
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
.expect("selection mentions should serialize")
});
let expected_text = format!(
"{} needs work\n{} looks fine",
first_uri.as_link(),
second_uri.as_link()
);
assert_eq!(copied_text, expected_text);
let target_message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
message_editor
});
cx.write_to_clipboard(ClipboardItem::new_string(copied_text));
target_message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.paste(&Paste, window, cx);
});
cx.run_until_parked();
let target_text = target_message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(target_text, expected_text);
let contents = mention_contents(&target_message_editor, &mut cx).await;
assert_eq!(contents.len(), 2);
assert!(contents.iter().any(|(uri, _)| uri == &first_uri));
assert!(contents.iter().any(|(uri, _)| uri == &second_uri));
}
struct SelectionMentionFixture {
message_editor: Entity<MessageEditor>,
first_uri: MentionUri,
first_range: Range<usize>,
second_uri: MentionUri,
second_range: Range<usize>,
buffer_len: MultiBufferOffset,
}
async fn setup_selection_mention_fixture(
cx: &mut TestAppContext,
) -> (SelectionMentionFixture, VisualTestContext) {
let (message_editor, _source_editor, mut cx) = setup_paste_test_message_editor(
json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
cx,
)
.await;
let source_text = "selection needs work\nselection looks fine";
let first_range = 0..9;
let second_start = "selection needs work\n".len();
let second_range = second_start..(second_start + "selection".len());
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.set_text(source_text, window, cx);
let snapshot = message_editor
.editor
.read(cx)
.buffer()
.read(cx)
.snapshot(cx);
for (range, uri, content) in [
(
first_range.clone(),
first_uri.clone(),
"line 1\nline 2\n".to_string(),
),
(
second_range.clone(),
second_uri.clone(),
"line 3\nline 4\n".to_string(),
),
] {
let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
snapshot
.anchor_to_buffer_anchor(
snapshot.anchor_before(MultiBufferOffset(range.start)),
)
.expect("selection mention anchor should map to a buffer")
.0,
range.len(),
uri.name().into(),
uri.icon_path(cx),
uri.tooltip_text(),
Some(uri.clone()),
Some(message_editor.workspace.clone()),
None,
message_editor.editor.clone(),
window,
cx,
) else {
panic!("expected mention crease insertion");
};
drop(tx);
message_editor.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
uri,
Task::ready(Ok(Mention::Text {
content,
tracked_buffers: Vec::new(),
}))
.shared(),
None,
cx,
);
});
}
snapshot.len()
});
(
SelectionMentionFixture {
message_editor,
first_uri,
first_range,
second_uri,
second_range,
buffer_len,
},
cx,
)
}
#[gpui::test]
async fn test_serialized_copy_text_selection_covers_only_mention(cx: &mut TestAppContext) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
let range = fixture.first_range.clone();
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
MultiBufferOffset(range.start)..MultiBufferOffset(range.end)
]);
});
});
});
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, Some(fixture.first_uri.as_link().to_string()));
}
#[gpui::test]
async fn test_serialized_copy_text_returns_none_when_mentions_outside_selection(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let between_start = fixture.first_range.end;
let between_end = fixture.second_range.start - 1;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
]);
});
});
});
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, None);
}
#[gpui::test]
async fn test_draft_content_blocks_snapshot_preserves_selection_mentions(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let blocks = fixture.message_editor.update(&mut cx, |editor, cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
editor.draft_content_blocks_snapshot(cx)
});
// Each selection mention must round-trip as a `Resource` block carrying
// its URI and content, not as a `Text` block containing the fold
// placeholder string.
let resource_uris: Vec<&str> =
blocks
.iter()
.filter_map(|block| match block {
acp::ContentBlock::Resource(acp::EmbeddedResource {
resource:
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents { uri, .. },
),
..
}) => Some(uri.as_str()),
_ => None,
})
.collect();
assert_eq!(
resource_uris.len(),
2,
"snapshot should emit one Resource block per selection mention; got {blocks:#?}"
);
assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str()));
for block in &blocks {
if let acp::ContentBlock::Text(text) = block {
assert!(
!text.text.split_whitespace().any(|word| word == "selection"),
"text block must not contain bare fold placeholder: {:?}",
text.text
);
}
}
}
#[gpui::test]
async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let buffer_len = fixture.buffer_len;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
});
});
message_editor.cut(&Cut, window, cx);
});
let expected_text = format!(
"{} needs work\n{} looks fine",
fixture.first_uri.as_link(),
fixture.second_uri.as_link()
);
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(clipboard_text, expected_text);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "");
}
#[gpui::test]
async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4);
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([cursor_offset..cursor_offset]);
});
});
message_editor.cut(&Cut, window, cx);
});
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(
clipboard_text,
format!("{} needs work\n", fixture.first_uri.as_link())
);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "selection looks fine");
}
#[gpui::test]
async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let between_start = fixture.first_range.end;
let between_end = fixture.second_range.start - 1;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
]);
});
});
});
let result = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialize_selection_with_mentions(true, cx)
});
assert!(
result.is_none(),
"serialize_selection_with_mentions should return None so the default editor cut runs"
);
}
#[gpui::test]
async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
cx: &mut TestAppContext,
) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), json!({"file.txt": "content"}))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let (_message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
cx.simulate_input("@");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "@");
assert!(editor.has_visible_completions_menu());
});
cx.write_to_clipboard(ClipboardItem::new_string("[@f](file:///test.txt) @".into()));
cx.dispatch_action(Paste);
editor.update(&mut cx, |editor, cx| {
assert!(editor.text(cx).contains("[@f](file:///test.txt)"));
});
}
#[gpui::test]
async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, editor, mut cx) =
setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
paste_external_paths(
&message_editor,
vec![PathBuf::from(path!("/project/file.txt"))],
&mut cx,
);
let expected_uri = MentionUri::File {
abs_path: path!("/project/file.txt").into(),
}
.to_uri()
.to_string();
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) "));
});
let contents = mention_contents(&message_editor, &mut cx).await;
let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
panic!("Unexpected mentions");
};
assert_eq!(content, "content");
assert_eq!(
uri,
&MentionUri::File {
abs_path: path!("/project/file.txt").into(),
}
);
}
#[gpui::test]
async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, editor, mut cx) = setup_paste_test_message_editor(
json!({
"src": {
"main.rs": "fn main() {}\n",
}
}),
cx,
)
.await;
paste_external_paths(
&message_editor,
vec![PathBuf::from(path!("/project/src"))],
&mut cx,
);
let expected_uri = MentionUri::Directory {
abs_path: path!("/project/src").into(),
}
.to_uri()
.to_string();
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) "));
});
let contents = mention_contents(&message_editor, &mut cx).await;
let [(uri, Mention::Link)] = contents.as_slice() else {
panic!("Unexpected mentions");
};
assert_eq!(
uri,
&MentionUri::Directory {
abs_path: path!("/project/src").into(),
}
);
}
#[gpui::test]
async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, editor, mut cx) =
setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
editor.update_in(&mut cx, |editor, window, cx| {
editor.set_text("Hello world", window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
});
});
paste_external_paths(
&message_editor,
vec![PathBuf::from(path!("/project/file.txt"))],
&mut cx,
);
let expected_uri = MentionUri::File {
abs_path: path!("/project/file.txt").into(),
}
.to_uri()
.to_string();
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("Hello [@file.txt]({expected_uri}) world")
);
});
}
#[gpui::test]
async fn test_paste_mixed_external_image_without_extension_and_file_path(
cx: &mut TestAppContext,
) {
init_test(cx);
let (message_editor, editor, mut cx) =
setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
message_editor.update(&mut cx, |message_editor, _cx| {
message_editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
});
let temporary_image_path = write_test_png_file(None);
paste_external_paths(
&message_editor,
vec![
temporary_image_path.clone(),
PathBuf::from(path!("/project/file.txt")),
],
&mut cx,
);
let image_name = temporary_image_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Image")
.to_string();
std::fs::remove_file(&temporary_image_path).expect("remove temp png");
let expected_file_uri = MentionUri::File {
abs_path: path!("/project/file.txt").into(),
}
.to_uri()
.to_string();
let expected_image_uri = MentionUri::PastedImage {
name: image_name.clone(),
}
.to_uri()
.to_string();
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
format!("[@{image_name}]({expected_image_uri}) [@file.txt]({expected_file_uri}) ")
);
});
let contents = mention_contents(&message_editor, &mut cx).await;
assert_eq!(contents.len(), 2);
assert!(contents.iter().any(|(uri, mention)| {
matches!(uri, MentionUri::PastedImage { .. }) && matches!(mention, Mention::Image(_))
}));
assert!(contents.iter().any(|(uri, mention)| {
*uri == MentionUri::File {
abs_path: path!("/project/file.txt").into(),
} && matches!(
mention,
Mention::Text {
content,
tracked_buffers: _,
} if content == "content"
)
}));
}
async fn setup_paste_test_message_editor(
project_tree: Value,
cx: &mut TestAppContext,
) -> (Entity<MessageEditor>, Entity<Editor>, VisualTestContext) {
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), project_tree)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
(message_editor, editor, cx)
}
fn paste_external_paths(
message_editor: &Entity<MessageEditor>,
paths: Vec<PathBuf>,
cx: &mut VisualTestContext,
) {
cx.write_to_clipboard(ClipboardItem {
entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))],
});
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.paste(&Paste, window, cx);
});
cx.run_until_parked();
}
async fn mention_contents(
message_editor: &Entity<MessageEditor>,
cx: &mut VisualTestContext,
) -> Vec<(MentionUri, Mention)> {
message_editor
.update(cx, |message_editor, cx| {
message_editor
.mention_set()
.update(cx, |mention_set, cx| mention_set.contents(false, cx))
})
.await
.unwrap()
.into_values()
.collect::<Vec<_>>()
}
fn write_test_png_file(extension: Option<&str>) -> PathBuf {
let bytes = base64::prelude::BASE64_STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
.expect("decode png");
let file_name = match extension {
Some(extension) => format!("zed-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension),
None => format!("zed-agent-ui-test-{}", uuid::Uuid::new_v4()),
};
let path = std::env::temp_dir().join(file_name);
std::fs::write(&path, bytes).expect("write temp png");
path
}
// Helper that creates a minimal MessageEditor inside a window, returning both
// the entity and the underlying VisualTestContext so callers can drive updates.
async fn setup_message_editor(
cx: &mut TestAppContext,
) -> (Entity<MessageEditor>, &mut VisualTestContext) {
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file.txt": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
None,
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
cx.run_until_parked();
(message_editor, cx)
}
#[gpui::test]
async fn test_set_message_plain_text(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |editor, window, cx| {
editor.set_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"hello world".to_string(),
))],
window,
cx,
);
});
let text = message_editor.update(cx, |editor, cx| editor.text(cx));
assert_eq!(text, "hello world");
assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx)));
}
#[gpui::test]
async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
// Set initial content.
message_editor.update_in(cx, |editor, window, cx| {
editor.set_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"old content".to_string(),
))],
window,
cx,
);
});
// Replace with new content.
message_editor.update_in(cx, |editor, window, cx| {
editor.set_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"new content".to_string(),
))],
window,
cx,
);
});
let text = message_editor.update(cx, |editor, cx| editor.text(cx));
assert_eq!(
text, "new content",
"set_message should replace old content"
);
}
#[gpui::test]
async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |editor, window, cx| {
editor.append_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"appended".to_string(),
))],
Some("\n\n"),
window,
cx,
);
});
let text = message_editor.update(cx, |editor, cx| editor.text(cx));
assert_eq!(
text, "appended",
"No separator should be inserted when the editor is empty"
);
}
#[gpui::test]
async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
// Seed initial content.
message_editor.update_in(cx, |editor, window, cx| {
editor.set_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"initial".to_string(),
))],
window,
cx,
);
});
// Append with separator.
message_editor.update_in(cx, |editor, window, cx| {
editor.append_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"appended".to_string(),
))],
Some("\n\n"),
window,
cx,
);
});
let text = message_editor.update(cx, |editor, cx| editor.text(cx));
assert_eq!(
text, "initial\n\nappended",
"Separator should appear between existing and appended content"
);
}
#[gpui::test]
async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file.txt": "content"}))
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
None,
None,
Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
cx.run_until_parked();
// Seed plain-text prefix so the editor is non-empty before appending.
message_editor.update_in(cx, |editor, window, cx| {
editor.set_message(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"prefix text".to_string(),
))],
window,
cx,
);
});
// Append a message that contains a ResourceLink mention.
message_editor.update_in(cx, |editor, window, cx| {
editor.append_message(
vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
"file.txt",
"file:///project/file.txt",
))],
Some("\n\n"),
window,
cx,
);
});
cx.run_until_parked();
// The mention should be registered in the mention_set so that contents()
// will emit it as a structured block rather than plain text.
let mention_uris =
message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
assert_eq!(
mention_uris.len(),
1,
"Expected exactly one mention in the mention_set after append, got: {mention_uris:?}"
);
// The editor text should start with the prefix, then the separator, then
// the mention placeholder — confirming the offset was computed correctly.
let text = message_editor.update(cx, |editor, cx| editor.text(cx));
assert!(
text.starts_with("prefix text\n\n"),
"Expected text to start with 'prefix text\\n\\n', got: {text:?}"
);
}
}