1use anyhow::Result;
2use collections::{HashMap, HashSet};
3use editor::CompletionProvider;
4use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
5use gpui::{
6 actions, point, size, transparent_black, Action, App, Bounds, Entity, EventEmitter, Focusable,
7 PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
8 WindowOptions,
9};
10use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
11use language_model::{
12 LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
13};
14use picker::{Picker, PickerDelegate};
15use release_channel::ReleaseChannel;
16use rope::Rope;
17use settings::Settings;
18use std::sync::Arc;
19use std::time::Duration;
20use theme::ThemeSettings;
21use ui::{
22 div, prelude::*, Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing,
23 ParentElement, Render, SharedString, Styled, Tooltip, Window,
24};
25use util::{ResultExt, TryFutureExt};
26use workspace::Workspace;
27use zed_actions::assistant::InlineAssist;
28
29use prompt_store::*;
30
31pub fn init(cx: &mut App) {
32 prompt_store::init(cx);
33}
34
35actions!(
36 prompt_library,
37 [
38 NewPrompt,
39 DeletePrompt,
40 DuplicatePrompt,
41 ToggleDefaultPrompt
42 ]
43);
44
45const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
46 "This prompt supports special functionality.\n",
47 "It's read-only, but you can remove it from your default prompt."
48);
49
50pub trait InlineAssistDelegate {
51 fn assist(
52 &self,
53 prompt_editor: &Entity<Editor>,
54 initial_prompt: Option<String>,
55 window: &mut Window,
56 cx: &mut Context<PromptLibrary>,
57 );
58
59 /// Returns whether the Assistant panel was focused.
60 fn focus_assistant_panel(
61 &self,
62 workspace: &mut Workspace,
63 window: &mut Window,
64 cx: &mut Context<Workspace>,
65 ) -> bool;
66}
67
68/// This function opens a new prompt library window if one doesn't exist already.
69/// If one exists, it brings it to the foreground.
70///
71/// Note that, when opening a new window, this waits for the PromptStore to be
72/// initialized. If it was initialized successfully, it returns a window handle
73/// to a prompt library.
74pub fn open_prompt_library(
75 language_registry: Arc<LanguageRegistry>,
76 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
77 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
78 cx: &mut App,
79) -> Task<Result<WindowHandle<PromptLibrary>>> {
80 let store = PromptStore::global(cx);
81 cx.spawn(|cx| async move {
82 // We query windows in spawn so that all windows have been returned to GPUI
83 let existing_window = cx
84 .update(|cx| {
85 let existing_window = cx
86 .windows()
87 .into_iter()
88 .find_map(|window| window.downcast::<PromptLibrary>());
89 if let Some(existing_window) = existing_window {
90 existing_window
91 .update(cx, |_, window, _| window.activate_window())
92 .ok();
93
94 Some(existing_window)
95 } else {
96 None
97 }
98 })
99 .ok()
100 .flatten();
101
102 if let Some(existing_window) = existing_window {
103 return Ok(existing_window);
104 }
105
106 let store = store.await?;
107 cx.update(|cx| {
108 let app_id = ReleaseChannel::global(cx).app_id();
109 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
110 cx.open_window(
111 WindowOptions {
112 titlebar: Some(TitlebarOptions {
113 title: Some("Prompt Library".into()),
114 appears_transparent: cfg!(target_os = "macos"),
115 traffic_light_position: Some(point(px(9.0), px(9.0))),
116 }),
117 app_id: Some(app_id.to_owned()),
118 window_bounds: Some(WindowBounds::Windowed(bounds)),
119 ..Default::default()
120 },
121 |window, cx| {
122 cx.new(|cx| {
123 PromptLibrary::new(
124 store,
125 language_registry,
126 inline_assist_delegate,
127 make_completion_provider,
128 window,
129 cx,
130 )
131 })
132 },
133 )
134 })?
135 })
136}
137
138pub struct PromptLibrary {
139 store: Arc<PromptStore>,
140 language_registry: Arc<LanguageRegistry>,
141 prompt_editors: HashMap<PromptId, PromptEditor>,
142 active_prompt_id: Option<PromptId>,
143 picker: Entity<Picker<PromptPickerDelegate>>,
144 pending_load: Task<()>,
145 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
146 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
147 _subscriptions: Vec<Subscription>,
148}
149
150struct PromptEditor {
151 title_editor: Entity<Editor>,
152 body_editor: Entity<Editor>,
153 token_count: Option<usize>,
154 pending_token_count: Task<Option<()>>,
155 next_title_and_body_to_save: Option<(String, Rope)>,
156 pending_save: Option<Task<Option<()>>>,
157 _subscriptions: Vec<Subscription>,
158}
159
160struct PromptPickerDelegate {
161 store: Arc<PromptStore>,
162 selected_index: usize,
163 matches: Vec<PromptMetadata>,
164}
165
166enum PromptPickerEvent {
167 Selected { prompt_id: PromptId },
168 Confirmed { prompt_id: PromptId },
169 Deleted { prompt_id: PromptId },
170 ToggledDefault { prompt_id: PromptId },
171}
172
173impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
174
175impl PickerDelegate for PromptPickerDelegate {
176 type ListItem = ListItem;
177
178 fn match_count(&self) -> usize {
179 self.matches.len()
180 }
181
182 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> SharedString {
183 if self.store.prompt_count() == 0 {
184 "No prompts.".into()
185 } else {
186 "No prompts found matching your search.".into()
187 }
188 }
189
190 fn selected_index(&self) -> usize {
191 self.selected_index
192 }
193
194 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
195 self.selected_index = ix;
196 if let Some(prompt) = self.matches.get(self.selected_index) {
197 cx.emit(PromptPickerEvent::Selected {
198 prompt_id: prompt.id,
199 });
200 }
201 }
202
203 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
204 "Search...".into()
205 }
206
207 fn update_matches(
208 &mut self,
209 query: String,
210 window: &mut Window,
211 cx: &mut Context<Picker<Self>>,
212 ) -> Task<()> {
213 let search = self.store.search(query);
214 let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
215 cx.spawn_in(window, |this, mut cx| async move {
216 let (matches, selected_index) = cx
217 .background_spawn(async move {
218 let matches = search.await;
219
220 let selected_index = prev_prompt_id
221 .and_then(|prev_prompt_id| {
222 matches.iter().position(|entry| entry.id == prev_prompt_id)
223 })
224 .unwrap_or(0);
225 (matches, selected_index)
226 })
227 .await;
228
229 this.update_in(&mut cx, |this, window, cx| {
230 this.delegate.matches = matches;
231 this.delegate.set_selected_index(selected_index, window, cx);
232 cx.notify();
233 })
234 .ok();
235 })
236 }
237
238 fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
239 if let Some(prompt) = self.matches.get(self.selected_index) {
240 cx.emit(PromptPickerEvent::Confirmed {
241 prompt_id: prompt.id,
242 });
243 }
244 }
245
246 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
247
248 fn render_match(
249 &self,
250 ix: usize,
251 selected: bool,
252 _: &mut Window,
253 cx: &mut Context<Picker<Self>>,
254 ) -> Option<Self::ListItem> {
255 let prompt = self.matches.get(ix)?;
256 let default = prompt.default;
257 let prompt_id = prompt.id;
258 let element = ListItem::new(ix)
259 .inset(true)
260 .spacing(ListItemSpacing::Sparse)
261 .toggle_state(selected)
262 .child(h_flex().h_5().line_height(relative(1.)).child(Label::new(
263 prompt.title.clone().unwrap_or("Untitled".into()),
264 )))
265 .end_slot::<IconButton>(default.then(|| {
266 IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
267 .toggle_state(true)
268 .icon_color(Color::Accent)
269 .shape(IconButtonShape::Square)
270 .tooltip(Tooltip::text("Remove from Default Prompt"))
271 .on_click(cx.listener(move |_, _, _, cx| {
272 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
273 }))
274 }))
275 .end_hover_slot(
276 h_flex()
277 .gap_2()
278 .child(if prompt_id.is_built_in() {
279 div()
280 .id("built-in-prompt")
281 .child(Icon::new(IconName::FileLock).color(Color::Muted))
282 .tooltip(move |window, cx| {
283 Tooltip::with_meta(
284 "Built-in prompt",
285 None,
286 BUILT_IN_TOOLTIP_TEXT,
287 window,
288 cx,
289 )
290 })
291 .into_any()
292 } else {
293 IconButton::new("delete-prompt", IconName::Trash)
294 .icon_color(Color::Muted)
295 .shape(IconButtonShape::Square)
296 .tooltip(Tooltip::text("Delete Prompt"))
297 .on_click(cx.listener(move |_, _, _, cx| {
298 cx.emit(PromptPickerEvent::Deleted { prompt_id })
299 }))
300 .into_any_element()
301 })
302 .child(
303 IconButton::new("toggle-default-prompt", IconName::Sparkle)
304 .toggle_state(default)
305 .selected_icon(IconName::SparkleFilled)
306 .icon_color(if default { Color::Accent } else { Color::Muted })
307 .shape(IconButtonShape::Square)
308 .tooltip(Tooltip::text(if default {
309 "Remove from Default Prompt"
310 } else {
311 "Add to Default Prompt"
312 }))
313 .on_click(cx.listener(move |_, _, _, cx| {
314 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
315 })),
316 ),
317 );
318 Some(element)
319 }
320
321 fn render_editor(
322 &self,
323 editor: &Entity<Editor>,
324 _: &mut Window,
325 cx: &mut Context<Picker<Self>>,
326 ) -> Div {
327 h_flex()
328 .bg(cx.theme().colors().editor_background)
329 .rounded_md()
330 .overflow_hidden()
331 .flex_none()
332 .py_1()
333 .px_2()
334 .mx_1()
335 .child(editor.clone())
336 }
337}
338
339impl PromptLibrary {
340 fn new(
341 store: Arc<PromptStore>,
342 language_registry: Arc<LanguageRegistry>,
343 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
344 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
345 window: &mut Window,
346 cx: &mut Context<Self>,
347 ) -> Self {
348 let delegate = PromptPickerDelegate {
349 store: store.clone(),
350 selected_index: 0,
351 matches: Vec::new(),
352 };
353
354 let picker = cx.new(|cx| {
355 let picker = Picker::uniform_list(delegate, window, cx)
356 .modal(false)
357 .max_height(None);
358 picker.focus(window, cx);
359 picker
360 });
361 Self {
362 store: store.clone(),
363 language_registry,
364 prompt_editors: HashMap::default(),
365 active_prompt_id: None,
366 pending_load: Task::ready(()),
367 inline_assist_delegate,
368 make_completion_provider,
369 _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
370 picker,
371 }
372 }
373
374 fn handle_picker_event(
375 &mut self,
376 _: &Entity<Picker<PromptPickerDelegate>>,
377 event: &PromptPickerEvent,
378 window: &mut Window,
379 cx: &mut Context<Self>,
380 ) {
381 match event {
382 PromptPickerEvent::Selected { prompt_id } => {
383 self.load_prompt(*prompt_id, false, window, cx);
384 }
385 PromptPickerEvent::Confirmed { prompt_id } => {
386 self.load_prompt(*prompt_id, true, window, cx);
387 }
388 PromptPickerEvent::ToggledDefault { prompt_id } => {
389 self.toggle_default_for_prompt(*prompt_id, window, cx);
390 }
391 PromptPickerEvent::Deleted { prompt_id } => {
392 self.delete_prompt(*prompt_id, window, cx);
393 }
394 }
395 }
396
397 pub fn new_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
398 // If we already have an untitled prompt, use that instead
399 // of creating a new one.
400 if let Some(metadata) = self.store.first() {
401 if metadata.title.is_none() {
402 self.load_prompt(metadata.id, true, window, cx);
403 return;
404 }
405 }
406
407 let prompt_id = PromptId::new();
408 let save = self.store.save(prompt_id, None, false, "".into());
409 self.picker
410 .update(cx, |picker, cx| picker.refresh(window, cx));
411 cx.spawn_in(window, |this, mut cx| async move {
412 save.await?;
413 this.update_in(&mut cx, |this, window, cx| {
414 this.load_prompt(prompt_id, true, window, cx)
415 })
416 })
417 .detach_and_log_err(cx);
418 }
419
420 pub fn save_prompt(
421 &mut self,
422 prompt_id: PromptId,
423 window: &mut Window,
424 cx: &mut Context<Self>,
425 ) {
426 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
427
428 if prompt_id.is_built_in() {
429 return;
430 }
431
432 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
433 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
434 let title = prompt_editor.title_editor.read(cx).text(cx);
435 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
436 editor
437 .buffer()
438 .read(cx)
439 .as_singleton()
440 .unwrap()
441 .read(cx)
442 .as_rope()
443 .clone()
444 });
445
446 let store = self.store.clone();
447 let executor = cx.background_executor().clone();
448
449 prompt_editor.next_title_and_body_to_save = Some((title, body));
450 if prompt_editor.pending_save.is_none() {
451 prompt_editor.pending_save = Some(cx.spawn_in(window, |this, mut cx| {
452 async move {
453 loop {
454 let title_and_body = this.update(&mut cx, |this, _| {
455 this.prompt_editors
456 .get_mut(&prompt_id)?
457 .next_title_and_body_to_save
458 .take()
459 })?;
460
461 if let Some((title, body)) = title_and_body {
462 let title = if title.trim().is_empty() {
463 None
464 } else {
465 Some(SharedString::from(title))
466 };
467 store
468 .save(prompt_id, title, prompt_metadata.default, body)
469 .await
470 .log_err();
471 this.update_in(&mut cx, |this, window, cx| {
472 this.picker
473 .update(cx, |picker, cx| picker.refresh(window, cx));
474 cx.notify();
475 })?;
476
477 executor.timer(SAVE_THROTTLE).await;
478 } else {
479 break;
480 }
481 }
482
483 this.update(&mut cx, |this, _cx| {
484 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
485 prompt_editor.pending_save = None;
486 }
487 })
488 }
489 .log_err()
490 }));
491 }
492 }
493
494 pub fn delete_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
495 if let Some(active_prompt_id) = self.active_prompt_id {
496 self.delete_prompt(active_prompt_id, window, cx);
497 }
498 }
499
500 pub fn duplicate_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
501 if let Some(active_prompt_id) = self.active_prompt_id {
502 self.duplicate_prompt(active_prompt_id, window, cx);
503 }
504 }
505
506 pub fn toggle_default_for_active_prompt(
507 &mut self,
508 window: &mut Window,
509 cx: &mut Context<Self>,
510 ) {
511 if let Some(active_prompt_id) = self.active_prompt_id {
512 self.toggle_default_for_prompt(active_prompt_id, window, cx);
513 }
514 }
515
516 pub fn toggle_default_for_prompt(
517 &mut self,
518 prompt_id: PromptId,
519 window: &mut Window,
520 cx: &mut Context<Self>,
521 ) {
522 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
523 self.store
524 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
525 .detach_and_log_err(cx);
526 self.picker
527 .update(cx, |picker, cx| picker.refresh(window, cx));
528 cx.notify();
529 }
530 }
531
532 pub fn load_prompt(
533 &mut self,
534 prompt_id: PromptId,
535 focus: bool,
536 window: &mut Window,
537 cx: &mut Context<Self>,
538 ) {
539 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
540 if focus {
541 prompt_editor
542 .body_editor
543 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
544 }
545 self.set_active_prompt(Some(prompt_id), window, cx);
546 } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
547 let language_registry = self.language_registry.clone();
548 let prompt = self.store.load(prompt_id);
549 let make_completion_provider = self.make_completion_provider.clone();
550 self.pending_load = cx.spawn_in(window, |this, mut cx| async move {
551 let prompt = prompt.await;
552 let markdown = language_registry.language_for_name("Markdown").await;
553 this.update_in(&mut cx, |this, window, cx| match prompt {
554 Ok(prompt) => {
555 let title_editor = cx.new(|cx| {
556 let mut editor = Editor::auto_width(window, cx);
557 editor.set_placeholder_text("Untitled", cx);
558 editor.set_text(prompt_metadata.title.unwrap_or_default(), window, cx);
559 if prompt_id.is_built_in() {
560 editor.set_read_only(true);
561 editor.set_show_edit_predictions(Some(false), window, cx);
562 }
563 editor
564 });
565 let body_editor = cx.new(|cx| {
566 let buffer = cx.new(|cx| {
567 let mut buffer = Buffer::local(prompt, cx);
568 buffer.set_language(markdown.log_err(), cx);
569 buffer.set_language_registry(language_registry);
570 buffer
571 });
572
573 let mut editor = Editor::for_buffer(buffer, None, window, cx);
574 if prompt_id.is_built_in() {
575 editor.set_read_only(true);
576 editor.set_show_edit_predictions(Some(false), window, cx);
577 }
578 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
579 editor.set_show_gutter(false, cx);
580 editor.set_show_wrap_guides(false, cx);
581 editor.set_show_indent_guides(false, cx);
582 editor.set_use_modal_editing(false);
583 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
584 editor.set_completion_provider(Some(make_completion_provider()));
585 if focus {
586 window.focus(&editor.focus_handle(cx));
587 }
588 editor
589 });
590 let _subscriptions = vec![
591 cx.subscribe_in(
592 &title_editor,
593 window,
594 move |this, editor, event, window, cx| {
595 this.handle_prompt_title_editor_event(
596 prompt_id, editor, event, window, cx,
597 )
598 },
599 ),
600 cx.subscribe_in(
601 &body_editor,
602 window,
603 move |this, editor, event, window, cx| {
604 this.handle_prompt_body_editor_event(
605 prompt_id, editor, event, window, cx,
606 )
607 },
608 ),
609 ];
610 this.prompt_editors.insert(
611 prompt_id,
612 PromptEditor {
613 title_editor,
614 body_editor,
615 next_title_and_body_to_save: None,
616 pending_save: None,
617 token_count: None,
618 pending_token_count: Task::ready(None),
619 _subscriptions,
620 },
621 );
622 this.set_active_prompt(Some(prompt_id), window, cx);
623 this.count_tokens(prompt_id, window, cx);
624 }
625 Err(error) => {
626 // TODO: we should show the error in the UI.
627 log::error!("error while loading prompt: {:?}", error);
628 }
629 })
630 .ok();
631 });
632 }
633 }
634
635 fn set_active_prompt(
636 &mut self,
637 prompt_id: Option<PromptId>,
638 window: &mut Window,
639 cx: &mut Context<Self>,
640 ) {
641 self.active_prompt_id = prompt_id;
642 self.picker.update(cx, |picker, cx| {
643 if let Some(prompt_id) = prompt_id {
644 if picker
645 .delegate
646 .matches
647 .get(picker.delegate.selected_index())
648 .map_or(true, |old_selected_prompt| {
649 old_selected_prompt.id != prompt_id
650 })
651 {
652 if let Some(ix) = picker
653 .delegate
654 .matches
655 .iter()
656 .position(|mat| mat.id == prompt_id)
657 {
658 picker.set_selected_index(ix, true, window, cx);
659 }
660 }
661 } else {
662 picker.focus(window, cx);
663 }
664 });
665 cx.notify();
666 }
667
668 pub fn delete_prompt(
669 &mut self,
670 prompt_id: PromptId,
671 window: &mut Window,
672 cx: &mut Context<Self>,
673 ) {
674 if let Some(metadata) = self.store.metadata(prompt_id) {
675 let confirmation = window.prompt(
676 PromptLevel::Warning,
677 &format!(
678 "Are you sure you want to delete {}",
679 metadata.title.unwrap_or("Untitled".into())
680 ),
681 None,
682 &["Delete", "Cancel"],
683 cx,
684 );
685
686 cx.spawn_in(window, |this, mut cx| async move {
687 if confirmation.await.ok() == Some(0) {
688 this.update_in(&mut cx, |this, window, cx| {
689 if this.active_prompt_id == Some(prompt_id) {
690 this.set_active_prompt(None, window, cx);
691 }
692 this.prompt_editors.remove(&prompt_id);
693 this.store.delete(prompt_id).detach_and_log_err(cx);
694 this.picker
695 .update(cx, |picker, cx| picker.refresh(window, cx));
696 cx.notify();
697 })?;
698 }
699 anyhow::Ok(())
700 })
701 .detach_and_log_err(cx);
702 }
703 }
704
705 pub fn duplicate_prompt(
706 &mut self,
707 prompt_id: PromptId,
708 window: &mut Window,
709 cx: &mut Context<Self>,
710 ) {
711 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
712 const DUPLICATE_SUFFIX: &str = " copy";
713 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
714 let existing_titles = self
715 .prompt_editors
716 .iter()
717 .filter(|&(&id, _)| id != prompt_id)
718 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
719 .filter(|title| title.starts_with(&title_to_duplicate))
720 .collect::<HashSet<_>>();
721
722 let title = if existing_titles.is_empty() {
723 title_to_duplicate + DUPLICATE_SUFFIX
724 } else {
725 let mut i = 1;
726 loop {
727 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
728 if !existing_titles.contains(&new_title) {
729 break new_title;
730 }
731 i += 1;
732 }
733 };
734
735 let new_id = PromptId::new();
736 let body = prompt.body_editor.read(cx).text(cx);
737 let save = self
738 .store
739 .save(new_id, Some(title.into()), false, body.into());
740 self.picker
741 .update(cx, |picker, cx| picker.refresh(window, cx));
742 cx.spawn_in(window, |this, mut cx| async move {
743 save.await?;
744 this.update_in(&mut cx, |prompt_library, window, cx| {
745 prompt_library.load_prompt(new_id, true, window, cx)
746 })
747 })
748 .detach_and_log_err(cx);
749 }
750 }
751
752 fn focus_active_prompt(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
753 if let Some(active_prompt) = self.active_prompt_id {
754 self.prompt_editors[&active_prompt]
755 .body_editor
756 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
757 cx.stop_propagation();
758 }
759 }
760
761 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
762 self.picker
763 .update(cx, |picker, cx| picker.focus(window, cx));
764 }
765
766 pub fn inline_assist(
767 &mut self,
768 action: &InlineAssist,
769 window: &mut Window,
770 cx: &mut Context<Self>,
771 ) {
772 let Some(active_prompt_id) = self.active_prompt_id else {
773 cx.propagate();
774 return;
775 };
776
777 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
778 let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
779 return;
780 };
781
782 let initial_prompt = action.prompt.clone();
783 if provider.is_authenticated(cx) {
784 self.inline_assist_delegate
785 .assist(prompt_editor, initial_prompt, window, cx);
786 } else {
787 for window in cx.windows() {
788 if let Some(workspace) = window.downcast::<Workspace>() {
789 let panel = workspace
790 .update(cx, |workspace, window, cx| {
791 window.activate_window();
792 self.inline_assist_delegate
793 .focus_assistant_panel(workspace, window, cx)
794 })
795 .ok();
796 if panel == Some(true) {
797 return;
798 }
799 }
800 }
801 }
802 }
803
804 fn move_down_from_title(
805 &mut self,
806 _: &editor::actions::MoveDown,
807 window: &mut Window,
808 cx: &mut Context<Self>,
809 ) {
810 if let Some(prompt_id) = self.active_prompt_id {
811 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
812 window.focus(&prompt_editor.body_editor.focus_handle(cx));
813 }
814 }
815 }
816
817 fn move_up_from_body(
818 &mut self,
819 _: &editor::actions::MoveUp,
820 window: &mut Window,
821 cx: &mut Context<Self>,
822 ) {
823 if let Some(prompt_id) = self.active_prompt_id {
824 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
825 window.focus(&prompt_editor.title_editor.focus_handle(cx));
826 }
827 }
828 }
829
830 fn handle_prompt_title_editor_event(
831 &mut self,
832 prompt_id: PromptId,
833 title_editor: &Entity<Editor>,
834 event: &EditorEvent,
835 window: &mut Window,
836 cx: &mut Context<Self>,
837 ) {
838 match event {
839 EditorEvent::BufferEdited => {
840 self.save_prompt(prompt_id, window, cx);
841 self.count_tokens(prompt_id, window, cx);
842 }
843 EditorEvent::Blurred => {
844 title_editor.update(cx, |title_editor, cx| {
845 title_editor.change_selections(None, window, cx, |selections| {
846 let cursor = selections.oldest_anchor().head();
847 selections.select_anchor_ranges([cursor..cursor]);
848 });
849 });
850 }
851 _ => {}
852 }
853 }
854
855 fn handle_prompt_body_editor_event(
856 &mut self,
857 prompt_id: PromptId,
858 body_editor: &Entity<Editor>,
859 event: &EditorEvent,
860 window: &mut Window,
861 cx: &mut Context<Self>,
862 ) {
863 match event {
864 EditorEvent::BufferEdited => {
865 self.save_prompt(prompt_id, window, cx);
866 self.count_tokens(prompt_id, window, cx);
867 }
868 EditorEvent::Blurred => {
869 body_editor.update(cx, |body_editor, cx| {
870 body_editor.change_selections(None, window, cx, |selections| {
871 let cursor = selections.oldest_anchor().head();
872 selections.select_anchor_ranges([cursor..cursor]);
873 });
874 });
875 }
876 _ => {}
877 }
878 }
879
880 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
881 let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
882 return;
883 };
884 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
885 let editor = &prompt.body_editor.read(cx);
886 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
887 let body = buffer.as_rope().clone();
888 prompt.pending_token_count = cx.spawn_in(window, |this, mut cx| {
889 async move {
890 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
891
892 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
893 let token_count = cx
894 .update(|_, cx| {
895 model.count_tokens(
896 LanguageModelRequest {
897 messages: vec![LanguageModelRequestMessage {
898 role: Role::System,
899 content: vec![body.to_string().into()],
900 cache: false,
901 }],
902 tools: Vec::new(),
903 stop: Vec::new(),
904 temperature: None,
905 },
906 cx,
907 )
908 })?
909 .await?;
910
911 this.update(&mut cx, |this, cx| {
912 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
913 prompt_editor.token_count = Some(token_count);
914 cx.notify();
915 })
916 }
917 .log_err()
918 });
919 }
920 }
921
922 fn render_prompt_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
923 v_flex()
924 .id("prompt-list")
925 .capture_action(cx.listener(Self::focus_active_prompt))
926 .bg(cx.theme().colors().panel_background)
927 .h_full()
928 .px_1()
929 .w_1_3()
930 .overflow_x_hidden()
931 .child(
932 h_flex()
933 .p(DynamicSpacing::Base04.rems(cx))
934 .h_9()
935 .w_full()
936 .flex_none()
937 .justify_end()
938 .child(
939 IconButton::new("new-prompt", IconName::Plus)
940 .style(ButtonStyle::Transparent)
941 .shape(IconButtonShape::Square)
942 .tooltip(move |window, cx| {
943 Tooltip::for_action("New Prompt", &NewPrompt, window, cx)
944 })
945 .on_click(|_, window, cx| {
946 window.dispatch_action(Box::new(NewPrompt), cx);
947 }),
948 ),
949 )
950 .child(div().flex_grow().child(self.picker.clone()))
951 }
952
953 fn render_active_prompt(&mut self, cx: &mut Context<PromptLibrary>) -> gpui::Stateful<Div> {
954 div()
955 .w_2_3()
956 .h_full()
957 .id("prompt-editor")
958 .border_l_1()
959 .border_color(cx.theme().colors().border)
960 .bg(cx.theme().colors().editor_background)
961 .flex_none()
962 .min_w_64()
963 .children(self.active_prompt_id.and_then(|prompt_id| {
964 let prompt_metadata = self.store.metadata(prompt_id)?;
965 let prompt_editor = &self.prompt_editors[&prompt_id];
966 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
967 let model = LanguageModelRegistry::read_global(cx).active_model();
968 let settings = ThemeSettings::get_global(cx);
969
970 Some(
971 v_flex()
972 .id("prompt-editor-inner")
973 .size_full()
974 .relative()
975 .overflow_hidden()
976 .pl(DynamicSpacing::Base16.rems(cx))
977 .pt(DynamicSpacing::Base08.rems(cx))
978 .on_click(cx.listener(move |_, _, window, _| {
979 window.focus(&focus_handle);
980 }))
981 .child(
982 h_flex()
983 .group("active-editor-header")
984 .pr(DynamicSpacing::Base16.rems(cx))
985 .pt(DynamicSpacing::Base02.rems(cx))
986 .pb(DynamicSpacing::Base08.rems(cx))
987 .justify_between()
988 .child(
989 h_flex().gap_1().child(
990 div()
991 .max_w_80()
992 .on_action(cx.listener(Self::move_down_from_title))
993 .border_1()
994 .border_color(transparent_black())
995 .rounded_md()
996 .group_hover("active-editor-header", |this| {
997 this.border_color(
998 cx.theme().colors().border_variant,
999 )
1000 })
1001 .child(EditorElement::new(
1002 &prompt_editor.title_editor,
1003 EditorStyle {
1004 background: cx.theme().system().transparent,
1005 local_player: cx.theme().players().local(),
1006 text: TextStyle {
1007 color: cx
1008 .theme()
1009 .colors()
1010 .editor_foreground,
1011 font_family: settings
1012 .ui_font
1013 .family
1014 .clone(),
1015 font_features: settings
1016 .ui_font
1017 .features
1018 .clone(),
1019 font_size: HeadlineSize::Large
1020 .rems()
1021 .into(),
1022 font_weight: settings.ui_font.weight,
1023 line_height: relative(
1024 settings.buffer_line_height.value(),
1025 ),
1026 ..Default::default()
1027 },
1028 scrollbar_width: Pixels::ZERO,
1029 syntax: cx.theme().syntax().clone(),
1030 status: cx.theme().status().clone(),
1031 inlay_hints_style:
1032 editor::make_inlay_hints_style(cx),
1033 inline_completion_styles:
1034 editor::make_suggestion_styles(cx),
1035 ..EditorStyle::default()
1036 },
1037 )),
1038 ),
1039 )
1040 .child(
1041 h_flex()
1042 .h_full()
1043 .child(
1044 h_flex()
1045 .h_full()
1046 .gap(DynamicSpacing::Base16.rems(cx))
1047 .child(div()),
1048 )
1049 .child(
1050 h_flex()
1051 .h_full()
1052 .gap(DynamicSpacing::Base16.rems(cx))
1053 .children(prompt_editor.token_count.map(
1054 |token_count| {
1055 let token_count: SharedString =
1056 token_count.to_string().into();
1057 let label_token_count: SharedString =
1058 token_count.to_string().into();
1059
1060 h_flex()
1061 .id("token_count")
1062 .tooltip(move |window, cx| {
1063 let token_count =
1064 token_count.clone();
1065
1066 Tooltip::with_meta(
1067 format!(
1068 "{} tokens",
1069 token_count.clone()
1070 ),
1071 None,
1072 format!(
1073 "Model: {}",
1074 model
1075 .as_ref()
1076 .map(|model| model
1077 .name()
1078 .0)
1079 .unwrap_or_default()
1080 ),
1081 window,
1082 cx,
1083 )
1084 })
1085 .child(
1086 Label::new(format!(
1087 "{} tokens",
1088 label_token_count.clone()
1089 ))
1090 .color(Color::Muted),
1091 )
1092 },
1093 ))
1094 .child(if prompt_id.is_built_in() {
1095 div()
1096 .id("built-in-prompt")
1097 .child(
1098 Icon::new(IconName::FileLock)
1099 .color(Color::Muted),
1100 )
1101 .tooltip(move |window, cx| {
1102 Tooltip::with_meta(
1103 "Built-in prompt",
1104 None,
1105 BUILT_IN_TOOLTIP_TEXT,
1106 window,
1107 cx,
1108 )
1109 })
1110 .into_any()
1111 } else {
1112 IconButton::new(
1113 "delete-prompt",
1114 IconName::Trash,
1115 )
1116 .size(ButtonSize::Large)
1117 .style(ButtonStyle::Transparent)
1118 .shape(IconButtonShape::Square)
1119 .size(ButtonSize::Large)
1120 .tooltip(move |window, cx| {
1121 Tooltip::for_action(
1122 "Delete Prompt",
1123 &DeletePrompt,
1124 window,
1125 cx,
1126 )
1127 })
1128 .on_click(|_, window, cx| {
1129 window.dispatch_action(
1130 Box::new(DeletePrompt),
1131 cx,
1132 );
1133 })
1134 .into_any_element()
1135 })
1136 .child(
1137 IconButton::new(
1138 "duplicate-prompt",
1139 IconName::BookCopy,
1140 )
1141 .size(ButtonSize::Large)
1142 .style(ButtonStyle::Transparent)
1143 .shape(IconButtonShape::Square)
1144 .size(ButtonSize::Large)
1145 .tooltip(move |window, cx| {
1146 Tooltip::for_action(
1147 "Duplicate Prompt",
1148 &DuplicatePrompt,
1149 window,
1150 cx,
1151 )
1152 })
1153 .on_click(|_, window, cx| {
1154 window.dispatch_action(
1155 Box::new(DuplicatePrompt),
1156 cx,
1157 );
1158 }),
1159 )
1160 .child(
1161 IconButton::new(
1162 "toggle-default-prompt",
1163 IconName::Sparkle,
1164 )
1165 .style(ButtonStyle::Transparent)
1166 .toggle_state(prompt_metadata.default)
1167 .selected_icon(IconName::SparkleFilled)
1168 .icon_color(if prompt_metadata.default {
1169 Color::Accent
1170 } else {
1171 Color::Muted
1172 })
1173 .shape(IconButtonShape::Square)
1174 .size(ButtonSize::Large)
1175 .tooltip(Tooltip::text(
1176 if prompt_metadata.default {
1177 "Remove from Default Prompt"
1178 } else {
1179 "Add to Default Prompt"
1180 },
1181 ))
1182 .on_click(|_, window, cx| {
1183 window.dispatch_action(
1184 Box::new(ToggleDefaultPrompt),
1185 cx,
1186 );
1187 }),
1188 ),
1189 ),
1190 ),
1191 )
1192 .child(
1193 div()
1194 .on_action(cx.listener(Self::focus_picker))
1195 .on_action(cx.listener(Self::inline_assist))
1196 .on_action(cx.listener(Self::move_up_from_body))
1197 .flex_grow()
1198 .h_full()
1199 .child(prompt_editor.body_editor.clone()),
1200 ),
1201 )
1202 }))
1203 }
1204}
1205
1206impl Render for PromptLibrary {
1207 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1208 let ui_font = theme::setup_ui_font(window, cx);
1209 let theme = cx.theme().clone();
1210
1211 h_flex()
1212 .id("prompt-manager")
1213 .key_context("PromptLibrary")
1214 .on_action(cx.listener(|this, &NewPrompt, window, cx| this.new_prompt(window, cx)))
1215 .on_action(
1216 cx.listener(|this, &DeletePrompt, window, cx| {
1217 this.delete_active_prompt(window, cx)
1218 }),
1219 )
1220 .on_action(cx.listener(|this, &DuplicatePrompt, window, cx| {
1221 this.duplicate_active_prompt(window, cx)
1222 }))
1223 .on_action(cx.listener(|this, &ToggleDefaultPrompt, window, cx| {
1224 this.toggle_default_for_active_prompt(window, cx)
1225 }))
1226 .size_full()
1227 .overflow_hidden()
1228 .font(ui_font)
1229 .text_color(theme.colors().text)
1230 .child(self.render_prompt_list(cx))
1231 .map(|el| {
1232 if self.store.prompt_count() == 0 {
1233 el.child(
1234 v_flex()
1235 .w_2_3()
1236 .h_full()
1237 .items_center()
1238 .justify_center()
1239 .gap_4()
1240 .bg(cx.theme().colors().editor_background)
1241 .child(
1242 h_flex()
1243 .gap_2()
1244 .child(
1245 Icon::new(IconName::Book)
1246 .size(IconSize::Medium)
1247 .color(Color::Muted),
1248 )
1249 .child(
1250 Label::new("No prompts yet")
1251 .size(LabelSize::Large)
1252 .color(Color::Muted),
1253 ),
1254 )
1255 .child(
1256 h_flex()
1257 .child(h_flex())
1258 .child(
1259 v_flex()
1260 .gap_1()
1261 .child(Label::new("Create your first prompt:"))
1262 .child(
1263 Button::new("create-prompt", "New Prompt")
1264 .full_width()
1265 .key_binding(KeyBinding::for_action(
1266 &NewPrompt, window, cx,
1267 ))
1268 .on_click(|_, window, cx| {
1269 window.dispatch_action(
1270 NewPrompt.boxed_clone(),
1271 cx,
1272 )
1273 }),
1274 ),
1275 )
1276 .child(h_flex()),
1277 ),
1278 )
1279 } else {
1280 el.child(self.render_active_prompt(cx))
1281 }
1282 })
1283 }
1284}