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