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