From 65798fcbb0adb8479298e4b7fdd627304ca839f9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 19 Mar 2026 22:44:33 +0800 Subject: [PATCH 1/2] feat(commit): add AI-generated commit message support. (no ai vendor-specific integrations). --- docs-master/Config.md | 7 ++ pkg/config/user_config.go | 5 ++ .../controllers/commit_message_controller.go | 8 ++ pkg/gui/controllers/helpers/commits_helper.go | 90 +++++++++++++++++++ .../helpers/commits_helper_test.go | 44 +++++++++ pkg/i18n/english.go | 8 ++ schema-master/config.json | 4 + 7 files changed, 166 insertions(+) diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8ea..cd47b85dbce 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -359,6 +359,13 @@ git: # If autoWrapCommitMessage is true, the width to wrap to autoWrapWidth: 72 + # Shell command that generates a commit message from a diff piped to stdin. + # The command's stdout is used as the commit message (markdown fences are + # stripped automatically). + # Accessible via the commit menu ( then 'a'). + # Example: "claude -p 'Generate a conventional commit message for this diff:'" + aiGenerateCommand: "" + # Config relating to merging merging: # If true, run merges in a subprocess so that if a commit message is required, diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843d7..b5f3c5dece9 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -355,6 +355,11 @@ type CommitConfig struct { AutoWrapCommitMessage bool `yaml:"autoWrapCommitMessage"` // If autoWrapCommitMessage is true, the width to wrap to AutoWrapWidth int `yaml:"autoWrapWidth"` + // Shell command that generates a commit message from a diff piped to stdin. + // The command's stdout is used as the commit message (markdown fences are stripped automatically). + // Accessible via the commit menu ( then 'a'). + // Example: "claude -p 'Generate a conventional commit message for this diff:'" + AIGenerateCommand string `yaml:"aiGenerateCommand"` } type MergingConfig struct { diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index e9ff1bc67a3..a51fae6e4c6 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -167,6 +167,10 @@ func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, e } func (self *CommitMessageController) confirm() error { + if self.c.Helpers().Commits.IsGenerating() { + return nil + } + // The default keybinding for this action is "", which means that we // also get here when pasting multi-line text that contains newlines. In // that case we don't want to confirm the commit, but switch to the @@ -185,6 +189,10 @@ func (self *CommitMessageController) confirm() error { } func (self *CommitMessageController) close() error { + if self.c.Helpers().Commits.IsGenerating() { + return nil + } + self.c.Helpers().Commits.CloseCommitMessagePanel() return nil } diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 47170606488..bedbea83b5c 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -4,6 +4,7 @@ import ( "errors" "path/filepath" "strings" + "sync/atomic" "time" "github.com/jesseduffield/gocui" @@ -20,6 +21,13 @@ type CommitsHelper struct { getCommitDescription func() string getUnwrappedCommitDescription func() string setCommitDescription func(string) + + // set to 1 while AI commit message generation is in progress + generating atomic.Int32 +} + +func (self *CommitsHelper) IsGenerating() bool { + return self.generating.Load() == 1 } func NewCommitsHelper( @@ -199,6 +207,13 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S } } + var disabledReasonForAI *types.DisabledReason + if self.c.UserConfig().Git.Commit.AIGenerateCommand == "" { + disabledReasonForAI = &types.DisabledReason{ + Text: self.c.Tr.NoAICommandConfigured, + } + } + menuItems := []*types.MenuItem{ { Label: self.c.Tr.OpenInEditor, @@ -222,6 +237,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S }, Key: 'p', }, + { + Label: self.c.Tr.GenerateCommitMessageWithAI, + OnPress: func() error { + return self.generateCommitMessageWithAI() + }, + Key: 'a', + DisabledReason: disabledReasonForAI, + }, } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CommitMenuTitle, @@ -263,3 +286,70 @@ func (self *CommitsHelper) pasteCommitMessageFromClipboard() error { }, }) } + +func (self *CommitsHelper) generateCommitMessageWithAI() error { + self.generating.Store(1) + self.c.Views().CommitMessage.Editable = false + self.c.Views().CommitDescription.Editable = false + originalTitle := self.c.Views().CommitMessage.Title + self.c.Views().CommitMessage.Title = self.c.Tr.GeneratingCommitMessageStatus + + restore := func() { + self.c.OnUIThread(func() error { + self.generating.Store(0) + self.c.Views().CommitMessage.Editable = true + self.c.Views().CommitDescription.Editable = true + self.c.Views().CommitMessage.Title = originalTitle + return nil + }) + } + + return self.c.WithWaitingStatus(self.c.Tr.GeneratingCommitMessageStatus, func(_ gocui.Task) error { + defer restore() + + diff, err := self.c.Git().Diff.GetDiff(true) + if err != nil { + return err + } + if strings.TrimSpace(diff) == "" { + self.c.OnUIThread(func() error { + self.c.ErrorToast(self.c.Tr.NoStagedChangesForAI) + return nil + }) + return nil + } + + command := self.c.UserConfig().Git.Commit.AIGenerateCommand + message, err := self.c.OS().Cmd.NewShell(command, "").SetStdin(diff).DontLog().RunWithOutput() + if err != nil { + return err + } + + message = parseAIOutput(message) + if message == "" { + return errors.New("AI returned an empty commit message") + } + + self.c.OnUIThread(func() error { + self.SetMessageAndDescriptionInView(message) + return nil + }) + return nil + }) +} + +// parseAIOutput strips markdown code fences and any preamble before them. +func parseAIOutput(s string) string { + s = strings.TrimSpace(s) + if start := strings.Index(s, "```"); start >= 0 { + rest := s[start:] + if idx := strings.Index(rest, "\n"); idx >= 0 { + rest = rest[idx+1:] + } + if idx := strings.LastIndex(rest, "```"); idx >= 0 { + rest = rest[:idx] + } + s = strings.TrimSpace(rest) + } + return s +} diff --git a/pkg/gui/controllers/helpers/commits_helper_test.go b/pkg/gui/controllers/helpers/commits_helper_test.go index 6197c3916ce..026f1d2ae21 100644 --- a/pkg/gui/controllers/helpers/commits_helper_test.go +++ b/pkg/gui/controllers/helpers/commits_helper_test.go @@ -6,6 +6,50 @@ import ( "github.com/stretchr/testify/assert" ) +func TestParseAIOutput(t *testing.T) { + scenarios := []struct { + name string + input string + expected string + }{ + { + name: "plain text unchanged", + input: "feat: add login button", + expected: "feat: add login button", + }, + { + name: "strips surrounding whitespace", + input: " feat: add login button ", + expected: "feat: add login button", + }, + { + name: "strips plain code fence", + input: "```\nfeat: add login button\n```", + expected: "feat: add login button", + }, + { + name: "strips fenced block with language tag", + input: "```text\nfeat: add login button\n```", + expected: "feat: add login button", + }, + { + name: "strips fenced block with surrounding text", + input: "Here is your commit:\n```\nfeat: add login button\n```\nDone.", + expected: "feat: add login button", + }, + { + name: "multiline commit message in fence", + input: "```\nfeat: add login button\n\nCloses #123\n```", + expected: "feat: add login button\n\nCloses #123", + }, + } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, parseAIOutput(s.input)) + }) + } +} + func TestTryRemoveHardLineBreaks(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4c423c2bcbd..ba7fcad889f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -672,6 +672,10 @@ type TranslationSet struct { CommitURL string PasteCommitMessageFromClipboard string SurePasteCommitMessage string + GenerateCommitMessageWithAI string + GeneratingCommitMessageStatus string + NoStagedChangesForAI string + NoAICommandConfigured string CommitMessage string CommitMessageBody string CommitSubject string @@ -1789,6 +1793,10 @@ func EnglishTranslationSet() *TranslationSet { CommitURL: "Commit URL", PasteCommitMessageFromClipboard: "Paste commit message from clipboard", SurePasteCommitMessage: "Pasting will overwrite the current commit message, continue?", + GenerateCommitMessageWithAI: "Generate commit message with AI", + GeneratingCommitMessageStatus: "Generating commit message...", + NoStagedChangesForAI: "No staged changes to generate a commit message from", + NoAICommandConfigured: "No AI command configured. Set git.commit.aiGenerateCommand in your config", CommitMessage: "Commit message (subject and body)", CommitMessageBody: "Commit message body", CommitSubject: "Commit subject", diff --git a/schema-master/config.json b/schema-master/config.json index 54f9fa9ec4d..fb012bb3d89 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -19,6 +19,10 @@ "type": "integer", "description": "If autoWrapCommitMessage is true, the width to wrap to", "default": 72 + }, + "aiGenerateCommand": { + "type": "string", + "description": "Shell command that generates a commit message from a diff piped to stdin.\nThe command's stdout is used as the commit message (markdown fences are stripped automatically).\nAccessible via the commit menu (\u003cc-o\u003e then 'a').\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\"" } }, "additionalProperties": false, From 3343a3a36487191bc84678861a0dace70d63989b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 20 Mar 2026 01:53:03 +0800 Subject: [PATCH 2/2] refactor: Removed 'ai', just made it generate. --- docs-master/Config.md | 4 +-- pkg/config/user_config.go | 4 +-- pkg/gui/controllers/helpers/commits_helper.go | 32 +++++++++---------- .../helpers/commits_helper_test.go | 2 +- pkg/i18n/english.go | 12 +++---- schema-master/config.json | 4 +-- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index cd47b85dbce..33c3c49d078 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -362,9 +362,9 @@ git: # Shell command that generates a commit message from a diff piped to stdin. # The command's stdout is used as the commit message (markdown fences are # stripped automatically). - # Accessible via the commit menu ( then 'a'). + # Accessible via the commit menu ( then 'g'). # Example: "claude -p 'Generate a conventional commit message for this diff:'" - aiGenerateCommand: "" + generateCommand: "" # Config relating to merging merging: diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index b5f3c5dece9..520d320742f 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -357,9 +357,9 @@ type CommitConfig struct { AutoWrapWidth int `yaml:"autoWrapWidth"` // Shell command that generates a commit message from a diff piped to stdin. // The command's stdout is used as the commit message (markdown fences are stripped automatically). - // Accessible via the commit menu ( then 'a'). + // Accessible via the commit menu ( then 'g'). // Example: "claude -p 'Generate a conventional commit message for this diff:'" - AIGenerateCommand string `yaml:"aiGenerateCommand"` + GenerateCommand string `yaml:"generateCommand"` } type MergingConfig struct { diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index bedbea83b5c..00824694435 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -22,7 +22,7 @@ type CommitsHelper struct { getUnwrappedCommitDescription func() string setCommitDescription func(string) - // set to 1 while AI commit message generation is in progress + // set to 1 while commit message generation is in progress generating atomic.Int32 } @@ -207,10 +207,10 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S } } - var disabledReasonForAI *types.DisabledReason - if self.c.UserConfig().Git.Commit.AIGenerateCommand == "" { - disabledReasonForAI = &types.DisabledReason{ - Text: self.c.Tr.NoAICommandConfigured, + var disabledReasonForGenerate *types.DisabledReason + if self.c.UserConfig().Git.Commit.GenerateCommand == "" { + disabledReasonForGenerate = &types.DisabledReason{ + Text: self.c.Tr.NoGenerateCommandConfigured, } } @@ -238,12 +238,12 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S Key: 'p', }, { - Label: self.c.Tr.GenerateCommitMessageWithAI, + Label: self.c.Tr.GenerateCommitMessage, OnPress: func() error { - return self.generateCommitMessageWithAI() + return self.generateCommitMessage() }, - Key: 'a', - DisabledReason: disabledReasonForAI, + Key: 'g', + DisabledReason: disabledReasonForGenerate, }, } return self.c.Menu(types.CreateMenuOptions{ @@ -287,7 +287,7 @@ func (self *CommitsHelper) pasteCommitMessageFromClipboard() error { }) } -func (self *CommitsHelper) generateCommitMessageWithAI() error { +func (self *CommitsHelper) generateCommitMessage() error { self.generating.Store(1) self.c.Views().CommitMessage.Editable = false self.c.Views().CommitDescription.Editable = false @@ -313,21 +313,21 @@ func (self *CommitsHelper) generateCommitMessageWithAI() error { } if strings.TrimSpace(diff) == "" { self.c.OnUIThread(func() error { - self.c.ErrorToast(self.c.Tr.NoStagedChangesForAI) + self.c.ErrorToast(self.c.Tr.NoStagedChangesForGenerate) return nil }) return nil } - command := self.c.UserConfig().Git.Commit.AIGenerateCommand + command := self.c.UserConfig().Git.Commit.GenerateCommand message, err := self.c.OS().Cmd.NewShell(command, "").SetStdin(diff).DontLog().RunWithOutput() if err != nil { return err } - message = parseAIOutput(message) + message = parseGenerateOutput(message) if message == "" { - return errors.New("AI returned an empty commit message") + return errors.New("generate command returned an empty commit message") } self.c.OnUIThread(func() error { @@ -338,8 +338,8 @@ func (self *CommitsHelper) generateCommitMessageWithAI() error { }) } -// parseAIOutput strips markdown code fences and any preamble before them. -func parseAIOutput(s string) string { +// parseGenerateOutput strips markdown code fences and any preamble before them. +func parseGenerateOutput(s string) string { s = strings.TrimSpace(s) if start := strings.Index(s, "```"); start >= 0 { rest := s[start:] diff --git a/pkg/gui/controllers/helpers/commits_helper_test.go b/pkg/gui/controllers/helpers/commits_helper_test.go index 026f1d2ae21..c8dacae41d3 100644 --- a/pkg/gui/controllers/helpers/commits_helper_test.go +++ b/pkg/gui/controllers/helpers/commits_helper_test.go @@ -45,7 +45,7 @@ func TestParseAIOutput(t *testing.T) { } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - assert.Equal(t, s.expected, parseAIOutput(s.input)) + assert.Equal(t, s.expected, parseGenerateOutput(s.input)) }) } } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ba7fcad889f..753c816c0c5 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -672,10 +672,10 @@ type TranslationSet struct { CommitURL string PasteCommitMessageFromClipboard string SurePasteCommitMessage string - GenerateCommitMessageWithAI string + GenerateCommitMessage string GeneratingCommitMessageStatus string - NoStagedChangesForAI string - NoAICommandConfigured string + NoStagedChangesForGenerate string + NoGenerateCommandConfigured string CommitMessage string CommitMessageBody string CommitSubject string @@ -1793,10 +1793,10 @@ func EnglishTranslationSet() *TranslationSet { CommitURL: "Commit URL", PasteCommitMessageFromClipboard: "Paste commit message from clipboard", SurePasteCommitMessage: "Pasting will overwrite the current commit message, continue?", - GenerateCommitMessageWithAI: "Generate commit message with AI", + GenerateCommitMessage: "Generate commit message", GeneratingCommitMessageStatus: "Generating commit message...", - NoStagedChangesForAI: "No staged changes to generate a commit message from", - NoAICommandConfigured: "No AI command configured. Set git.commit.aiGenerateCommand in your config", + NoStagedChangesForGenerate: "No staged changes to generate a commit message from", + NoGenerateCommandConfigured: "No generate command configured. Set git.commit.generateCommand in your config", CommitMessage: "Commit message (subject and body)", CommitMessageBody: "Commit message body", CommitSubject: "Commit subject", diff --git a/schema-master/config.json b/schema-master/config.json index fb012bb3d89..3fcd9899c32 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -20,9 +20,9 @@ "description": "If autoWrapCommitMessage is true, the width to wrap to", "default": 72 }, - "aiGenerateCommand": { + "generateCommand": { "type": "string", - "description": "Shell command that generates a commit message from a diff piped to stdin.\nThe command's stdout is used as the commit message (markdown fences are stripped automatically).\nAccessible via the commit menu (\u003cc-o\u003e then 'a').\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\"" + "description": "Shell command that generates a commit message from a diff piped to stdin.\nThe command's stdout is used as the commit message (markdown fences are stripped automatically).\nAccessible via the commit menu (\u003cc-o\u003e then 'g').\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\"" } }, "additionalProperties": false,