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