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>
Showing 6 changed files with 189 additions and 14 deletions.
| @@ -41,6 +41,19 @@ pub enum BranchNaming { | ||
| 41 | 41 | Custom, |
| 42 | 42 | } |
| 43 | 43 | |
| 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 | + | |
| 44 | 57 | /// What Ctrl+Q takes. |
| 45 | 58 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 46 | 59 | #[serde(rename_all = "snake_case")] |
| @@ -367,6 +380,12 @@ pub struct Settings { | ||
| 367 | 380 | pub branch_prefix: String, |
| 368 | 381 | /// What custom branch naming tells the model. |
| 369 | 382 | 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, | |
| 370 | 389 | /// Where new worktrees go; empty is the app's data folder. |
| 371 | 390 | pub worktrees_dir: String, |
| 372 | 391 | /// Remove a deleted thread's worktree without asking, unless it has |
| @@ -524,6 +543,9 @@ impl Default for Settings { | ||
| 524 | 543 | branch_naming: BranchNaming::Static, |
| 525 | 544 | branch_prefix: "vectrum".into(), |
| 526 | 545 | branch_instructions: String::new(), |
| 546 | + writing_style: WritingStyle::RepoConventions, | |
| 547 | + writing_instructions: String::new(), | |
| 548 | + follow_pr_templates: true, | |
| 527 | 549 | worktrees_dir: String::new(), |
| 528 | 550 | cleanup_deleted_worktrees: false, |
| 529 | 551 | cleanup_inactive_worktrees: None, |
| @@ -58,8 +58,8 @@ pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> O | ||
| 58 | 58 | |
| 59 | 59 | /// A commit message for what's staged, as T3 Code asks for one: an |
| 60 | 60 | /// 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 = [ | |
| 63 | 63 | "You write concise git commit messages.", |
| 64 | 64 | "Return a JSON object with keys: subject, body.", |
| 65 | 65 | "Rules:", |
| @@ -76,6 +76,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: | ||
| 76 | 76 | &limit(staged_patch, 40_000), |
| 77 | 77 | ] |
| 78 | 78 | .join("\n"); |
| 79 | + push_instructions(&mut prompt, instructions); | |
| 79 | 80 | let value = json_reply(&generate(&prompt)?)?; |
| 80 | 81 | let subject = value.get("subject")?.as_str()?.trim().trim_end_matches('.').to_string(); |
| 81 | 82 | if subject.is_empty() { |
| @@ -86,15 +87,44 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: | ||
| 86 | 87 | } |
| 87 | 88 | |
| 88 | 89 | /// 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 | + } | |
| 90 | 126 | 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(), | |
| 98 | 128 | "", |
| 99 | 129 | &format!("Base branch: {base}"), |
| 100 | 130 | &format!("Head branch: {head}"), |
| @@ -115,6 +145,13 @@ pub fn pull_request_content(base: &str, head: &str, commits: &str, diff_stat: &s | ||
| 115 | 145 | (!title.is_empty()).then_some((title, body)) |
| 116 | 146 | } |
| 117 | 147 | |
| 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 | + | |
| 118 | 155 | /// The JSON object in a model's reply, which may sit in a code fence. |
| 119 | 156 | fn json_reply(reply: &str) -> Option<serde_json::Value> { |
| 120 | 157 | let start = reply.find('{')?; |
| @@ -139,6 +139,7 @@ pub struct SettingsView { | ||
| 139 | 139 | add_project_dir: Entity<InputState>, |
| 140 | 140 | branch_prefix: Entity<InputState>, |
| 141 | 141 | branch_instructions: Entity<TextareaState>, |
| 142 | + writing_instructions: Entity<TextareaState>, | |
| 142 | 143 | worktrees_dir: Entity<InputState>, |
| 143 | 144 | keybinding_search: Entity<InputState>, |
| 144 | 145 | providers: providers::ProvidersState, |
| @@ -168,6 +169,9 @@ impl SettingsView { | ||
| 168 | 169 | let branch_instructions = cx.new(|cx| { |
| 169 | 170 | TextareaState::new(window, cx).rows(4).placeholder("Use julius/ followed by the issue ID and a short description.") |
| 170 | 171 | }); |
| 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 | + }); | |
| 171 | 175 | |
| 172 | 176 | let mut subscriptions = vec![ |
| 173 | 177 | cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event { |
| @@ -196,6 +200,15 @@ impl SettingsView { | ||
| 196 | 200 | commit_on_blur(&worktrees_dir, window, cx, |settings| settings.worktrees_dir.clone(), |settings, value| { |
| 197 | 201 | settings.worktrees_dir = value |
| 198 | 202 | }), |
| 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 | + }), | |
| 199 | 212 | cx.subscribe_in(&branch_instructions, window, |this, input, event: &InputEvent, window, cx| { |
| 200 | 213 | if matches!(event, InputEvent::Blur) { |
| 201 | 214 | let value = input.read(cx).value().trim().to_string(); |
| @@ -219,6 +232,7 @@ impl SettingsView { | ||
| 219 | 232 | add_project_dir, |
| 220 | 233 | branch_prefix, |
| 221 | 234 | branch_instructions, |
| 235 | + writing_instructions, | |
| 222 | 236 | worktrees_dir, |
| 223 | 237 | keybinding_search, |
| 224 | 238 | providers: providers::ProvidersState::default(), |
| @@ -265,6 +279,8 @@ impl SettingsView { | ||
| 265 | 279 | set(&self.worktrees_dir, saved.worktrees_dir.clone(), window, cx); |
| 266 | 280 | self.branch_instructions |
| 267 | 281 | .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)); | |
| 268 | 284 | self.connections.reload(&saved, window, cx); |
| 269 | 285 | self.reload_provider_form(window, cx); |
| 270 | 286 | } |
| @@ -37,6 +37,10 @@ const fn folded(id: &'static str, title: &'static str, page: Page, terms: &'stat | ||
| 37 | 37 | /// Searchable settings, in result order. |
| 38 | 38 | pub const ITEMS: &[Item] = &[ |
| 39 | 39 | 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"), | |
| 40 | 44 | item("storage-files", "Files", Page::Storage, "settings threads scratch data folder open"), |
| 41 | 45 | item("default-model", "Default model", Page::General, "new thread project provider reasoning effort"), |
| 42 | 46 | item( |
| @@ -116,6 +120,18 @@ pub const ITEMS: &[Item] = &[ | ||
| 116 | 120 | item("open-in", "Open in", Page::Integrations, "editor zed vs code cursor vscodium sublime kate file manager"), |
| 117 | 121 | item("source-control", "Source control", Page::SourceControl, "version control git github hosting integrations credentials gh"), |
| 118 | 122 | 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 | + ), | |
| 119 | 135 | revealed("branch-prefix", "Branch prefix", Page::SourceControl, "worktree branch name prefix vectrum", "worktree-branch-naming"), |
| 120 | 136 | revealed( |
| 121 | 137 | "branch-naming-instructions", |
| @@ -9,12 +9,18 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 9 | 9 | use gpui_kit::*; |
| 10 | 10 | |
| 11 | 11 | 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}; | |
| 14 | 14 | use crate::ui::icons::Lucide; |
| 15 | 15 | use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _}; |
| 16 | 16 | use crate::ui::workspace; |
| 17 | 17 | |
| 18 | + const WRITING_STYLES: &[(WritingStyle, &str)] = &[ | |
| 19 | + (WritingStyle::RepoConventions, "Repository conventions"), | |
| 20 | + (WritingStyle::Conventional, "Conventional Commits"), | |
| 21 | + (WritingStyle::Custom, "Custom instructions"), | |
| 22 | + ]; | |
| 23 | + | |
| 18 | 24 | const BRANCH_NAMING: &[(BranchNaming, &str)] = &[ |
| 19 | 25 | (BranchNaming::Static, "Static prefix"), |
| 20 | 26 | (BranchNaming::Semantic, "Semantic prefix"), |
| @@ -169,6 +175,42 @@ impl SettingsView { | ||
| 169 | 175 | } |
| 170 | 176 | BranchNaming::Semantic => {} |
| 171 | 177 | } |
| 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 | + ); | |
| 172 | 214 | vec![version_control, providers, naming.render(cx)] |
| 173 | 215 | } |
| 174 | 216 | } |
| @@ -201,7 +201,8 @@ fn run( | ||
| 201 | 201 | let message = if message.trim().is_empty() { |
| 202 | 202 | let (summary, patch) = git::staged(path); |
| 203 | 203 | 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()) | |
| 205 | 206 | } else { |
| 206 | 207 | message |
| 207 | 208 | }; |
| @@ -230,7 +231,9 @@ fn run( | ||
| 230 | 231 | let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default(); |
| 231 | 232 | let mut command = std::process::Command::new(gh); |
| 232 | 233 | 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) { | |
| 234 | 237 | Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]), |
| 235 | 238 | None => command.arg("--fill"), |
| 236 | 239 | }; |
| @@ -246,6 +249,45 @@ fn run( | ||
| 246 | 249 | Ok(Outcome { title: if title.is_empty() { "Done".into() } else { title }, url: None }) |
| 247 | 250 | } |
| 248 | 251 | |
| 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 | + | |
| 249 | 291 | impl Workspace { |
| 250 | 292 | /// The folder the git actions work in: the open thread's worktree, or |
| 251 | 293 | /// its project's checkout. `None` for a thread on another device. |