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