@@ -26,12 +26,13 @@ use gpui::{
KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
};
use language::{Buffer, Language, language_settings::InlayHintKind};
+use parking_lot::RwLock;
use project::AgentId;
use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
use prompt_store::PromptStore;
use rope::Point;
use settings::Settings;
-use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
+use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc};
use theme::ThemeSettings;
use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*};
use util::paths::PathStyle;
@@ -39,41 +40,39 @@ use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, PasteRaw};
-pub struct MessageEditor {
- mention_set: Entity<MentionSet>,
- editor: Entity<Editor>,
- workspace: WeakEntity<Workspace>,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- agent_id: AgentId,
- thread_store: Option<Entity<ThreadStore>>,
- _subscriptions: Vec<Subscription>,
- _parse_slash_command_task: Task<()>,
+#[derive(Default)]
+pub struct SessionCapabilities {
+ prompt_capabilities: acp::PromptCapabilities,
+ available_commands: Vec<acp::AvailableCommand>,
}
-#[derive(Clone, Debug)]
-pub enum MessageEditorEvent {
- Send,
- SendImmediately,
- Cancel,
- Focus,
- LostFocus,
- InputAttempted(Arc<str>),
-}
+impl SessionCapabilities {
+ pub fn new(
+ prompt_capabilities: acp::PromptCapabilities,
+ available_commands: Vec<acp::AvailableCommand>,
+ ) -> Self {
+ Self {
+ prompt_capabilities,
+ available_commands,
+ }
+ }
-impl EventEmitter<MessageEditorEvent> for MessageEditor {}
+ pub fn supports_images(&self) -> bool {
+ self.prompt_capabilities.image
+ }
-const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
+ pub fn supports_embedded_context(&self) -> bool {
+ self.prompt_capabilities.embedded_context
+ }
-impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
- fn supports_images(&self, cx: &App) -> bool {
- self.read(cx).prompt_capabilities.borrow().image
+ pub fn available_commands(&self) -> &[acp::AvailableCommand] {
+ &self.available_commands
}
- fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
+ fn supported_modes(&self, has_thread_store: bool) -> Vec<PromptContextType> {
let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
- if self.read(cx).prompt_capabilities.borrow().embedded_context {
- if self.read(cx).thread_store.is_some() {
+ if self.prompt_capabilities.embedded_context {
+ if has_thread_store {
supported.push(PromptContextType::Thread);
}
supported.extend(&[
@@ -86,10 +85,8 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
supported
}
- fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
- self.read(cx)
- .available_commands
- .borrow()
+ pub fn completion_commands(&self) -> Vec<crate::completion_provider::AvailableCommand> {
+ self.available_commands
.iter()
.map(|cmd| crate::completion_provider::AvailableCommand {
name: cmd.name.clone().into(),
@@ -99,11 +96,68 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
.collect()
}
+ 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 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<crate::completion_provider::AvailableCommand> {
+ self.session_capabilities.read().completion_commands()
+ }
+
fn confirm_command(&self, cx: &mut App) {
- self.update(cx, |this, cx| this.send(cx));
+ 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 MessageEditorEvent {
+ Send,
+ SendImmediately,
+ Cancel,
+ Focus,
+ LostFocus,
+ InputAttempted(Arc<str>),
+}
+
+impl EventEmitter<MessageEditorEvent> for MessageEditor {}
+
+const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
+
impl MessageEditor {
pub fn new(
workspace: WeakEntity<Workspace>,
@@ -111,8 +165,7 @@ impl MessageEditor {
thread_store: Option<Entity<ThreadStore>>,
history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+ session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
placeholder: &str,
mode: EditorMode,
@@ -164,7 +217,11 @@ impl MessageEditor {
let mention_set =
cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
let completion_provider = Rc::new(PromptCompletionProvider::new(
- cx.entity(),
+ MessageEditorCompletionDelegate {
+ session_capabilities: session_capabilities.clone(),
+ has_thread_store: thread_store.is_some(),
+ message_editor: cx.weak_entity(),
+ },
editor.downgrade(),
mention_set.clone(),
history,
@@ -234,8 +291,7 @@ impl MessageEditor {
editor,
mention_set,
workspace,
- prompt_capabilities,
- available_commands,
+ session_capabilities,
agent_id,
thread_store,
_subscriptions: subscriptions,
@@ -243,18 +299,17 @@ impl MessageEditor {
}
}
- pub fn set_command_state(
+ pub fn set_session_capabilities(
&mut self,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+ session_capabilities: SharedSessionCapabilities,
_cx: &mut Context<Self>,
) {
- self.prompt_capabilities = prompt_capabilities;
- self.available_commands = available_commands;
+ self.session_capabilities = session_capabilities;
}
fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
- let available_commands = self.available_commands.borrow();
+ let session_capabilities = self.session_capabilities.read();
+ let available_commands = session_capabilities.available_commands();
if available_commands.is_empty() {
return None;
}
@@ -334,7 +389,7 @@ impl MessageEditor {
.text_anchor
});
- let supports_images = self.prompt_capabilities.borrow().image;
+ let supports_images = self.session_capabilities.read().supports_images();
self.mention_set
.update(cx, |mention_set, cx| {
@@ -415,7 +470,11 @@ impl MessageEditor {
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let text = self.editor.read(cx).text(cx);
- let available_commands = self.available_commands.borrow().clone();
+ let available_commands = self
+ .session_capabilities
+ .read()
+ .available_commands()
+ .to_vec();
let agent_id = self.agent_id.clone();
let build_task = self.build_content_blocks(full_mention_content, cx);
@@ -442,7 +501,8 @@ impl MessageEditor {
.mention_set
.update(cx, |store, cx| store.contents(full_mention_content, cx));
let editor = self.editor.clone();
- let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
+ let supports_embedded_context =
+ self.session_capabilities.read().supports_embedded_context();
cx.spawn(async move |_, cx| {
let contents = contents.await?;
@@ -822,7 +882,7 @@ impl MessageEditor {
}
if !all_mentions.is_empty() {
- let supports_images = self.prompt_capabilities.borrow().image;
+ 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 {
@@ -881,7 +941,7 @@ impl MessageEditor {
})
.unwrap_or(false);
- if self.prompt_capabilities.borrow().image
+ if self.session_capabilities.read().supports_images()
&& has_non_text_content
&& let Some(task) = paste_images_as_context(
self.editor.clone(),
@@ -958,7 +1018,7 @@ impl MessageEditor {
cx,
);
});
- let supports_images = self.prompt_capabilities.borrow().image;
+ let supports_images = self.session_capabilities.read().supports_images();
tasks.push(self.mention_set.update(cx, |mention_set, cx| {
mention_set.confirm_mention_completion(
file_name,
@@ -1213,7 +1273,7 @@ impl MessageEditor {
return;
};
let Some(completion) =
- PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
+ PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
PromptContextAction::AddSelections,
anchor..anchor,
self.editor.downgrade(),
@@ -1235,7 +1295,7 @@ impl MessageEditor {
}
pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !self.prompt_capabilities.borrow().image {
+ if !self.session_capabilities.read().supports_images() {
return;
}
@@ -1662,7 +1722,7 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
#[cfg(test)]
mod tests {
- use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
+ use std::{ops::Range, path::Path, sync::Arc};
use acp_thread::MentionUri;
use agent::{ThreadStore, outline};
@@ -1680,6 +1740,7 @@ mod tests {
};
use language_model::LanguageModelRegistry;
use lsp::{CompletionContext, CompletionTriggerKind};
+ use parking_lot::RwLock;
use project::{CompletionIntent, Project, ProjectPath};
use serde_json::json;
@@ -1688,10 +1749,10 @@ mod tests {
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{AppState, Item, MultiWorkspace};
- use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
+ use crate::completion_provider::PromptContextType;
use crate::{
conversation_view::tests::init_test,
- message_editor::{Mention, MessageEditor, parse_mention_links},
+ message_editor::{Mention, MessageEditor, SessionCapabilities, parse_mention_links},
};
#[test]
@@ -1809,7 +1870,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -1904,9 +1964,10 @@ mod tests {
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let thread_store = None;
- let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
- // Start with no available commands - simulating Claude which doesn't support slash commands
- let available_commands = Rc::new(RefCell::new(vec![]));
+ let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
+ acp::PromptCapabilities::default(),
+ vec![],
+ )));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
@@ -1920,8 +1981,7 @@ mod tests {
thread_store.clone(),
None,
None,
- prompt_capabilities.clone(),
- available_commands.clone(),
+ session_capabilities.clone(),
"Claude Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -1951,7 +2011,9 @@ mod tests {
assert!(error_message.contains("Available commands: none"));
// Now simulate Claude providing its list of available commands (which doesn't include file)
- available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
+ 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| {
@@ -2065,15 +2127,17 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
- let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
- let available_commands = Rc::new(RefCell::new(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 session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
+ 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();
@@ -2084,8 +2148,7 @@ mod tests {
thread_store.clone(),
None,
None,
- prompt_capabilities.clone(),
- available_commands.clone(),
+ session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2298,7 +2361,10 @@ mod tests {
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
+ let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
+ acp::PromptCapabilities::default(),
+ vec![],
+ )));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -2309,8 +2375,7 @@ mod tests {
Some(thread_store),
None,
None,
- prompt_capabilities.clone(),
- Default::default(),
+ session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2356,12 +2421,14 @@ mod tests {
editor.set_text("", window, cx);
});
- prompt_capabilities.replace(
- acp::PromptCapabilities::new()
- .image(true)
- .audio(true)
- .embedded_context(true),
- );
+ 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 ");
@@ -2802,7 +2869,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2814,8 +2880,9 @@ mod tests {
);
// Enable embedded context so files are actually included
editor
- .prompt_capabilities
- .replace(acp::PromptCapabilities::new().embedded_context(true));
+ .session_capabilities
+ .write()
+ .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
editor
})
});
@@ -2904,7 +2971,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2975,7 +3041,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3030,7 +3095,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3045,13 +3109,19 @@ mod tests {
message_editor.update(cx, |editor, _cx| {
editor
- .prompt_capabilities
- .replace(acp::PromptCapabilities::new().embedded_context(true));
+ .session_capabilities
+ .write()
+ .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
});
let supported_modes = {
let app = cx.app.borrow();
- message_editor.supported_modes(&app)
+ let _ = &app;
+ message_editor
+ .read(&app)
+ .session_capabilities
+ .read()
+ .supported_modes(false)
};
assert!(
@@ -3083,7 +3153,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3098,13 +3167,19 @@ mod tests {
message_editor.update(cx, |editor, _cx| {
editor
- .prompt_capabilities
- .replace(acp::PromptCapabilities::new().embedded_context(true));
+ .session_capabilities
+ .write()
+ .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
});
let supported_modes = {
let app = cx.app.borrow();
- message_editor.supported_modes(&app)
+ let _ = &app;
+ message_editor
+ .read(&app)
+ .session_capabilities
+ .read()
+ .supported_modes(true)
};
assert!(
@@ -3137,7 +3212,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3201,12 +3275,11 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
- max_lines: None,
min_lines: 1,
+ max_lines: None,
},
window,
cx,
@@ -3258,8 +3331,9 @@ mod tests {
message_editor.update(cx, |editor, _cx| {
editor
- .prompt_capabilities
- .replace(acp::PromptCapabilities::new().embedded_context(true))
+ .session_capabilities
+ .write()
+ .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true))
});
let content = message_editor
@@ -3362,7 +3436,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::full(),
@@ -3474,11 +3547,10 @@ mod tests {
MessageEditor::new(
workspace_handle,
project.downgrade(),
- Some(thread_store),
+ Some(thread_store.clone()),
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3559,7 +3631,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3619,6 +3690,86 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ let app_state = cx.update(AppState::test);
+
+ cx.update(|cx| {
+ editor::init(cx);
+ workspace::init(app_state.clone(), cx);
+ });
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(path!("/project"), json!({"file.txt": "content"}))
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
+ let window =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+
+ let mut cx = VisualTestContext::from_window(window.into(), cx);
+
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+
+ let (_message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
+ let workspace_handle = cx.weak_entity();
+ let message_editor = cx.new(|cx| {
+ MessageEditor::new(
+ workspace_handle,
+ project.downgrade(),
+ Some(thread_store),
+ None,
+ None,
+ Default::default(),
+ "Test Agent".into(),
+ "Test",
+ EditorMode::AutoHeight {
+ max_lines: None,
+ min_lines: 1,
+ },
+ window,
+ cx,
+ )
+ });
+ workspace.active_pane().update(cx, |pane, cx| {
+ pane.add_item(
+ Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
+ true,
+ true,
+ None,
+ window,
+ cx,
+ );
+ });
+ message_editor.read(cx).focus_handle(cx).focus(window, cx);
+ let editor = message_editor.read(cx).editor().clone();
+ (message_editor, editor)
+ });
+
+ cx.simulate_input("@");
+
+ editor.update(&mut cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "@");
+ assert!(editor.has_visible_completions_menu());
+ });
+
+ cx.write_to_clipboard(ClipboardItem::new_string("[@f](file:///test.txt) @".into()));
+ cx.dispatch_action(Paste);
+
+ editor.update(&mut cx, |editor, cx| {
+ assert!(editor.text(cx).contains("[@f](file:///test.txt)"));
+ });
+ }
+
// 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(
@@ -3641,7 +3792,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -3792,7 +3942,6 @@ mod tests {
None,
None,
Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {