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