mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Removes unused `@rule` mentions and unused APIs from `prompt_store` Follow up to #58067 Release Notes: - N/A
5442 lines
194 KiB
Rust
5442 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 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>>,
|
|
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()));
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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,
|
|
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),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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()),
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
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,
|
|
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,
|
|
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:?}"
|
|
);
|
|
}
|
|
}
|