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