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