Vectrum
Sign In

Source control writing style and change request templates

Settings → Source Control → Text generation adds T3 Code's writing style
for generated commit messages and pull requests (Repository conventions,
which follows the recent commits; Conventional Commits; or your own
instructions) and Follow change request templates, which has a generated
pull request fill in the repository's template. Both are searchable, as
are the new worktree cleanup rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 1b02949commitef6a6cc

Showing 6 changed files with 189 additions and 14 deletions.

+22−0src/settings.rsView File
@@ -41,6 +41,19 @@ pub enum BranchNaming {
4141 Custom,
4242 }
4343
44+ /// How generated commit messages and pull requests are written.
45+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
46+ #[serde(rename_all = "snake_case")]
47+ pub enum WritingStyle {
48+ /// Like the repository's recent commits.
49+ #[default]
50+ RepoConventions,
51+ /// With Conventional Commit prefixes.
52+ Conventional,
53+ /// As `writing_instructions` say.
54+ Custom,
55+ }
56+
4457 /// What Ctrl+Q takes.
4558 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
4659 #[serde(rename_all = "snake_case")]
@@ -367,6 +380,12 @@ pub struct Settings {
367380 pub branch_prefix: String,
368381 /// What custom branch naming tells the model.
369382 pub branch_instructions: String,
383+ /// How generated commit messages and pull requests read.
384+ pub writing_style: WritingStyle,
385+ /// What the custom writing style tells the model.
386+ pub writing_instructions: String,
387+ /// Fill in the repository's pull request template, when it has one.
388+ pub follow_pr_templates: bool,
370389 /// Where new worktrees go; empty is the app's data folder.
371390 pub worktrees_dir: String,
372391 /// Remove a deleted thread's worktree without asking, unless it has
@@ -524,6 +543,9 @@ impl Default for Settings {
524543 branch_naming: BranchNaming::Static,
525544 branch_prefix: "vectrum".into(),
526545 branch_instructions: String::new(),
546+ writing_style: WritingStyle::RepoConventions,
547+ writing_instructions: String::new(),
548+ follow_pr_templates: true,
527549 worktrees_dir: String::new(),
528550 cleanup_deleted_worktrees: false,
529551 cleanup_inactive_worktrees: None,
+47−10src/textgen.rsView File
@@ -58,8 +58,8 @@ pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> O
5858
5959 /// A commit message for what's staged, as T3 Code asks for one: an
6060 /// imperative subject of at most 72 characters and an optional body.
61- pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: &str) -> Option<String> {
62- let prompt = [
61+ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: &str, instructions: &str) -> Option<String> {
62+ let mut prompt = [
6363 "You write concise git commit messages.",
6464 "Return a JSON object with keys: subject, body.",
6565 "Rules:",
@@ -76,6 +76,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch:
7676 &limit(staged_patch, 40_000),
7777 ]
7878 .join("\n");
79+ push_instructions(&mut prompt, instructions);
7980 let value = json_reply(&generate(&prompt)?)?;
8081 let subject = value.get("subject")?.as_str()?.trim().trim_end_matches('.').to_string();
8182 if subject.is_empty() {
@@ -86,15 +87,44 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch:
8687 }
8788
8889 /// A pull request's title and body for a branch, as T3 Code asks for them.
89- pub fn pull_request_content(base: &str, head: &str, commits: &str, diff_stat: &str, diff_patch: &str) -> Option<(String, String)> {
90+ pub fn pull_request_content(
91+ base: &str,
92+ head: &str,
93+ commits: &str,
94+ diff_stat: &str,
95+ diff_patch: &str,
96+ template: Option<&str>,
97+ instructions: &str,
98+ ) -> Option<(String, String)> {
99+ let body_rules: &[&str] = if template.is_some() {
100+ &[
101+ "- body must be markdown and follow the repository change request template structure",
102+ "- fill in the template sections appropriately for this change",
103+ "- drop HTML comments from the template in the generated body",
104+ "- keep the template's markdown structure",
105+ ]
106+ } else {
107+ &[
108+ "- body must be markdown and include headings '## Summary' and '## Testing'",
109+ "- under Summary, provide short bullet points",
110+ "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
111+ ]
112+ };
113+ let mut lines: Vec<String> = vec![
114+ "You write source control change request content.".into(),
115+ "Return a JSON object with keys: title, body.".into(),
116+ "Rules:".into(),
117+ "- title should be concise and specific".into(),
118+ ];
119+ lines.extend(body_rules.iter().map(|rule| rule.to_string()));
120+ if !instructions.trim().is_empty() {
121+ lines.extend(["".into(), "Additional instructions:".into(), limit(instructions.trim(), 20_000)]);
122+ }
123+ if let Some(template) = template {
124+ lines.extend(["".into(), "Repository change request template:".into(), limit(template, 8_000)]);
125+ }
90126 let prompt = [
91- "You write source control change request content.",
92- "Return a JSON object with keys: title, body.",
93- "Rules:",
94- "- title should be concise and specific",
95- "- body must be markdown and include headings '## Summary' and '## Testing'",
96- "- under Summary, provide short bullet points",
97- "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
127+ lines.join("\n").as_str(),
98128 "",
99129 &format!("Base branch: {base}"),
100130 &format!("Head branch: {head}"),
@@ -115,6 +145,13 @@ pub fn pull_request_content(base: &str, head: &str, commits: &str, diff_stat: &s
115145 (!title.is_empty()).then_some((title, body))
116146 }
117147
148+ /// T3 Code's "Additional instructions" section, when there are any.
149+ fn push_instructions(prompt: &mut String, instructions: &str) {
150+ if !instructions.trim().is_empty() {
151+ prompt.push_str(&format!("\n\nAdditional instructions:\n{}", limit(instructions.trim(), 20_000)));
152+ }
153+ }
154+
118155 /// The JSON object in a model's reply, which may sit in a code fence.
119156 fn json_reply(reply: &str) -> Option<serde_json::Value> {
120157 let start = reply.find('{')?;
+16−0src/ui/settings/mod.rsView File
@@ -139,6 +139,7 @@ pub struct SettingsView {
139139 add_project_dir: Entity<InputState>,
140140 branch_prefix: Entity<InputState>,
141141 branch_instructions: Entity<TextareaState>,
142+ writing_instructions: Entity<TextareaState>,
142143 worktrees_dir: Entity<InputState>,
143144 keybinding_search: Entity<InputState>,
144145 providers: providers::ProvidersState,
@@ -168,6 +169,9 @@ impl SettingsView {
168169 let branch_instructions = cx.new(|cx| {
169170 TextareaState::new(window, cx).rows(4).placeholder("Use julius/ followed by the issue ID and a short description.")
170171 });
172+ let writing_instructions = cx.new(|cx| {
173+ TextareaState::new(window, cx).rows(4).placeholder("Keep titles concise. Use short bullet points in descriptions.")
174+ });
171175
172176 let mut subscriptions = vec![
173177 cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event {
@@ -196,6 +200,15 @@ impl SettingsView {
196200 commit_on_blur(&worktrees_dir, window, cx, |settings| settings.worktrees_dir.clone(), |settings, value| {
197201 settings.worktrees_dir = value
198202 }),
203+ cx.subscribe_in(&writing_instructions, window, |this, input, event: &InputEvent, window, cx| {
204+ if matches!(event, InputEvent::Blur) {
205+ let value = input.read(cx).value().trim().to_string();
206+ if settings::get(|settings| settings.writing_instructions != value) {
207+ settings::update(|settings| settings.writing_instructions = value);
208+ this.changed(window, cx);
209+ }
210+ }
211+ }),
199212 cx.subscribe_in(&branch_instructions, window, |this, input, event: &InputEvent, window, cx| {
200213 if matches!(event, InputEvent::Blur) {
201214 let value = input.read(cx).value().trim().to_string();
@@ -219,6 +232,7 @@ impl SettingsView {
219232 add_project_dir,
220233 branch_prefix,
221234 branch_instructions,
235+ writing_instructions,
222236 worktrees_dir,
223237 keybinding_search,
224238 providers: providers::ProvidersState::default(),
@@ -265,6 +279,8 @@ impl SettingsView {
265279 set(&self.worktrees_dir, saved.worktrees_dir.clone(), window, cx);
266280 self.branch_instructions
267281 .update(cx, |input, cx| input.set_value(saved.branch_instructions.clone(), window, cx));
282+ self.writing_instructions
283+ .update(cx, |input, cx| input.set_value(saved.writing_instructions.clone(), window, cx));
268284 self.connections.reload(&saved, window, cx);
269285 self.reload_provider_form(window, cx);
270286 }
+16−0src/ui/settings/search.rsView File
@@ -37,6 +37,10 @@ const fn folded(id: &'static str, title: &'static str, page: Page, terms: &'stat
3737 /// Searchable settings, in result order.
3838 pub const ITEMS: &[Item] = &[
3939 item("storage-worktrees-location", "Worktree location", Page::Storage, "worktree location folder directory path drive external disk"),
40+ item("storage-worktrees-deleted", "Delete worktrees with deleted threads", Page::Storage, "worktree cleanup remove delete thread automatic"),
41+ item("storage-worktrees-inactive", "Delete inactive worktrees", Page::Storage, "worktree cleanup remove inactive days retention automatic"),
42+ item("storage-worktrees-merged", "Delete merged worktrees", Page::Storage, "worktree cleanup remove merged pull request automatic"),
43+ item("storage-worktrees-unchanged", "Delete unchanged worktrees", Page::Storage, "worktree cleanup remove unchanged commits default branch automatic"),
4044 item("storage-files", "Files", Page::Storage, "settings threads scratch data folder open"),
4145 item("default-model", "Default model", Page::General, "new thread project provider reasoning effort"),
4246 item(
@@ -116,6 +120,18 @@ pub const ITEMS: &[Item] = &[
116120 item("open-in", "Open in", Page::Integrations, "editor zed vs code cursor vscodium sublime kate file manager"),
117121 item("source-control", "Source control", Page::SourceControl, "version control git github hosting integrations credentials gh"),
118122 item("worktree-branch-naming", "Worktree branch naming", Page::SourceControl, "static semantic prefix custom prompt instructions feat fix refactor chore"),
123+ item(
124+ "source-control-writing-style",
125+ "Source control writing style",
126+ Page::SourceControl,
127+ "commit message pull request repository conventions conventional commits custom instructions",
128+ ),
129+ item(
130+ "follow-change-request-templates",
131+ "Follow change request templates",
132+ Page::SourceControl,
133+ "pull request template description markdown",
134+ ),
119135 revealed("branch-prefix", "Branch prefix", Page::SourceControl, "worktree branch name prefix vectrum", "worktree-branch-naming"),
120136 revealed(
121137 "branch-naming-instructions",
+44−2src/ui/settings/source_control.rsView File
@@ -9,12 +9,18 @@ use gpui_kit::prelude::FluentBuilder as _;
99 use gpui_kit::*;
1010
1111 use super::SettingsView;
12- use super::widgets::{Section, SettingRow, choice, code, empty_row, reset, text_field};
13- use crate::settings::{self, BranchNaming};
12+ use super::widgets::{Section, SettingRow, choice, code, empty_row, reset, switch, text_field};
13+ use crate::settings::{self, BranchNaming, WritingStyle};
1414 use crate::ui::icons::Lucide;
1515 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};
1616 use crate::ui::workspace;
1717
18+ const WRITING_STYLES: &[(WritingStyle, &str)] = &[
19+ (WritingStyle::RepoConventions, "Repository conventions"),
20+ (WritingStyle::Conventional, "Conventional Commits"),
21+ (WritingStyle::Custom, "Custom instructions"),
22+ ];
23+
1824 const BRANCH_NAMING: &[(BranchNaming, &str)] = &[
1925 (BranchNaming::Static, "Static prefix"),
2026 (BranchNaming::Semantic, "Semantic prefix"),
@@ -169,6 +175,42 @@ impl SettingsView {
169175 }
170176 BranchNaming::Semantic => {}
171177 }
178+ naming = naming.row(
179+ SettingRow::new("Source control writing style")
180+ .id("source-control-writing-style")
181+ .description(match saved.writing_style {
182+ WritingStyle::RepoConventions => {
183+ "In each project, matches recent change descriptions and change request titles."
184+ }
185+ WritingStyle::Conventional => "Use Conventional Commit prefixes and keep change request text concise.",
186+ WritingStyle::Custom => "Use your instructions for change descriptions and change requests in every project.",
187+ })
188+ .reset(reset(&view, |s| s.writing_style, |s, style| s.writing_style = style))
189+ .control(choice("writing-style", saved.writing_style, WRITING_STYLES, &view, |settings, style| {
190+ settings.writing_style = style
191+ }))
192+ .when(saved.writing_style == WritingStyle::Custom, |row| {
193+ row.below(
194+ div()
195+ .px(px(2.))
196+ .py(px(2.))
197+ .rounded(px(12.))
198+ .well(p)
199+ .text_size(px(12.5))
200+ .text_color(p.ink)
201+ .child(Textarea::new(&self.writing_instructions).appearance(false)),
202+ )
203+ }),
204+ );
205+ naming = naming.row(
206+ SettingRow::new("Follow change request templates")
207+ .id("follow-change-request-templates")
208+ .description("Use the repository's template for change request descriptions when available.")
209+ .reset(reset(&view, |s| s.follow_pr_templates, |s, on| s.follow_pr_templates = on))
210+ .control(switch("follow-pr-templates", saved.follow_pr_templates, &view, p, |settings, on| {
211+ settings.follow_pr_templates = on
212+ })),
213+ );
172214 vec![version_control, providers, naming.render(cx)]
173215 }
174216 }
+44−2src/ui/workspace/git_actions.rsView File
@@ -201,7 +201,8 @@ fn run(
201201 let message = if message.trim().is_empty() {
202202 let (summary, patch) = git::staged(path);
203203 let branch = git::status(path).and_then(|status| status.branch);
204- crate::textgen::commit_message(branch.as_deref(), &summary, &patch).unwrap_or_else(|| "Update files".into())
204+ crate::textgen::commit_message(branch.as_deref(), &summary, &patch, &writing_instructions(path))
205+ .unwrap_or_else(|| "Update files".into())
205206 } else {
206207 message
207208 };
@@ -230,7 +231,9 @@ fn run(
230231 let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default();
231232 let mut command = std::process::Command::new(gh);
232233 command.current_dir(path).args(["pr", "create", "--base", &base, "--head", &branch]);
233- match crate::textgen::pull_request_content(&base, &branch, &commits, &stat, &patch) {
234+ let template = pr_template(path);
235+ let instructions = writing_instructions(path);
236+ match crate::textgen::pull_request_content(&base, &branch, &commits, &stat, &patch, template.as_deref(), &instructions) {
234237 Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]),
235238 None => command.arg("--fill"),
236239 };
@@ -246,6 +249,45 @@ fn run(
246249 Ok(Outcome { title: if title.is_empty() { "Done".into() } else { title }, url: None })
247250 }
248251
252+ /// The instructions Settings → Source Control → writing style adds to
253+ /// commit message and pull request prompts.
254+ fn writing_instructions(path: &Path) -> String {
255+ let (style, custom) = crate::settings::get(|settings| (settings.writing_style, settings.writing_instructions.clone()));
256+ match style {
257+ crate::settings::WritingStyle::RepoConventions => {
258+ let recent = git::git(path, &["log", "-15", "--format=%s"]).unwrap_or_default();
259+ if recent.trim().is_empty() {
260+ String::new()
261+ } else {
262+ format!("Match the style of this repository's recent commit messages:\n{}", recent.trim())
263+ }
264+ }
265+ crate::settings::WritingStyle::Conventional => {
266+ "Use Conventional Commit prefixes and keep change request text concise.".into()
267+ }
268+ crate::settings::WritingStyle::Custom => custom,
269+ }
270+ }
271+
272+ /// The repository's pull request template, when it has one and the
273+ /// settings say to follow it.
274+ fn pr_template(path: &Path) -> Option<String> {
275+ if !crate::settings::get(|settings| settings.follow_pr_templates) {
276+ return None;
277+ }
278+ let root = PathBuf::from(git::git(path, &["rev-parse", "--show-toplevel"]).ok()?.trim());
279+ [
280+ ".github/pull_request_template.md",
281+ ".github/PULL_REQUEST_TEMPLATE.md",
282+ "docs/pull_request_template.md",
283+ "pull_request_template.md",
284+ "PULL_REQUEST_TEMPLATE.md",
285+ ]
286+ .iter()
287+ .find_map(|candidate| std::fs::read_to_string(root.join(candidate)).ok())
288+ .filter(|template| !template.trim().is_empty())
289+ }
290+
249291 impl Workspace {
250292 /// The folder the git actions work in: the open thread's worktree, or
251293 /// its project's checkout. `None` for a thread on another device.