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