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