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, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
7 PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
8 WindowOptions, actions, point, size, 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(12.0), px(12.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(DEFAULT_ADDITIONAL_WINDOW_SIZE),
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 Label::new(rule.title.clone().unwrap_or("Untitled".into()))
373 .truncate()
374 .mr_10(),
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 cx,
398 )
399 })
400 .into_any()
401 } else {
402 IconButton::new("delete-rule", IconName::Trash)
403 .icon_color(Color::Muted)
404 .icon_size(IconSize::Small)
405 .tooltip(Tooltip::text("Delete Rule"))
406 .on_click(cx.listener(move |_, _, _, cx| {
407 cx.emit(RulePickerEvent::Deleted { prompt_id })
408 }))
409 .into_any_element()
410 })
411 .child(
412 IconButton::new("toggle-default-rule", IconName::Plus)
413 .selected_icon(IconName::Dash)
414 .toggle_state(default)
415 .icon_size(IconSize::Small)
416 .icon_color(if default {
417 Color::Accent
418 } else {
419 Color::Muted
420 })
421 .map(|this| {
422 if default {
423 this.tooltip(Tooltip::text(
424 "Remove from Default Rules",
425 ))
426 } else {
427 this.tooltip(move |_window, cx| {
428 Tooltip::with_meta(
429 "Add to Default Rules",
430 None,
431 "Always included in every thread.",
432 cx,
433 )
434 })
435 }
436 })
437 .on_click(cx.listener(move |_, _, _, cx| {
438 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
439 })),
440 ),
441 )
442 .into_any_element(),
443 )
444 }
445 }
446 }
447
448 fn render_editor(
449 &self,
450 editor: &Entity<Editor>,
451 _: &mut Window,
452 cx: &mut Context<Picker<Self>>,
453 ) -> Div {
454 h_flex()
455 .py_1()
456 .px_1p5()
457 .mx_1()
458 .gap_1p5()
459 .rounded_sm()
460 .bg(cx.theme().colors().editor_background)
461 .border_1()
462 .border_color(cx.theme().colors().border)
463 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
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, Default::default(), 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(
892 new_id,
893 Some(title.into()),
894 false,
895 Rope::from_str(&body, cx.background_executor()),
896 cx,
897 )
898 });
899 self.picker
900 .update(cx, |picker, cx| picker.refresh(window, cx));
901 cx.spawn_in(window, async move |this, cx| {
902 save.await?;
903 this.update_in(cx, |rules_library, window, cx| {
904 rules_library.load_rule(new_id, true, window, cx)
905 })
906 })
907 .detach_and_log_err(cx);
908 }
909 }
910
911 fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
912 if let Some(active_rule) = self.active_rule_id {
913 self.rule_editors[&active_rule]
914 .body_editor
915 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
916 cx.stop_propagation();
917 }
918 }
919
920 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
921 self.picker
922 .update(cx, |picker, cx| picker.focus(window, cx));
923 }
924
925 pub fn inline_assist(
926 &mut self,
927 action: &InlineAssist,
928 window: &mut Window,
929 cx: &mut Context<Self>,
930 ) {
931 let Some(active_rule_id) = self.active_rule_id else {
932 cx.propagate();
933 return;
934 };
935
936 let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
937 let Some(ConfiguredModel { provider, .. }) =
938 LanguageModelRegistry::read_global(cx).inline_assistant_model()
939 else {
940 return;
941 };
942
943 let initial_prompt = action.prompt.clone();
944 if provider.is_authenticated(cx) {
945 self.inline_assist_delegate
946 .assist(rule_editor, initial_prompt, window, cx);
947 } else {
948 for window in cx.windows() {
949 if let Some(workspace) = window.downcast::<Workspace>() {
950 let panel = workspace
951 .update(cx, |workspace, window, cx| {
952 window.activate_window();
953 self.inline_assist_delegate
954 .focus_agent_panel(workspace, window, cx)
955 })
956 .ok();
957 if panel == Some(true) {
958 return;
959 }
960 }
961 }
962 }
963 }
964
965 fn move_down_from_title(
966 &mut self,
967 _: &editor::actions::MoveDown,
968 window: &mut Window,
969 cx: &mut Context<Self>,
970 ) {
971 if let Some(rule_id) = self.active_rule_id
972 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
973 {
974 window.focus(&rule_editor.body_editor.focus_handle(cx));
975 }
976 }
977
978 fn move_up_from_body(
979 &mut self,
980 _: &editor::actions::MoveUp,
981 window: &mut Window,
982 cx: &mut Context<Self>,
983 ) {
984 if let Some(rule_id) = self.active_rule_id
985 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
986 {
987 window.focus(&rule_editor.title_editor.focus_handle(cx));
988 }
989 }
990
991 fn handle_rule_title_editor_event(
992 &mut self,
993 prompt_id: PromptId,
994 title_editor: &Entity<Editor>,
995 event: &EditorEvent,
996 window: &mut Window,
997 cx: &mut Context<Self>,
998 ) {
999 match event {
1000 EditorEvent::BufferEdited => {
1001 self.save_rule(prompt_id, window, cx);
1002 self.count_tokens(prompt_id, window, cx);
1003 }
1004 EditorEvent::Blurred => {
1005 title_editor.update(cx, |title_editor, cx| {
1006 title_editor.change_selections(
1007 SelectionEffects::no_scroll(),
1008 window,
1009 cx,
1010 |selections| {
1011 let cursor = selections.oldest_anchor().head();
1012 selections.select_anchor_ranges([cursor..cursor]);
1013 },
1014 );
1015 });
1016 }
1017 _ => {}
1018 }
1019 }
1020
1021 fn handle_rule_body_editor_event(
1022 &mut self,
1023 prompt_id: PromptId,
1024 body_editor: &Entity<Editor>,
1025 event: &EditorEvent,
1026 window: &mut Window,
1027 cx: &mut Context<Self>,
1028 ) {
1029 match event {
1030 EditorEvent::BufferEdited => {
1031 self.save_rule(prompt_id, window, cx);
1032 self.count_tokens(prompt_id, window, cx);
1033 }
1034 EditorEvent::Blurred => {
1035 body_editor.update(cx, |body_editor, cx| {
1036 body_editor.change_selections(
1037 SelectionEffects::no_scroll(),
1038 window,
1039 cx,
1040 |selections| {
1041 let cursor = selections.oldest_anchor().head();
1042 selections.select_anchor_ranges([cursor..cursor]);
1043 },
1044 );
1045 });
1046 }
1047 _ => {}
1048 }
1049 }
1050
1051 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
1052 let Some(ConfiguredModel { model, .. }) =
1053 LanguageModelRegistry::read_global(cx).default_model()
1054 else {
1055 return;
1056 };
1057 if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
1058 let editor = &rule.body_editor.read(cx);
1059 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
1060 let body = buffer.as_rope().clone();
1061 rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
1062 async move {
1063 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
1064
1065 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
1066 let token_count = cx
1067 .update(|_, cx| {
1068 model.count_tokens(
1069 LanguageModelRequest {
1070 thread_id: None,
1071 prompt_id: None,
1072 intent: None,
1073 mode: None,
1074 messages: vec![LanguageModelRequestMessage {
1075 role: Role::System,
1076 content: vec![body.to_string().into()],
1077 cache: false,
1078 }],
1079 tools: Vec::new(),
1080 tool_choice: None,
1081 stop: Vec::new(),
1082 temperature: None,
1083 thinking_allowed: true,
1084 },
1085 cx,
1086 )
1087 })?
1088 .await?;
1089
1090 this.update(cx, |this, cx| {
1091 let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
1092 rule_editor.token_count = Some(token_count);
1093 cx.notify();
1094 })
1095 }
1096 .log_err()
1097 .await
1098 });
1099 }
1100 }
1101
1102 fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1103 v_flex()
1104 .id("rule-list")
1105 .capture_action(cx.listener(Self::focus_active_rule))
1106 .px_1p5()
1107 .h_full()
1108 .w_64()
1109 .overflow_x_hidden()
1110 .bg(cx.theme().colors().panel_background)
1111 .map(|this| {
1112 if cfg!(target_os = "macos") {
1113 this.child(
1114 h_flex()
1115 .p(DynamicSpacing::Base04.rems(cx))
1116 .h_9()
1117 .w_full()
1118 .flex_none()
1119 .justify_end()
1120 .child(
1121 IconButton::new("new-rule", IconName::Plus)
1122 .tooltip(move |_window, cx| {
1123 Tooltip::for_action("New Rule", &NewRule, cx)
1124 })
1125 .on_click(|_, window, cx| {
1126 window.dispatch_action(Box::new(NewRule), cx);
1127 }),
1128 ),
1129 )
1130 } else {
1131 this.child(
1132 h_flex().p_1().w_full().child(
1133 Button::new("new-rule", "New Rule")
1134 .full_width()
1135 .style(ButtonStyle::Outlined)
1136 .icon(IconName::Plus)
1137 .icon_size(IconSize::Small)
1138 .icon_position(IconPosition::Start)
1139 .icon_color(Color::Muted)
1140 .on_click(|_, window, cx| {
1141 window.dispatch_action(Box::new(NewRule), cx);
1142 }),
1143 ),
1144 )
1145 }
1146 })
1147 .child(div().flex_grow().child(self.picker.clone()))
1148 }
1149
1150 fn render_active_rule_editor(
1151 &self,
1152 editor: &Entity<Editor>,
1153 cx: &mut Context<Self>,
1154 ) -> impl IntoElement {
1155 let settings = ThemeSettings::get_global(cx);
1156
1157 div()
1158 .w_full()
1159 .on_action(cx.listener(Self::move_down_from_title))
1160 .pl_1()
1161 .border_1()
1162 .border_color(transparent_black())
1163 .rounded_sm()
1164 .group_hover("active-editor-header", |this| {
1165 this.border_color(cx.theme().colors().border_variant)
1166 })
1167 .child(EditorElement::new(
1168 &editor,
1169 EditorStyle {
1170 background: cx.theme().system().transparent,
1171 local_player: cx.theme().players().local(),
1172 text: TextStyle {
1173 color: cx.theme().colors().editor_foreground,
1174 font_family: settings.ui_font.family.clone(),
1175 font_features: settings.ui_font.features.clone(),
1176 font_size: HeadlineSize::Large.rems().into(),
1177 font_weight: settings.ui_font.weight,
1178 line_height: relative(settings.buffer_line_height.value()),
1179 ..Default::default()
1180 },
1181 scrollbar_width: Pixels::ZERO,
1182 syntax: cx.theme().syntax().clone(),
1183 status: cx.theme().status().clone(),
1184 inlay_hints_style: editor::make_inlay_hints_style(cx),
1185 edit_prediction_styles: editor::make_suggestion_styles(cx),
1186 ..EditorStyle::default()
1187 },
1188 ))
1189 }
1190
1191 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
1192 div()
1193 .id("rule-editor")
1194 .h_full()
1195 .flex_grow()
1196 .border_l_1()
1197 .border_color(cx.theme().colors().border)
1198 .bg(cx.theme().colors().editor_background)
1199 .children(self.active_rule_id.and_then(|prompt_id| {
1200 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
1201 let rule_editor = &self.rule_editors[&prompt_id];
1202 let focus_handle = rule_editor.body_editor.focus_handle(cx);
1203 let model = LanguageModelRegistry::read_global(cx)
1204 .default_model()
1205 .map(|default| default.model);
1206
1207 Some(
1208 v_flex()
1209 .id("rule-editor-inner")
1210 .size_full()
1211 .relative()
1212 .overflow_hidden()
1213 .on_click(cx.listener(move |_, _, window, _| {
1214 window.focus(&focus_handle);
1215 }))
1216 .child(
1217 h_flex()
1218 .group("active-editor-header")
1219 .pt_2()
1220 .pl_1p5()
1221 .pr_2p5()
1222 .gap_2()
1223 .justify_between()
1224 .child(
1225 self.render_active_rule_editor(&rule_editor.title_editor, cx),
1226 )
1227 .child(
1228 h_flex()
1229 .h_full()
1230 .flex_shrink_0()
1231 .children(rule_editor.token_count.map(|token_count| {
1232 let token_count: SharedString =
1233 token_count.to_string().into();
1234 let label_token_count: SharedString =
1235 token_count.to_string().into();
1236
1237 div()
1238 .id("token_count")
1239 .mr_1()
1240 .flex_shrink_0()
1241 .tooltip(move |_window, cx| {
1242 Tooltip::with_meta(
1243 "Token Estimation",
1244 None,
1245 format!(
1246 "Model: {}",
1247 model
1248 .as_ref()
1249 .map(|model| model.name().0)
1250 .unwrap_or_default()
1251 ),
1252 cx,
1253 )
1254 })
1255 .child(
1256 Label::new(format!(
1257 "{} tokens",
1258 label_token_count
1259 ))
1260 .color(Color::Muted),
1261 )
1262 }))
1263 .child(if prompt_id.is_built_in() {
1264 div()
1265 .id("built-in-rule")
1266 .child(
1267 Icon::new(IconName::FileLock)
1268 .color(Color::Muted),
1269 )
1270 .tooltip(move |_window, cx| {
1271 Tooltip::with_meta(
1272 "Built-in rule",
1273 None,
1274 BUILT_IN_TOOLTIP_TEXT,
1275 cx,
1276 )
1277 })
1278 .into_any()
1279 } else {
1280 IconButton::new("delete-rule", IconName::Trash)
1281 .tooltip(move |_window, cx| {
1282 Tooltip::for_action(
1283 "Delete Rule",
1284 &DeleteRule,
1285 cx,
1286 )
1287 })
1288 .on_click(|_, window, cx| {
1289 window
1290 .dispatch_action(Box::new(DeleteRule), cx);
1291 })
1292 .into_any_element()
1293 })
1294 .child(
1295 IconButton::new("duplicate-rule", IconName::BookCopy)
1296 .tooltip(move |_window, cx| {
1297 Tooltip::for_action(
1298 "Duplicate Rule",
1299 &DuplicateRule,
1300 cx,
1301 )
1302 })
1303 .on_click(|_, window, cx| {
1304 window.dispatch_action(
1305 Box::new(DuplicateRule),
1306 cx,
1307 );
1308 }),
1309 )
1310 .child(
1311 IconButton::new(
1312 "toggle-default-rule",
1313 IconName::Paperclip,
1314 )
1315 .toggle_state(rule_metadata.default)
1316 .icon_color(if rule_metadata.default {
1317 Color::Accent
1318 } else {
1319 Color::Muted
1320 })
1321 .map(|this| {
1322 if rule_metadata.default {
1323 this.tooltip(Tooltip::text(
1324 "Remove from Default Rules",
1325 ))
1326 } else {
1327 this.tooltip(move |_window, cx| {
1328 Tooltip::with_meta(
1329 "Add to Default Rules",
1330 None,
1331 "Always included in every thread.",
1332 cx,
1333 )
1334 })
1335 }
1336 })
1337 .on_click(
1338 |_, window, cx| {
1339 window.dispatch_action(
1340 Box::new(ToggleDefaultRule),
1341 cx,
1342 );
1343 },
1344 ),
1345 ),
1346 ),
1347 )
1348 .child(
1349 div()
1350 .on_action(cx.listener(Self::focus_picker))
1351 .on_action(cx.listener(Self::inline_assist))
1352 .on_action(cx.listener(Self::move_up_from_body))
1353 .h_full()
1354 .flex_grow()
1355 .child(
1356 h_flex()
1357 .py_2()
1358 .pl_2p5()
1359 .h_full()
1360 .flex_1()
1361 .child(rule_editor.body_editor.clone()),
1362 ),
1363 ),
1364 )
1365 }))
1366 }
1367}
1368
1369impl Render for RulesLibrary {
1370 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1371 let ui_font = theme::setup_ui_font(window, cx);
1372 let theme = cx.theme().clone();
1373
1374 client_side_decorations(
1375 v_flex()
1376 .id("rules-library")
1377 .key_context("RulesLibrary")
1378 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1379 .on_action(
1380 cx.listener(|this, &DeleteRule, window, cx| {
1381 this.delete_active_rule(window, cx)
1382 }),
1383 )
1384 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1385 this.duplicate_active_rule(window, cx)
1386 }))
1387 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1388 this.toggle_default_for_active_rule(window, cx)
1389 }))
1390 .size_full()
1391 .overflow_hidden()
1392 .font(ui_font)
1393 .text_color(theme.colors().text)
1394 .children(self.title_bar.clone())
1395 .bg(theme.colors().background)
1396 .child(
1397 h_flex()
1398 .flex_1()
1399 .when(!cfg!(target_os = "macos"), |this| {
1400 this.border_t_1().border_color(cx.theme().colors().border)
1401 })
1402 .child(self.render_rule_list(cx))
1403 .map(|el| {
1404 if self.store.read(cx).prompt_count() == 0 {
1405 el.child(
1406 v_flex()
1407 .h_full()
1408 .flex_1()
1409 .items_center()
1410 .justify_center()
1411 .border_l_1()
1412 .border_color(cx.theme().colors().border)
1413 .bg(cx.theme().colors().editor_background)
1414 .child(
1415 Button::new("create-rule", "New Rule")
1416 .style(ButtonStyle::Outlined)
1417 .key_binding(KeyBinding::for_action(&NewRule, cx))
1418 .on_click(|_, window, cx| {
1419 window
1420 .dispatch_action(NewRule.boxed_clone(), cx)
1421 }),
1422 ),
1423 )
1424 } else {
1425 el.child(self.render_active_rule(cx))
1426 }
1427 }),
1428 ),
1429 window,
1430 cx,
1431 )
1432 }
1433}