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