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