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