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