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