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