git: Allow customising commit message prompt from rules library (#45004)

Closes #26823 

Release Notes:

- Added support for customising the prompt used for generating commit
message in the rules library

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Bennet Bo FennerandDanilo Leal authored and GitHub committed 2025-12-16 18:02:13 +00:00
1 parent c1317baebe
commit d07818b20f
5 files changed
+288 -205

No files matched your search

+1 -4
View File
@@ -414,10 +414,7 @@ impl NativeAgent {
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: match prompt_metadata.id {
prompt_store::PromptId::User { uuid } => uuid,
prompt_store::PromptId::EditWorkflow => return None,
},
uuid: prompt_metadata.id.user_id()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
+5 -8
View File
@@ -20,7 +20,7 @@ use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
};
use prompt_store::{PromptId, PromptStore, UserPromptId};
use prompt_store::{PromptStore, UserPromptId};
use rope::Point;
use text::{Anchor, ToPoint as _};
use ui::prelude::*;
@@ -1585,13 +1585,10 @@ pub(crate) fn search_rules(
if metadata.default {
None
} else {
match metadata.id {
PromptId::EditWorkflow => None,
PromptId::User { uuid } => Some(RulesContextEntry {
prompt_id: uuid,
title: metadata.title?,
}),
}
Some(RulesContextEntry {
prompt_id: metadata.id.user_id()?,
title: metadata.title?,
})
}
})
.collect::<Vec<_>>()
+18 -4
View File
@@ -57,7 +57,7 @@ use project::{
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::RULES_FILE_NAMES;
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore, StatusStyle};
use std::future::Future;
@@ -2422,6 +2422,20 @@ impl GitPanel {
}
}
async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String {
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
let load = async {
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
store
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
.ok()?
.await
.ok()
};
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
}
/// Generates a commit message using an LLM.
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
@@ -2487,14 +2501,14 @@ impl GitPanel {
let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
let prompt = Self::load_commit_message_prompt(&mut cx).await;
let subject = this.update(cx, |this, cx| {
this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
})?;
let text_empty = subject.trim().is_empty();
const PROMPT: &str = include_str!("commit_message_prompt.txt");
let rules_section = match &rules_content {
Some(rules) => format!(
"\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
@@ -2510,7 +2524,7 @@ impl GitPanel {
};
let content = format!(
"{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
"{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
);
let request = LanguageModelRequest {
+49 -4
View File
@@ -56,6 +56,7 @@ pub struct PromptMetadata {
pub enum PromptId {
User { uuid: UserPromptId },
EditWorkflow,
CommitMessage,
}
impl PromptId {
@@ -63,8 +64,32 @@ impl PromptId {
UserPromptId::new().into()
}
pub fn user_id(&self) -> Option<UserPromptId> {
match self {
Self::User { uuid } => Some(*uuid),
_ => None,
}
}
pub fn is_built_in(&self) -> bool {
!matches!(self, PromptId::User { .. })
match self {
Self::User { .. } => false,
Self::EditWorkflow | Self::CommitMessage => true,
}
}
pub fn can_edit(&self) -> bool {
match self {
Self::User { .. } | Self::CommitMessage => true,
Self::EditWorkflow => false,
}
}
pub fn default_content(&self) -> Option<&'static str> {
match self {
Self::User { .. } | Self::EditWorkflow => None,
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
}
}
}
@@ -95,6 +120,7 @@ impl std::fmt::Display for PromptId {
match self {
PromptId::User { uuid } => write!(f, "{}", uuid.0),
PromptId::EditWorkflow => write!(f, "Edit workflow"),
PromptId::CommitMessage => write!(f, "Commit message"),
}
}
}
@@ -181,6 +207,25 @@ impl PromptStore {
metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
// Insert default commit message prompt if not present
if metadata.get(&txn, &PromptId::CommitMessage)?.is_none() {
metadata.put(
&mut txn,
&PromptId::CommitMessage,
&PromptMetadata {
id: PromptId::CommitMessage,
title: Some("Git Commit Message".into()),
default: false,
saved_at: Utc::now(),
},
)?;
}
if bodies.get(&txn, &PromptId::CommitMessage)?.is_none() {
let commit_message_prompt =
include_str!("../../git_ui/src/commit_message_prompt.txt");
bodies.put(&mut txn, &PromptId::CommitMessage, commit_message_prompt)?;
}
txn.commit()?;
Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
@@ -387,8 +432,8 @@ impl PromptStore {
body: Rope,
cx: &Context<Self>,
) -> Task<Result<()>> {
if id.is_built_in() {
return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
if !id.can_edit() {
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
}
let prompt_metadata = PromptMetadata {
@@ -430,7 +475,7 @@ impl PromptStore {
) -> Task<Result<()>> {
let mut cache = self.metadata_cache.write();
if id.is_built_in() {
if !id.can_edit() {
title = cache
.metadata_by_id
.get(&id)
+215 -185
View File
@@ -21,9 +21,7 @@ use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{
Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
};
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use util::{ResultExt, TryFutureExt};
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
use zed_actions::assistant::InlineAssist;
@@ -44,15 +42,12 @@ actions!(
/// Duplicates the selected rule.
DuplicateRule,
/// Toggles whether the selected rule is a default rule.
ToggleDefaultRule
ToggleDefaultRule,
/// Restores a built-in rule to its default content.
RestoreDefaultContent
]
);
const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
"This rule supports special functionality.\n",
"It's read-only, but you can remove it from your default rules."
);
pub trait InlineAssistDelegate {
fn assist(
&self,
@@ -270,23 +265,35 @@ impl PickerDelegate for RulePickerDelegate {
.background_spawn(async move {
let matches = search.await;
let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
matches.iter().partition(|rule| rule.default);
let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
matches.into_iter().partition(|rule| rule.id.is_built_in());
let (default_rules, other_rules): (Vec<_>, Vec<_>) =
user_rules.into_iter().partition(|rule| rule.default);
let mut filtered_entries = Vec::new();
if !default_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
if !built_in_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
for rule in default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
for rule in built_in_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
filtered_entries.push(RulePickerEntry::Separator);
}
for rule in non_default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
if !default_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
for rule in default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
filtered_entries.push(RulePickerEntry::Separator);
}
for rule in other_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
let selected_index = prev_prompt_id
@@ -341,21 +348,27 @@ impl PickerDelegate for RulePickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
RulePickerEntry::Header(title) => Some(
ListSubHeader::new(title.clone())
.end_slot(
IconButton::new("info", IconName::Info)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(
"Default Rules are attached by default with every new thread.",
))
.into_any_element(),
)
.inset(true)
.into_any_element(),
),
RulePickerEntry::Header(title) => {
let tooltip_text = if title.as_ref() == "Built-in Rules" {
"Built-in rules are those included out of the box with Zed."
} else {
"Default Rules are attached by default with every new thread."
};
Some(
ListSubHeader::new(title.clone())
.end_slot(
IconButton::new("info", IconName::Info)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(tooltip_text))
.into_any_element(),
)
.inset(true)
.into_any_element(),
)
}
RulePickerEntry::Separator => Some(
h_flex()
.py_1()
@@ -376,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
.truncate()
.mr_10(),
)
.end_slot::<IconButton>(default.then(|| {
.end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
IconButton::new("toggle-default-rule", IconName::Paperclip)
.toggle_state(true)
.icon_color(Color::Accent)
@@ -386,62 +399,52 @@ impl PickerDelegate for RulePickerDelegate {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
}))
}))
.end_hover_slot(
h_flex()
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(Icon::new(IconName::FileLock).color(Color::Muted))
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Delete Rule"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::Deleted { prompt_id })
}))
.into_any_element()
})
.child(
IconButton::new("toggle-default-rule", IconName::Plus)
.selected_icon(IconName::Dash)
.toggle_state(default)
.icon_size(IconSize::Small)
.icon_color(if default {
Color::Accent
} else {
Color::Muted
})
.map(|this| {
if default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
.when(!prompt_id.is_built_in(), |this| {
this.end_hover_slot(
h_flex()
.child(
IconButton::new("delete-rule", IconName::Trash)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Delete Rule"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::Deleted { prompt_id })
})),
)
.child(
IconButton::new("toggle-default-rule", IconName::Plus)
.selected_icon(IconName::Dash)
.toggle_state(default)
.icon_size(IconSize::Small)
.icon_color(if default {
Color::Accent
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
Color::Muted
})
.map(|this| {
if default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault {
prompt_id,
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
})),
),
)
})),
),
)
})
.into_any_element(),
)
}
@@ -573,7 +576,7 @@ impl RulesLibrary {
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
if prompt_id.is_built_in() {
if !prompt_id.can_edit() {
return;
}
@@ -661,6 +664,33 @@ impl RulesLibrary {
}
}
pub fn restore_default_content_for_active_rule(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(active_rule_id) = self.active_rule_id {
self.restore_default_content(active_rule_id, window, cx);
}
}
pub fn restore_default_content(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(default_content) = prompt_id.default_content() else {
return;
};
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
rule_editor.body_editor.update(cx, |editor, cx| {
editor.set_text(default_content, window, cx);
});
}
}
pub fn toggle_default_for_rule(
&mut self,
prompt_id: PromptId,
@@ -721,7 +751,7 @@ impl RulesLibrary {
});
let mut editor = Editor::for_buffer(buffer, None, window, cx);
if prompt_id.is_built_in() {
if !prompt_id.can_edit() {
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
}
@@ -1148,30 +1178,38 @@ impl RulesLibrary {
fn render_active_rule_editor(
&self,
editor: &Entity<Editor>,
read_only: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_color = if read_only {
cx.theme().colors().text_muted
} else {
cx.theme().colors().text
};
div()
.w_full()
.on_action(cx.listener(Self::move_down_from_title))
.pl_1()
.border_1()
.border_color(transparent_black())
.rounded_sm()
.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
.when(!read_only, |this| {
this.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
})
})
.on_action(cx.listener(Self::move_down_from_title))
.child(EditorElement::new(
&editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().editor_foreground,
color: text_color,
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features.clone(),
font_size: HeadlineSize::Large.rems().into(),
font_size: HeadlineSize::Medium.rems().into(),
font_weight: settings.ui_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
@@ -1186,6 +1224,68 @@ impl RulesLibrary {
))
}
fn render_duplicate_rule_button(&self) -> impl IntoElement {
IconButton::new("duplicate-rule", IconName::BookCopy)
.tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(DuplicateRule), cx);
})
}
fn render_built_in_rule_controls(&self) -> impl IntoElement {
h_flex()
.gap_1()
.child(self.render_duplicate_rule_button())
.child(
IconButton::new("restore-default", IconName::RotateCcw)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Restore to Default Content",
&RestoreDefaultContent,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(RestoreDefaultContent), cx);
}),
)
}
fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
h_flex()
.gap_1()
.child(
IconButton::new("toggle-default-rule", IconName::Paperclip)
.toggle_state(default)
.when(default, |this| this.icon_color(Color::Accent))
.map(|this| {
if default {
this.tooltip(Tooltip::text("Remove from Default Rules"))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(ToggleDefaultRule), cx);
}),
)
.child(self.render_duplicate_rule_button())
.child(
IconButton::new("delete-rule", IconName::Trash)
.tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(DeleteRule), cx);
}),
)
}
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
div()
.id("rule-editor")
@@ -1198,9 +1298,9 @@ impl RulesLibrary {
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
let rule_editor = &self.rule_editors[&prompt_id];
let focus_handle = rule_editor.body_editor.focus_handle(cx);
let model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
let registry = LanguageModelRegistry::read_global(cx);
let model = registry.default_model().map(|default| default.model);
let built_in = prompt_id.is_built_in();
Some(
v_flex()
@@ -1214,14 +1314,15 @@ impl RulesLibrary {
.child(
h_flex()
.group("active-editor-header")
.pt_2()
.pl_1p5()
.pr_2p5()
.h_12()
.px_2()
.gap_2()
.justify_between()
.child(
self.render_active_rule_editor(&rule_editor.title_editor, cx),
)
.child(self.render_active_rule_editor(
&rule_editor.title_editor,
built_in,
cx,
))
.child(
h_flex()
.h_full()
@@ -1258,89 +1359,15 @@ impl RulesLibrary {
.color(Color::Muted),
)
}))
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(
Icon::new(IconName::FileLock)
.color(Color::Muted),
)
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Delete Rule",
&DeleteRule,
cx,
)
})
.on_click(|_, window, cx| {
window
.dispatch_action(Box::new(DeleteRule), cx);
})
.into_any_element()
})
.child(
IconButton::new("duplicate-rule", IconName::BookCopy)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Duplicate Rule",
&DuplicateRule,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicateRule),
cx,
);
}),
)
.child(
IconButton::new(
"toggle-default-rule",
IconName::Paperclip,
)
.toggle_state(rule_metadata.default)
.icon_color(if rule_metadata.default {
Color::Accent
.map(|this| {
if built_in {
this.child(self.render_built_in_rule_controls())
} else {
Color::Muted
})
.map(|this| {
if rule_metadata.default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(
|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultRule),
cx,
);
},
),
),
this.child(self.render_regular_rule_controls(
rule_metadata.default,
))
}
}),
),
)
.child(
@@ -1385,6 +1412,9 @@ impl Render for RulesLibrary {
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
this.toggle_default_for_active_rule(window, cx)
}))
.on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
this.restore_default_content_for_active_rule(window, cx)
}))
.size_full()
.overflow_hidden()
.font(ui_font)