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 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::notifications::{NotificationId, NotifyTaskExt};
27use workspace::{Toast, Workspace};
28
29use crate::assistant_model_selector::AssistantModelSelector;
30use crate::context_picker::{ConfirmBehavior, ContextPicker};
31use crate::context_store::{refresh_context_store_text, ContextStore};
32use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
33use crate::thread::{RequestKind, Thread};
34use crate::thread_store::ThreadStore;
35use crate::tool_selector::ToolSelector;
36use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker};
37
38pub struct MessageEditor {
39 thread: Entity<Thread>,
40 editor: Entity<Editor>,
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
148 pub fn remove_all_context(
149 &mut self,
150 _: &RemoveAllContext,
151 _window: &mut Window,
152 cx: &mut Context<Self>,
153 ) {
154 self.context_store.update(cx, |store, _cx| store.clear());
155 cx.notify();
156 }
157
158 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
159 if self.is_editor_empty(cx) {
160 return;
161 }
162
163 if self.thread.read(cx).is_generating() {
164 return;
165 }
166
167 self.send_to_model(RequestKind::Chat, window, cx);
168 }
169
170 fn is_editor_empty(&self, cx: &App) -> bool {
171 self.editor.read(cx).text(cx).is_empty()
172 }
173
174 fn is_model_selected(&self, cx: &App) -> bool {
175 LanguageModelRegistry::read_global(cx)
176 .active_model()
177 .is_some()
178 }
179
180 fn send_to_model(
181 &mut self,
182 request_kind: RequestKind,
183 window: &mut Window,
184 cx: &mut Context<Self>,
185 ) {
186 let provider = LanguageModelRegistry::read_global(cx).active_provider();
187 if provider
188 .as_ref()
189 .map_or(false, |provider| provider.must_accept_terms(cx))
190 {
191 cx.notify();
192 return;
193 }
194
195 let model_registry = LanguageModelRegistry::read_global(cx);
196 let Some(model) = model_registry.active_model() else {
197 return;
198 };
199
200 let user_message = self.editor.update(cx, |editor, cx| {
201 let text = editor.text(cx);
202 editor.clear(window, cx);
203 text
204 });
205
206 let refresh_task =
207 refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
208
209 let thread = self.thread.clone();
210 let context_store = self.context_store.clone();
211 let git_store = self.project.read(cx).git_store();
212 let checkpoint = git_store.read(cx).checkpoint(cx);
213 cx.spawn(async move |_, cx| {
214 refresh_task.await;
215 let checkpoint = checkpoint.await.log_err();
216 thread
217 .update(cx, |thread, cx| {
218 let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
219 thread.insert_user_message(user_message, context, checkpoint, cx);
220 thread.send_to_model(model, request_kind, cx);
221 })
222 .ok();
223 })
224 .detach();
225 }
226
227 fn handle_editor_event(
228 &mut self,
229 editor: &Entity<Editor>,
230 event: &EditorEvent,
231 window: &mut Window,
232 cx: &mut Context<Self>,
233 ) {
234 match event {
235 EditorEvent::SelectionsChanged { .. } => {
236 editor.update(cx, |editor, cx| {
237 let snapshot = editor.buffer().read(cx).snapshot(cx);
238 let newest_cursor = editor.selections.newest::<Point>(cx).head();
239 if newest_cursor.column > 0 {
240 let behind_cursor = snapshot.clip_point(
241 Point::new(newest_cursor.row, newest_cursor.column - 1),
242 Bias::Left,
243 );
244 let char_behind_cursor = snapshot.chars_at(behind_cursor).next();
245 if char_behind_cursor == Some('@') {
246 self.inline_context_picker_menu_handle.show(window, cx);
247 }
248 }
249 });
250 }
251 _ => {}
252 }
253 }
254
255 fn handle_inline_context_picker_event(
256 &mut self,
257 _inline_context_picker: &Entity<ContextPicker>,
258 _event: &DismissEvent,
259 window: &mut Window,
260 cx: &mut Context<Self>,
261 ) {
262 let editor_focus_handle = self.editor.focus_handle(cx);
263 window.focus(&editor_focus_handle);
264 }
265
266 fn handle_context_strip_event(
267 &mut self,
268 _context_strip: &Entity<ContextStrip>,
269 event: &ContextStripEvent,
270 window: &mut Window,
271 cx: &mut Context<Self>,
272 ) {
273 match event {
274 ContextStripEvent::PickerDismissed
275 | ContextStripEvent::BlurredEmpty
276 | ContextStripEvent::BlurredDown => {
277 let editor_focus_handle = self.editor.focus_handle(cx);
278 window.focus(&editor_focus_handle);
279 }
280 ContextStripEvent::BlurredUp => {}
281 }
282 }
283
284 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
285 if self.context_picker_menu_handle.is_deployed()
286 || self.inline_context_picker_menu_handle.is_deployed()
287 {
288 cx.propagate();
289 } else {
290 self.context_strip.focus_handle(cx).focus(window);
291 }
292 }
293
294 fn handle_feedback_click(
295 &mut self,
296 is_positive: bool,
297 window: &mut Window,
298 cx: &mut Context<Self>,
299 ) {
300 let workspace = self.workspace.clone();
301 let report = self
302 .thread
303 .update(cx, |thread, cx| thread.report_feedback(is_positive, cx));
304
305 cx.spawn(async move |_, cx| {
306 report.await?;
307 workspace.update(cx, |workspace, cx| {
308 let message = if is_positive {
309 "Positive feedback recorded. Thank you!"
310 } else {
311 "Negative feedback recorded. Thank you for helping us improve!"
312 };
313
314 struct ThreadFeedback;
315 let id = NotificationId::unique::<ThreadFeedback>();
316 workspace.show_toast(Toast::new(id, message).autohide(), cx)
317 })
318 })
319 .detach_and_notify_err(window, cx);
320 }
321}
322
323impl Focusable for MessageEditor {
324 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
325 self.editor.focus_handle(cx)
326 }
327}
328
329impl Render for MessageEditor {
330 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
331 let font_size = TextSize::Default.rems(cx);
332 let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
333 let focus_handle = self.editor.focus_handle(cx);
334 let inline_context_picker = self.inline_context_picker.clone();
335 let bg_color = cx.theme().colors().editor_background;
336 let is_generating = self.thread.read(cx).is_generating();
337 let is_model_selected = self.is_model_selected(cx);
338 let is_editor_empty = self.is_editor_empty(cx);
339 let submit_label_color = if is_editor_empty {
340 Color::Muted
341 } else {
342 Color::Default
343 };
344
345 let vim_mode_enabled = VimModeSetting::get_global(cx).0;
346 let platform = PlatformStyle::platform();
347 let linux = platform == PlatformStyle::Linux;
348 let windows = platform == PlatformStyle::Windows;
349 let button_width = if linux || windows || vim_mode_enabled {
350 px(82.)
351 } else {
352 px(64.)
353 };
354
355 let project = self.thread.read(cx).project();
356 let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
357 repository.read(cx).status().count()
358 } else {
359 0
360 };
361
362 v_flex()
363 .size_full()
364 .when(is_generating, |parent| {
365 let focus_handle = self.editor.focus_handle(cx).clone();
366 parent.child(
367 h_flex().py_3().w_full().justify_center().child(
368 h_flex()
369 .flex_none()
370 .pl_2()
371 .pr_1()
372 .py_1()
373 .bg(cx.theme().colors().editor_background)
374 .border_1()
375 .border_color(cx.theme().colors().border_variant)
376 .rounded_lg()
377 .shadow_md()
378 .gap_1()
379 .child(
380 Icon::new(IconName::ArrowCircle)
381 .size(IconSize::XSmall)
382 .color(Color::Muted)
383 .with_animation(
384 "arrow-circle",
385 Animation::new(Duration::from_secs(2)).repeat(),
386 |icon, delta| {
387 icon.transform(gpui::Transformation::rotate(
388 gpui::percentage(delta),
389 ))
390 },
391 ),
392 )
393 .child(
394 Label::new("Generating…")
395 .size(LabelSize::XSmall)
396 .color(Color::Muted),
397 )
398 .child(ui::Divider::vertical())
399 .child(
400 Button::new("cancel-generation", "Cancel")
401 .label_size(LabelSize::XSmall)
402 .key_binding(
403 KeyBinding::for_action_in(
404 &editor::actions::Cancel,
405 &focus_handle,
406 window,
407 cx,
408 )
409 .map(|kb| kb.size(rems_from_px(10.))),
410 )
411 .on_click(move |_event, window, cx| {
412 focus_handle.dispatch_action(
413 &editor::actions::Cancel,
414 window,
415 cx,
416 );
417 }),
418 ),
419 ),
420 )
421 })
422 .when(changed_files > 0, |parent| {
423 parent.child(
424 v_flex()
425 .mx_2()
426 .bg(cx.theme().colors().element_background)
427 .border_1()
428 .border_b_0()
429 .border_color(cx.theme().colors().border)
430 .rounded_t_md()
431 .child(
432 h_flex()
433 .justify_between()
434 .p_2()
435 .child(
436 h_flex()
437 .gap_2()
438 .child(
439 IconButton::new(
440 "edits-disclosure",
441 IconName::GitBranchSmall,
442 )
443 .icon_size(IconSize::Small)
444 .on_click(
445 |_ev, _window, cx| {
446 cx.defer(|cx| {
447 cx.dispatch_action(&git_panel::ToggleFocus)
448 });
449 },
450 ),
451 )
452 .child(
453 Label::new(format!(
454 "{} {} changed",
455 changed_files,
456 if changed_files == 1 { "file" } else { "files" }
457 ))
458 .size(LabelSize::XSmall)
459 .color(Color::Muted),
460 ),
461 )
462 .child(
463 h_flex()
464 .gap_2()
465 .child(
466 Button::new("review", "Review")
467 .label_size(LabelSize::XSmall)
468 .on_click(|_event, _window, cx| {
469 cx.defer(|cx| {
470 cx.dispatch_action(
471 &git_ui::project_diff::Diff,
472 );
473 });
474 }),
475 )
476 .child(
477 Button::new("commit", "Commit")
478 .label_size(LabelSize::XSmall)
479 .on_click(|_event, _window, cx| {
480 cx.defer(|cx| {
481 cx.dispatch_action(&ExpandCommitEditor)
482 });
483 }),
484 ),
485 ),
486 ),
487 )
488 })
489 .child(
490 v_flex()
491 .key_context("MessageEditor")
492 .on_action(cx.listener(Self::chat))
493 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
494 this.model_selector
495 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
496 }))
497 .on_action(cx.listener(Self::toggle_context_picker))
498 .on_action(cx.listener(Self::remove_all_context))
499 .on_action(cx.listener(Self::move_up))
500 .on_action(cx.listener(Self::toggle_chat_mode))
501 .gap_2()
502 .p_2()
503 .bg(bg_color)
504 .border_t_1()
505 .border_color(cx.theme().colors().border)
506 .child(
507 h_flex()
508 .justify_between()
509 .child(self.context_strip.clone())
510 .when(!self.thread.read(cx).is_empty(), |this| {
511 this.child(
512 h_flex()
513 .gap_2()
514 .child(
515 IconButton::new(
516 "feedback-thumbs-up",
517 IconName::ThumbsUp,
518 )
519 .style(ButtonStyle::Subtle)
520 .icon_size(IconSize::Small)
521 .tooltip(Tooltip::text("Helpful"))
522 .on_click(
523 cx.listener(|this, _, window, cx| {
524 this.handle_feedback_click(true, window, cx);
525 }),
526 ),
527 )
528 .child(
529 IconButton::new(
530 "feedback-thumbs-down",
531 IconName::ThumbsDown,
532 )
533 .style(ButtonStyle::Subtle)
534 .icon_size(IconSize::Small)
535 .tooltip(Tooltip::text("Not Helpful"))
536 .on_click(
537 cx.listener(|this, _, window, cx| {
538 this.handle_feedback_click(false, window, cx);
539 }),
540 ),
541 ),
542 )
543 }),
544 )
545 .child(
546 v_flex()
547 .gap_5()
548 .child({
549 let settings = ThemeSettings::get_global(cx);
550 let text_style = TextStyle {
551 color: cx.theme().colors().text,
552 font_family: settings.ui_font.family.clone(),
553 font_fallbacks: settings.ui_font.fallbacks.clone(),
554 font_features: settings.ui_font.features.clone(),
555 font_size: font_size.into(),
556 font_weight: settings.ui_font.weight,
557 line_height: line_height.into(),
558 ..Default::default()
559 };
560
561 EditorElement::new(
562 &self.editor,
563 EditorStyle {
564 background: bg_color,
565 local_player: cx.theme().players().local(),
566 text: text_style,
567 ..Default::default()
568 },
569 )
570 })
571 .child(
572 PopoverMenu::new("inline-context-picker")
573 .menu(move |window, cx| {
574 inline_context_picker.update(cx, |this, cx| {
575 this.init(window, cx);
576 });
577
578 Some(inline_context_picker.clone())
579 })
580 .attach(gpui::Corner::TopLeft)
581 .anchor(gpui::Corner::BottomLeft)
582 .offset(gpui::Point {
583 x: px(0.0),
584 y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
585 - px(4.0),
586 })
587 .with_handle(self.inline_context_picker_menu_handle.clone()),
588 )
589 .child(
590 h_flex()
591 .justify_between()
592 .child(h_flex().gap_2().child(self.tool_selector.clone()))
593 .child(
594 h_flex().gap_1().child(self.model_selector.clone()).child(
595 ButtonLike::new("submit-message")
596 .width(button_width.into())
597 .style(ButtonStyle::Filled)
598 .disabled(
599 is_editor_empty
600 || !is_model_selected
601 || is_generating,
602 )
603 .child(
604 h_flex()
605 .w_full()
606 .justify_between()
607 .child(
608 Label::new("Submit")
609 .size(LabelSize::Small)
610 .color(submit_label_color),
611 )
612 .children(
613 KeyBinding::for_action_in(
614 &Chat,
615 &focus_handle,
616 window,
617 cx,
618 )
619 .map(|binding| {
620 binding
621 .when(vim_mode_enabled, |kb| {
622 kb.size(rems_from_px(12.))
623 })
624 .into_any_element()
625 }),
626 ),
627 )
628 .on_click(move |_event, window, cx| {
629 focus_handle.dispatch_action(&Chat, window, cx);
630 })
631 .when(is_editor_empty, |button| {
632 button.tooltip(Tooltip::text(
633 "Type a message to submit",
634 ))
635 })
636 .when(is_generating, |button| {
637 button.tooltip(Tooltip::text(
638 "Cancel to submit a new message",
639 ))
640 })
641 .when(!is_model_selected, |button| {
642 button.tooltip(Tooltip::text(
643 "Select a model to continue",
644 ))
645 }),
646 ),
647 ),
648 ),
649 ),
650 )
651 }
652}