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