1use crate::acp::completion_provider::ContextPickerCompletionProvider;
2use crate::acp::completion_provider::MentionSet;
3use acp_thread::MentionUri;
4use agent_client_protocol as acp;
5use anyhow::Result;
6use collections::HashSet;
7use editor::{
8 AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
9 EditorStyle, MultiBuffer,
10};
11use file_icons::FileIcons;
12use gpui::{
13 AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity,
14};
15use language::Buffer;
16use language::Language;
17use parking_lot::Mutex;
18use project::{CompletionIntent, Project};
19use settings::Settings;
20use std::path::Path;
21use std::rc::Rc;
22use std::sync::Arc;
23use theme::ThemeSettings;
24use ui::{
25 ActiveTheme, App, IconName, InteractiveElement, IntoElement, ParentElement, Render,
26 SharedString, Styled, TextSize, Window, div,
27};
28use util::ResultExt;
29use workspace::Workspace;
30use zed_actions::agent::Chat;
31
32pub struct MessageEditor {
33 editor: Entity<Editor>,
34 project: Entity<Project>,
35 mention_set: Arc<Mutex<MentionSet>>,
36}
37
38pub enum MessageEditorEvent {
39 Send,
40 Cancel,
41}
42
43impl EventEmitter<MessageEditorEvent> for MessageEditor {}
44
45impl MessageEditor {
46 pub fn new(
47 workspace: WeakEntity<Workspace>,
48 project: Entity<Project>,
49 mode: EditorMode,
50 window: &mut Window,
51 cx: &mut Context<Self>,
52 ) -> Self {
53 let language = Language::new(
54 language::LanguageConfig {
55 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
56 ..Default::default()
57 },
58 None,
59 );
60
61 let mention_set = Arc::new(Mutex::new(MentionSet::default()));
62 let editor = cx.new(|cx| {
63 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
64 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
65
66 let mut editor = Editor::new(mode, buffer, None, window, cx);
67 editor.set_placeholder_text("Message the agent - @ to include files", cx);
68 editor.set_show_indent_guides(false, cx);
69 editor.set_soft_wrap();
70 editor.set_use_modal_editing(true);
71 editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
72 mention_set.clone(),
73 workspace,
74 cx.weak_entity(),
75 ))));
76 editor.set_context_menu_options(ContextMenuOptions {
77 min_entries_visible: 12,
78 max_entries_visible: 12,
79 placement: Some(ContextMenuPlacement::Above),
80 });
81 editor
82 });
83
84 Self {
85 editor,
86 project,
87 mention_set,
88 }
89 }
90
91 pub fn is_empty(&self, cx: &App) -> bool {
92 self.editor.read(cx).is_empty(cx)
93 }
94
95 pub fn contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
96 let contents = self.mention_set.lock().contents(self.project.clone(), cx);
97 let editor = self.editor.clone();
98
99 cx.spawn(async move |_, cx| {
100 let contents = contents.await?;
101
102 editor.update(cx, |editor, cx| {
103 let mut ix = 0;
104 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
105 let text = editor.text(cx);
106 editor.display_map.update(cx, |map, cx| {
107 let snapshot = map.snapshot(cx);
108 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
109 // Skip creases that have been edited out of the message buffer.
110 if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
111 continue;
112 }
113
114 if let Some(mention) = contents.get(&crease_id) {
115 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
116 if crease_range.start > ix {
117 chunks.push(text[ix..crease_range.start].into());
118 }
119 chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource {
120 annotations: None,
121 resource: acp::EmbeddedResourceResource::TextResourceContents(
122 acp::TextResourceContents {
123 mime_type: None,
124 text: mention.content.clone(),
125 uri: mention.uri.to_uri(),
126 },
127 ),
128 }));
129 ix = crease_range.end;
130 }
131 }
132
133 if ix < text.len() {
134 let last_chunk = text[ix..].trim_end();
135 if !last_chunk.is_empty() {
136 chunks.push(last_chunk.into());
137 }
138 }
139 });
140
141 chunks
142 })
143 })
144 }
145
146 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
147 self.editor.update(cx, |editor, cx| {
148 editor.clear(window, cx);
149 editor.remove_creases(self.mention_set.lock().drain(), cx)
150 });
151 }
152
153 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
154 cx.emit(MessageEditorEvent::Send)
155 }
156
157 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
158 cx.emit(MessageEditorEvent::Cancel)
159 }
160
161 pub fn insert_dragged_files(
162 &self,
163 paths: Vec<project::ProjectPath>,
164 window: &mut Window,
165 cx: &mut Context<Self>,
166 ) {
167 let buffer = self.editor.read(cx).buffer().clone();
168 let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else {
169 return;
170 };
171 let Some(buffer) = buffer.read(cx).as_singleton() else {
172 return;
173 };
174 for path in paths {
175 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
176 continue;
177 };
178 let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
179 continue;
180 };
181
182 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
183 let path_prefix = abs_path
184 .file_name()
185 .unwrap_or(path.path.as_os_str())
186 .display()
187 .to_string();
188 let completion = ContextPickerCompletionProvider::completion_for_path(
189 path,
190 &path_prefix,
191 false,
192 entry.is_dir(),
193 excerpt_id,
194 anchor..anchor,
195 self.editor.clone(),
196 self.mention_set.clone(),
197 self.project.clone(),
198 cx,
199 );
200
201 self.editor.update(cx, |message_editor, cx| {
202 message_editor.edit(
203 [(
204 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
205 completion.new_text,
206 )],
207 cx,
208 );
209 });
210 if let Some(confirm) = completion.confirm.clone() {
211 confirm(CompletionIntent::Complete, window, cx);
212 }
213 }
214 }
215
216 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
217 self.editor.update(cx, |editor, cx| {
218 editor.set_mode(mode);
219 cx.notify()
220 });
221 }
222
223 pub fn set_message(
224 &mut self,
225 message: &[acp::ContentBlock],
226 window: &mut Window,
227 cx: &mut Context<Self>,
228 ) {
229 let mut text = String::new();
230 let mut mentions = Vec::new();
231
232 for chunk in message {
233 match chunk {
234 acp::ContentBlock::Text(text_content) => {
235 text.push_str(&text_content.text);
236 }
237 acp::ContentBlock::Resource(acp::EmbeddedResource {
238 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
239 ..
240 }) => {
241 if let Some(mention) = MentionUri::parse(&resource.uri).log_err() {
242 let project_path = self
243 .project
244 .read(cx)
245 .project_path_for_absolute_path(&abs_path, cx);
246 let start = text.len();
247 write!(text, "{}", mention.as_link());
248 let end = text.len();
249 mentions.push((start..end, project_path, filename));
250 }
251 }
252 acp::ContentBlock::Image(_)
253 | acp::ContentBlock::Audio(_)
254 | acp::ContentBlock::Resource(_)
255 | acp::ContentBlock::ResourceLink(_) => {}
256 }
257 }
258
259 let snapshot = self.editor.update(cx, |editor, cx| {
260 editor.set_text(text, window, cx);
261 editor.buffer().read(cx).snapshot(cx)
262 });
263
264 self.mention_set.lock().clear();
265 for (range, project_path, filename) in mentions {
266 let crease_icon_path = if project_path.path.is_dir() {
267 FileIcons::get_folder_icon(false, cx)
268 .unwrap_or_else(|| IconName::Folder.path().into())
269 } else {
270 FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
271 .unwrap_or_else(|| IconName::File.path().into())
272 };
273
274 let anchor = snapshot.anchor_before(range.start);
275 if let Some(project_path) = self.project.read(cx).absolute_path(&project_path, cx) {
276 let crease_id = crate::context_picker::insert_crease_for_mention(
277 anchor.excerpt_id,
278 anchor.text_anchor,
279 range.end - range.start,
280 filename,
281 crease_icon_path,
282 self.editor.clone(),
283 window,
284 cx,
285 );
286
287 if let Some(crease_id) = crease_id {
288 self.mention_set.lock().insert(crease_id, project_path);
289 }
290 }
291 }
292 cx.notify();
293 }
294
295 #[cfg(test)]
296 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
297 self.editor.update(cx, |editor, cx| {
298 editor.set_text(text, window, cx);
299 });
300 }
301}
302
303impl Focusable for MessageEditor {
304 fn focus_handle(&self, cx: &App) -> FocusHandle {
305 self.editor.focus_handle(cx)
306 }
307}
308
309impl Render for MessageEditor {
310 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
311 div()
312 .key_context("MessageEditor")
313 .on_action(cx.listener(Self::chat))
314 .on_action(cx.listener(Self::cancel))
315 .flex_1()
316 .child({
317 let settings = ThemeSettings::get_global(cx);
318 let font_size = TextSize::Small
319 .rems(cx)
320 .to_pixels(settings.agent_font_size(cx));
321 let line_height = settings.buffer_line_height.value() * font_size;
322
323 let text_style = TextStyle {
324 color: cx.theme().colors().text,
325 font_family: settings.buffer_font.family.clone(),
326 font_fallbacks: settings.buffer_font.fallbacks.clone(),
327 font_features: settings.buffer_font.features.clone(),
328 font_size: font_size.into(),
329 line_height: line_height.into(),
330 ..Default::default()
331 };
332
333 EditorElement::new(
334 &self.editor,
335 EditorStyle {
336 background: cx.theme().colors().editor_background,
337 local_player: cx.theme().players().local(),
338 text: text_style,
339 syntax: cx.theme().syntax().clone(),
340 ..Default::default()
341 },
342 )
343 })
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use std::path::Path;
350
351 use agent_client_protocol as acp;
352 use editor::EditorMode;
353 use fs::FakeFs;
354 use gpui::{AppContext, TestAppContext};
355 use lsp::{CompletionContext, CompletionTriggerKind};
356 use project::{CompletionIntent, Project};
357 use serde_json::json;
358 use util::path;
359 use workspace::Workspace;
360
361 use crate::acp::{message_editor::MessageEditor, thread_view::tests::init_test};
362
363 #[gpui::test]
364 async fn test_at_mention_removal(cx: &mut TestAppContext) {
365 init_test(cx);
366
367 let fs = FakeFs::new(cx.executor());
368 fs.insert_tree("/project", json!({"file": ""})).await;
369 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
370
371 let (workspace, cx) =
372 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
373
374 let message_editor = cx.update(|window, cx| {
375 cx.new(|cx| {
376 MessageEditor::new(
377 workspace.downgrade(),
378 project.clone(),
379 EditorMode::AutoHeight {
380 min_lines: 1,
381 max_lines: None,
382 },
383 window,
384 cx,
385 )
386 })
387 });
388 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
389
390 cx.run_until_parked();
391
392 let excerpt_id = editor.update(cx, |editor, cx| {
393 editor
394 .buffer()
395 .read(cx)
396 .excerpt_ids()
397 .into_iter()
398 .next()
399 .unwrap()
400 });
401 let completions = editor.update_in(cx, |editor, window, cx| {
402 editor.set_text("Hello @", window, cx);
403 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
404 let completion_provider = editor.completion_provider().unwrap();
405 completion_provider.completions(
406 excerpt_id,
407 &buffer,
408 text::Anchor::MAX,
409 CompletionContext {
410 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
411 trigger_character: Some("@".into()),
412 },
413 window,
414 cx,
415 )
416 });
417 let [_, completion]: [_; 2] = completions
418 .await
419 .unwrap()
420 .into_iter()
421 .flat_map(|response| response.completions)
422 .collect::<Vec<_>>()
423 .try_into()
424 .unwrap();
425
426 editor.update_in(cx, |editor, window, cx| {
427 let snapshot = editor.buffer().read(cx).snapshot(cx);
428 let start = snapshot
429 .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
430 .unwrap();
431 let end = snapshot
432 .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
433 .unwrap();
434 editor.edit([(start..end, completion.new_text)], cx);
435 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
436 });
437
438 cx.run_until_parked();
439
440 // Backspace over the inserted crease (and the following space).
441 editor.update_in(cx, |editor, window, cx| {
442 editor.backspace(&Default::default(), window, cx);
443 editor.backspace(&Default::default(), window, cx);
444 });
445
446 let content = message_editor
447 .update_in(cx, |message_editor, _window, cx| {
448 message_editor.contents(cx)
449 })
450 .await
451 .unwrap();
452
453 // We don't send a resource link for the deleted crease.
454 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
455 }
456}