1use std::sync::Arc;
2
3use collections::HashSet;
4use editor::actions::MoveUp;
5use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
6use file_icons::FileIcons;
7use fs::Fs;
8use gpui::{
9 linear_color_stop, linear_gradient, point, Animation, AnimationExt, App, DismissEvent, Entity,
10 Focusable, Subscription, TextStyle, WeakEntity,
11};
12use language_model::LanguageModelRegistry;
13use language_model_selector::ToggleModelSelector;
14use project::Project;
15use settings::Settings;
16use std::time::Duration;
17use theme::ThemeSettings;
18use ui::{
19 prelude::*, ButtonLike, Disclosure, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle,
20 Tooltip,
21};
22use util::ResultExt;
23use vim_mode_setting::VimModeSetting;
24use workspace::Workspace;
25
26use crate::assistant_model_selector::AssistantModelSelector;
27use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
28use crate::context_store::{refresh_context_store_text, ContextStore};
29use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
30use crate::profile_selector::ProfileSelector;
31use crate::thread::{RequestKind, Thread};
32use crate::thread_store::ThreadStore;
33use crate::{
34 AssistantDiff, Chat, ChatMode, OpenAssistantDiff, RemoveAllContext, ThreadEvent,
35 ToggleContextPicker, ToggleProfileSelector,
36};
37
38pub struct MessageEditor {
39 thread: Entity<Thread>,
40 editor: Entity<Editor>,
41 #[allow(dead_code)]
42 workspace: WeakEntity<Workspace>,
43 project: Entity<Project>,
44 context_store: Entity<ContextStore>,
45 context_strip: Entity<ContextStrip>,
46 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
47 inline_context_picker: Entity<ContextPicker>,
48 inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
49 model_selector: Entity<AssistantModelSelector>,
50 profile_selector: Entity<ProfileSelector>,
51 edits_expanded: bool,
52 _subscriptions: Vec<Subscription>,
53}
54
55impl MessageEditor {
56 pub fn new(
57 fs: Arc<dyn Fs>,
58 workspace: WeakEntity<Workspace>,
59 context_store: Entity<ContextStore>,
60 thread_store: WeakEntity<ThreadStore>,
61 thread: Entity<Thread>,
62 window: &mut Window,
63 cx: &mut Context<Self>,
64 ) -> Self {
65 let context_picker_menu_handle = PopoverMenuHandle::default();
66 let inline_context_picker_menu_handle = PopoverMenuHandle::default();
67 let model_selector_menu_handle = PopoverMenuHandle::default();
68
69 let editor = cx.new(|cx| {
70 let mut editor = Editor::auto_height(10, window, cx);
71 editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
72 editor.set_show_indent_guides(false, cx);
73 editor.set_context_menu_options(ContextMenuOptions {
74 min_entries_visible: 12,
75 max_entries_visible: 12,
76 placement: Some(ContextMenuPlacement::Above),
77 });
78
79 editor
80 });
81
82 let editor_entity = editor.downgrade();
83 editor.update(cx, |editor, _| {
84 editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
85 workspace.clone(),
86 context_store.downgrade(),
87 Some(thread_store.clone()),
88 editor_entity,
89 ))));
90 });
91
92 let inline_context_picker = cx.new(|cx| {
93 ContextPicker::new(
94 workspace.clone(),
95 Some(thread_store.clone()),
96 context_store.downgrade(),
97 ConfirmBehavior::Close,
98 window,
99 cx,
100 )
101 });
102
103 let context_strip = cx.new(|cx| {
104 ContextStrip::new(
105 context_store.clone(),
106 workspace.clone(),
107 Some(thread_store.clone()),
108 context_picker_menu_handle.clone(),
109 SuggestContextKind::File,
110 window,
111 cx,
112 )
113 });
114
115 let subscriptions = vec![
116 cx.subscribe_in(
117 &inline_context_picker,
118 window,
119 Self::handle_inline_context_picker_event,
120 ),
121 cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
122 ];
123
124 Self {
125 editor: editor.clone(),
126 project: thread.read(cx).project().clone(),
127 thread,
128 workspace,
129 context_store,
130 context_strip,
131 context_picker_menu_handle,
132 inline_context_picker,
133 inline_context_picker_menu_handle,
134 model_selector: cx.new(|cx| {
135 AssistantModelSelector::new(
136 fs.clone(),
137 model_selector_menu_handle,
138 editor.focus_handle(cx),
139 window,
140 cx,
141 )
142 }),
143 edits_expanded: false,
144 profile_selector: cx
145 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
146 _subscriptions: subscriptions,
147 }
148 }
149
150 fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
151 cx.notify();
152 }
153
154 fn toggle_context_picker(
155 &mut self,
156 _: &ToggleContextPicker,
157 window: &mut Window,
158 cx: &mut Context<Self>,
159 ) {
160 self.context_picker_menu_handle.toggle(window, cx);
161 }
162 pub fn remove_all_context(
163 &mut self,
164 _: &RemoveAllContext,
165 _window: &mut Window,
166 cx: &mut Context<Self>,
167 ) {
168 self.context_store.update(cx, |store, _cx| store.clear());
169 cx.notify();
170 }
171
172 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
173 if self.is_editor_empty(cx) {
174 return;
175 }
176
177 if self.thread.read(cx).is_generating() {
178 return;
179 }
180
181 self.send_to_model(RequestKind::Chat, window, cx);
182 }
183
184 fn is_editor_empty(&self, cx: &App) -> bool {
185 self.editor.read(cx).text(cx).is_empty()
186 }
187
188 fn is_model_selected(&self, cx: &App) -> bool {
189 LanguageModelRegistry::read_global(cx)
190 .active_model()
191 .is_some()
192 }
193
194 fn send_to_model(
195 &mut self,
196 request_kind: RequestKind,
197 window: &mut Window,
198 cx: &mut Context<Self>,
199 ) {
200 let provider = LanguageModelRegistry::read_global(cx).active_provider();
201 if provider
202 .as_ref()
203 .map_or(false, |provider| provider.must_accept_terms(cx))
204 {
205 cx.notify();
206 return;
207 }
208
209 let model_registry = LanguageModelRegistry::read_global(cx);
210 let Some(model) = model_registry.active_model() else {
211 return;
212 };
213
214 let user_message = self.editor.update(cx, |editor, cx| {
215 let text = editor.text(cx);
216 editor.clear(window, cx);
217 text
218 });
219
220 let refresh_task =
221 refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
222
223 let system_prompt_context_task = self.thread.read(cx).load_system_prompt_context(cx);
224
225 let thread = self.thread.clone();
226 let context_store = self.context_store.clone();
227 let checkpoint = self.project.read(cx).git_store().read(cx).checkpoint(cx);
228 cx.spawn(async move |_, cx| {
229 let checkpoint = checkpoint.await.ok();
230 refresh_task.await;
231 let (system_prompt_context, load_error) = system_prompt_context_task.await;
232 thread
233 .update(cx, |thread, cx| {
234 thread.set_system_prompt_context(system_prompt_context);
235 if let Some(load_error) = load_error {
236 cx.emit(ThreadEvent::ShowError(load_error));
237 }
238 })
239 .ok();
240 thread
241 .update(cx, |thread, cx| {
242 let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
243 thread.action_log().update(cx, |action_log, cx| {
244 action_log.clear_reviewed_changes(cx);
245 });
246 thread.insert_user_message(user_message, context, checkpoint, cx);
247 thread.send_to_model(model, request_kind, cx);
248 })
249 .ok();
250 })
251 .detach();
252 }
253
254 fn handle_inline_context_picker_event(
255 &mut self,
256 _inline_context_picker: &Entity<ContextPicker>,
257 _event: &DismissEvent,
258 window: &mut Window,
259 cx: &mut Context<Self>,
260 ) {
261 let editor_focus_handle = self.editor.focus_handle(cx);
262 window.focus(&editor_focus_handle);
263 }
264
265 fn handle_context_strip_event(
266 &mut self,
267 _context_strip: &Entity<ContextStrip>,
268 event: &ContextStripEvent,
269 window: &mut Window,
270 cx: &mut Context<Self>,
271 ) {
272 match event {
273 ContextStripEvent::PickerDismissed
274 | ContextStripEvent::BlurredEmpty
275 | ContextStripEvent::BlurredDown => {
276 let editor_focus_handle = self.editor.focus_handle(cx);
277 window.focus(&editor_focus_handle);
278 }
279 ContextStripEvent::BlurredUp => {}
280 }
281 }
282
283 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
284 if self.context_picker_menu_handle.is_deployed()
285 || self.inline_context_picker_menu_handle.is_deployed()
286 {
287 cx.propagate();
288 } else {
289 self.context_strip.focus_handle(cx).focus(window);
290 }
291 }
292
293 fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
294 AssistantDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
295 }
296}
297
298impl Focusable for MessageEditor {
299 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
300 self.editor.focus_handle(cx)
301 }
302}
303
304impl Render for MessageEditor {
305 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
306 let font_size = TextSize::Default.rems(cx);
307 let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
308
309 let focus_handle = self.editor.focus_handle(cx);
310 let inline_context_picker = self.inline_context_picker.clone();
311
312 let is_generating = self.thread.read(cx).is_generating();
313 let is_model_selected = self.is_model_selected(cx);
314 let is_editor_empty = self.is_editor_empty(cx);
315 let submit_label_color = if is_editor_empty {
316 Color::Muted
317 } else {
318 Color::Default
319 };
320
321 let vim_mode_enabled = VimModeSetting::get_global(cx).0;
322 let platform = PlatformStyle::platform();
323 let linux = platform == PlatformStyle::Linux;
324 let windows = platform == PlatformStyle::Windows;
325 let button_width = if linux || windows || vim_mode_enabled {
326 px(82.)
327 } else {
328 px(64.)
329 };
330
331 let action_log = self.thread.read(cx).action_log();
332 let changed_buffers = action_log.read(cx).changed_buffers(cx);
333 let changed_buffers_count = changed_buffers.len();
334
335 let editor_bg_color = cx.theme().colors().editor_background;
336 let border_color = cx.theme().colors().border;
337 let active_color = cx.theme().colors().element_selected;
338 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
339
340 v_flex()
341 .size_full()
342 .when(is_generating, |parent| {
343 let focus_handle = self.editor.focus_handle(cx).clone();
344 parent.child(
345 h_flex().py_3().w_full().justify_center().child(
346 h_flex()
347 .flex_none()
348 .pl_2()
349 .pr_1()
350 .py_1()
351 .bg(editor_bg_color)
352 .border_1()
353 .border_color(cx.theme().colors().border_variant)
354 .rounded_lg()
355 .shadow_md()
356 .gap_1()
357 .child(
358 Icon::new(IconName::ArrowCircle)
359 .size(IconSize::XSmall)
360 .color(Color::Muted)
361 .with_animation(
362 "arrow-circle",
363 Animation::new(Duration::from_secs(2)).repeat(),
364 |icon, delta| {
365 icon.transform(gpui::Transformation::rotate(
366 gpui::percentage(delta),
367 ))
368 },
369 ),
370 )
371 .child(
372 Label::new("Generating…")
373 .size(LabelSize::XSmall)
374 .color(Color::Muted),
375 )
376 .child(ui::Divider::vertical())
377 .child(
378 Button::new("cancel-generation", "Cancel")
379 .label_size(LabelSize::XSmall)
380 .key_binding(
381 KeyBinding::for_action_in(
382 &editor::actions::Cancel,
383 &focus_handle,
384 window,
385 cx,
386 )
387 .map(|kb| kb.size(rems_from_px(10.))),
388 )
389 .on_click(move |_event, window, cx| {
390 focus_handle.dispatch_action(
391 &editor::actions::Cancel,
392 window,
393 cx,
394 );
395 }),
396 ),
397 ),
398 )
399 })
400 .when(changed_buffers_count > 0, |parent| {
401 parent.child(
402 v_flex()
403 .mx_2()
404 .bg(bg_edit_files_disclosure)
405 .border_1()
406 .border_b_0()
407 .border_color(border_color)
408 .rounded_t_md()
409 .shadow(smallvec::smallvec![gpui::BoxShadow {
410 color: gpui::black().opacity(0.15),
411 offset: point(px(1.), px(-1.)),
412 blur_radius: px(3.),
413 spread_radius: px(0.),
414 }])
415 .child(
416 h_flex()
417 .p_1p5()
418 .justify_between()
419 .when(self.edits_expanded, |this| {
420 this.border_b_1().border_color(border_color)
421 })
422 .child(
423 h_flex()
424 .gap_1()
425 .child(
426 Disclosure::new(
427 "edits-disclosure",
428 self.edits_expanded,
429 )
430 .on_click(
431 cx.listener(|this, _ev, _window, cx| {
432 this.edits_expanded = !this.edits_expanded;
433 cx.notify();
434 }),
435 ),
436 )
437 .child(
438 Label::new("Edits")
439 .size(LabelSize::Small)
440 .color(Color::Muted),
441 )
442 .child(
443 Label::new("•")
444 .size(LabelSize::XSmall)
445 .color(Color::Muted),
446 )
447 .child(
448 Label::new(format!(
449 "{} {}",
450 changed_buffers_count,
451 if changed_buffers_count == 1 {
452 "file"
453 } else {
454 "files"
455 }
456 ))
457 .size(LabelSize::Small)
458 .color(Color::Muted),
459 ),
460 )
461 .child(
462 Button::new("review", "Review Changes")
463 .label_size(LabelSize::Small)
464 .key_binding(
465 KeyBinding::for_action_in(
466 &OpenAssistantDiff,
467 &focus_handle,
468 window,
469 cx,
470 )
471 .map(|kb| kb.size(rems_from_px(12.))),
472 )
473 .on_click(cx.listener(|this, _, window, cx| {
474 this.handle_review_click(window, cx)
475 })),
476 ),
477 )
478 .when(self.edits_expanded, |parent| {
479 parent.child(
480 v_flex().bg(cx.theme().colors().editor_background).children(
481 changed_buffers.into_iter().enumerate().flat_map(
482 |(index, (buffer, changed))| {
483 let file = buffer.read(cx).file()?;
484 let path = file.path();
485
486 let parent_label = path.parent().and_then(|parent| {
487 let parent_str = parent.to_string_lossy();
488
489 if parent_str.is_empty() {
490 None
491 } else {
492 Some(
493 Label::new(format!(
494 "{}{}",
495 parent_str,
496 std::path::MAIN_SEPARATOR_STR
497 ))
498 .color(Color::Muted)
499 .size(LabelSize::XSmall)
500 .buffer_font(cx),
501 )
502 }
503 });
504
505 let name_label = path.file_name().map(|name| {
506 Label::new(name.to_string_lossy().to_string())
507 .size(LabelSize::XSmall)
508 .buffer_font(cx)
509 });
510
511 let file_icon = FileIcons::get_icon(&path, cx)
512 .map(Icon::from_path)
513 .map(|icon| {
514 icon.color(Color::Muted).size(IconSize::Small)
515 })
516 .unwrap_or_else(|| {
517 Icon::new(IconName::File)
518 .color(Color::Muted)
519 .size(IconSize::Small)
520 });
521
522 let element = div()
523 .relative()
524 .py_1()
525 .px_2()
526 .when(index + 1 < changed_buffers_count, |parent| {
527 parent.border_color(border_color).border_b_1()
528 })
529 .child(
530 h_flex()
531 .gap_2()
532 .justify_between()
533 .child(
534 h_flex()
535 .id("file-container")
536 .pr_8()
537 .gap_1p5()
538 .max_w_full()
539 .overflow_x_scroll()
540 .child(file_icon)
541 .child(
542 h_flex()
543 .children(parent_label)
544 .children(name_label),
545 ) // TODO: show lines changed
546 .child(
547 Label::new("+")
548 .color(Color::Created),
549 )
550 .child(
551 Label::new("-")
552 .color(Color::Deleted),
553 ),
554 )
555 .when(!changed.needs_review, |parent| {
556 parent.child(
557 Icon::new(IconName::Check)
558 .color(Color::Success),
559 )
560 })
561 .child(
562 div()
563 .h_full()
564 .absolute()
565 .w_8()
566 .bottom_0()
567 .map(|this| {
568 if !changed.needs_review {
569 this.right_4()
570 } else {
571 this.right_0()
572 }
573 })
574 .bg(linear_gradient(
575 90.,
576 linear_color_stop(
577 editor_bg_color,
578 1.,
579 ),
580 linear_color_stop(
581 editor_bg_color
582 .opacity(0.2),
583 0.,
584 ),
585 )),
586 ),
587 );
588
589 Some(element)
590 },
591 ),
592 ),
593 )
594 }),
595 )
596 })
597 .child(
598 v_flex()
599 .key_context("MessageEditor")
600 .on_action(cx.listener(Self::chat))
601 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
602 this.profile_selector
603 .read(cx)
604 .menu_handle()
605 .toggle(window, cx);
606 }))
607 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
608 this.model_selector
609 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
610 }))
611 .on_action(cx.listener(Self::toggle_context_picker))
612 .on_action(cx.listener(Self::remove_all_context))
613 .on_action(cx.listener(Self::move_up))
614 .on_action(cx.listener(Self::toggle_chat_mode))
615 .gap_2()
616 .p_2()
617 .bg(editor_bg_color)
618 .border_t_1()
619 .border_color(cx.theme().colors().border)
620 .child(h_flex().justify_between().child(self.context_strip.clone()))
621 .child(
622 v_flex()
623 .gap_5()
624 .child({
625 let settings = ThemeSettings::get_global(cx);
626 let text_style = TextStyle {
627 color: cx.theme().colors().text,
628 font_family: settings.ui_font.family.clone(),
629 font_fallbacks: settings.ui_font.fallbacks.clone(),
630 font_features: settings.ui_font.features.clone(),
631 font_size: font_size.into(),
632 font_weight: settings.ui_font.weight,
633 line_height: line_height.into(),
634 ..Default::default()
635 };
636
637 EditorElement::new(
638 &self.editor,
639 EditorStyle {
640 background: editor_bg_color,
641 local_player: cx.theme().players().local(),
642 text: text_style,
643 syntax: cx.theme().syntax().clone(),
644 ..Default::default()
645 },
646 )
647 })
648 .child(
649 PopoverMenu::new("inline-context-picker")
650 .menu(move |window, cx| {
651 inline_context_picker.update(cx, |this, cx| {
652 this.init(window, cx);
653 });
654
655 Some(inline_context_picker.clone())
656 })
657 .attach(gpui::Corner::TopLeft)
658 .anchor(gpui::Corner::BottomLeft)
659 .offset(gpui::Point {
660 x: px(0.0),
661 y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
662 - px(4.0),
663 })
664 .with_handle(self.inline_context_picker_menu_handle.clone()),
665 )
666 .child(
667 h_flex()
668 .justify_between()
669 .child(h_flex().gap_2().child(self.profile_selector.clone()))
670 .child(
671 h_flex().gap_1().child(self.model_selector.clone()).child(
672 ButtonLike::new("submit-message")
673 .width(button_width.into())
674 .style(ButtonStyle::Filled)
675 .disabled(
676 is_editor_empty
677 || !is_model_selected
678 || is_generating,
679 )
680 .child(
681 h_flex()
682 .w_full()
683 .justify_between()
684 .child(
685 Label::new("Submit")
686 .size(LabelSize::Small)
687 .color(submit_label_color),
688 )
689 .children(
690 KeyBinding::for_action_in(
691 &Chat,
692 &focus_handle,
693 window,
694 cx,
695 )
696 .map(|binding| {
697 binding
698 .when(vim_mode_enabled, |kb| {
699 kb.size(rems_from_px(12.))
700 })
701 .into_any_element()
702 }),
703 ),
704 )
705 .on_click(move |_event, window, cx| {
706 focus_handle.dispatch_action(&Chat, window, cx);
707 })
708 .when(is_editor_empty, |button| {
709 button.tooltip(Tooltip::text(
710 "Type a message to submit",
711 ))
712 })
713 .when(is_generating, |button| {
714 button.tooltip(Tooltip::text(
715 "Cancel to submit a new message",
716 ))
717 })
718 .when(!is_model_selected, |button| {
719 button.tooltip(Tooltip::text(
720 "Select a model to continue",
721 ))
722 }),
723 ),
724 ),
725 ),
726 ),
727 )
728 }
729}