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