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