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