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