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