Add generate commit message button for git

This commit is contained in:
Steven Wexler
2025-05-12 08:44:05 -06:00
parent 451aa36575
commit ef7502d783
9 changed files with 246 additions and 4 deletions
+6 -1
View File
@@ -130,7 +130,8 @@ import { IVoidUpdateService } from '../../workbench/contrib/void/common/voidUpda
import { MetricsMainService } from '../../workbench/contrib/void/electron-main/metricsMainService.js';
import { VoidMainUpdateService } from '../../workbench/contrib/void/electron-main/voidUpdateMainService.js';
import { LLMMessageChannel } from '../../workbench/contrib/void/electron-main/sendLLMMessageChannel.js';
import { IVoidSCM } from '../../workbench/contrib/void/common/voidSCM.js';
import { VoidSCM } from '../../workbench/contrib/void/electron-main/voidSCM.js';
/**
* The main VS Code application. There will only ever be one instance,
* even if the user starts many instances (e.g. from the command line).
@@ -1102,6 +1103,7 @@ export class CodeApplication extends Disposable {
// Void main process services (required for services with a channel for comm between browser and electron-main (node))
services.set(IMetricsService, new SyncDescriptor(MetricsMainService, undefined, false));
services.set(IVoidUpdateService, new SyncDescriptor(VoidMainUpdateService, undefined, false));
services.set(IVoidSCM, new SyncDescriptor(VoidSCM, undefined, false));
// Default Extensions Profile Init
services.set(IExtensionsProfileScannerService, new SyncDescriptor(ExtensionsProfileScannerService, undefined, true));
@@ -1243,6 +1245,9 @@ export class CodeApplication extends Disposable {
const sendLLMMessageChannel = new LLMMessageChannel(accessor.get(IMetricsService));
mainProcessElectronServer.registerChannel('void-channel-llmMessage', sendLLMMessageChannel);
const voidSCMChannel = ProxyChannel.fromService(accessor.get(IVoidSCM), disposables);
mainProcessElectronServer.registerChannel('void-channel-scm', voidSCMChannel);
// Extension Host Debug Broadcasting
const electronExtensionHostDebugBroadcastChannel = new ElectronExtensionHostDebugBroadcastChannel(accessor.get(IWindowsMainService));
mainProcessElectronServer.registerChannel('extensionhostdebugservice', electronExtensionHostDebugBroadcastChannel);
@@ -123,6 +123,7 @@ const featureNameMap: { display: string, featureName: FeatureName }[] = [
{ display: 'Quick Edit', featureName: 'Ctrl+K' },
{ display: 'Autocomplete', featureName: 'Autocomplete' },
{ display: 'Fast Apply', featureName: 'Apply' },
{ display: 'Source Control', featureName: 'SCM' },
];
const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setPageIndex: (index: number) => void }) => {
@@ -58,6 +58,9 @@ import './voidOnboardingService.js'
// register misc service
import './miscWokrbenchContrib.js'
// register source control management
import './voidSCM.js'
// ---------- common (unclear if these actually need to be imported, because they're already imported wherever they're used) ----------
// llmMessage
@@ -0,0 +1,131 @@
import { ThemeIcon } from '../../../../base/common/themables.js'
import { localize2 } from '../../../../nls.js'
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'
import { ISCMService } from '../../scm/common/scm.js'
import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'
import { IVoidSCM } from '../common/voidSCM.js'
import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'
import { IVoidSettingsService } from '../common/voidSettingsService.js'
import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'
import { ILLMMessageService } from '../common/sendLLMMessageService.js'
import { ModelSelection, OverridesOfModel, ModelSelectionOptions } from '../common/voidSettingsTypes.js'
import { commitMessage_systemMessage } from '../common/prompt/prompts.js'
import { LLMChatMessage } from '../common/sendLLMMessageTypes.js'
import { ISCMRepository } from '../../../../workbench/contrib/scm/common/scm.js'
interface ModelOptions {
modelSelection: ModelSelection | null
modelSelectionOptions?: ModelSelectionOptions
overridesOfModel: OverridesOfModel
}
const scm = 'SCM'
const prepareModelOptions = (settingsService: IVoidSettingsService): ModelOptions => {
const modelSelection = settingsService.state.modelSelectionOfFeature[scm]
const modelSelectionOptions = modelSelection ? settingsService.state.optionsOfModelSelection[scm][modelSelection?.providerName]?.[modelSelection.modelName] : undefined
const overridesOfModel = settingsService.state.overridesOfModel
return {
modelSelection,
modelSelectionOptions,
overridesOfModel
}
}
const preparePrompt = (stat: string, sampledDiffs: string) => {
const section1 = `Section 1 - Summary of Changes (git diff --stat):`
const section2 = `Section 2 - Sampled File Diffs (Top changed files):`
return `
Based on the following Git changes, write a clear, concise commit message that accurately summarizes the intent of the code changes.
${section1}
${stat}
${section2}
${sampledDiffs}
`.trim()
}
const prepareMessages = (prompt: string, modelOptions: ModelOptions, convertToLLMMessageService: IConvertToLLMMessageService) => {
const simpleMessages = [{ role: 'user' as 'user', content: prompt }]
const { messages, separateSystemMessage } = convertToLLMMessageService.prepareLLMSimpleMessages({
simpleMessages,
systemMessage: commitMessage_systemMessage,
modelSelection: modelOptions.modelSelection,
featureName: scm,
})
return {
messages,
separateSystemMessage
}
}
const onFinalMessage = (repo: ISCMRepository) => (params: { fullText: string }) => {
const match = params.fullText.match(/<output>([\s\S]*?)<\/output>/i)
const commitMessage = match ? match[1].trim() : ''
repo.input.setValue(commitMessage, false)
}
const sendLLMMessage = (messages: LLMChatMessage[], separateSystemMessage: string, modelOptions: ModelOptions, repo: ISCMRepository, llmMessageService: ILLMMessageService) => {
//TODO VoidSCM - Experiment with messages to get better results
llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
messages,
separateSystemMessage,
chatMode: null,
modelSelection: modelOptions.modelSelection,
modelSelectionOptions: modelOptions.modelSelectionOptions,
overridesOfModel: modelOptions.overridesOfModel,
onText: () => { },
onFinalMessage: onFinalMessage(repo),
onError: (error: any) => { console.error(error) }, //TODO VoidSCM - handle errors
onAbort: () => { console.log('abort') }, //TODO VoidSCM - handle abort
logging: { loggingName: 'VoidSCM - Commit Message' },
})
}
class CommitMessagePromptActionService extends Action2 {
private static readonly VOID_COMMIT_MESSAGE_PROMPT_ID = 'void.commitMessagePrompt'
constructor() {
super({
id: CommitMessagePromptActionService.VOID_COMMIT_MESSAGE_PROMPT_ID,
title: localize2('voidCommitMessagePrompt', 'Void: Generate Commit Message'),
icon: ThemeIcon.fromId('sparkle'),
tooltip: localize2('voidCommitMessagePromptTooltip', 'Void: Generate Commit Message'),
f1: true,
menu: [{
id: MenuId.SCMInputBox,
when: ContextKeyExpr.equals('scmProvider', 'git'),
group: 'inline'
}]
})
}
//TODO VoidSCM - handle loading state, debouncing, errors, and cancellations
async run(accessor: ServicesAccessor): Promise<void> {
const scmService = accessor.get(ISCMService)
const mainProcessService = accessor.get(IMainProcessService)
const voidSettingsService = accessor.get(IVoidSettingsService)
const convertToLLMMessageService = accessor.get(IConvertToLLMMessageService)
const llmMessageService = accessor.get(ILLMMessageService)
const voidSCM = ProxyChannel.toService<IVoidSCM>(mainProcessService.getChannel('void-channel-scm'))
const repo = Array.from(scmService.repositories || []).find((r: any) => r.provider.contextValue === 'git')
//TODO VoidSCM - use the notification service to show an error if repo or rootUri is not found
if (!repo || !repo.provider.rootUri?.fsPath) { return }
const path = repo.provider.rootUri.fsPath
const [stat, sampledDiffs] = await Promise.all([voidSCM.gitStat(path), voidSCM.gitSampledDiffs(path)])
const modelOptions = prepareModelOptions(voidSettingsService)
const prompt = preparePrompt(stat, sampledDiffs)
const { messages, separateSystemMessage } = prepareMessages(prompt, modelOptions, convertToLLMMessageService)
sendLLMMessage(messages, separateSystemMessage!, modelOptions, repo, llmMessageService)
}
}
registerAction2(CommitMessagePromptActionService)
@@ -947,3 +947,20 @@ Store Result: After computing fib(n), the result is stored in memo for future re
## END EXAMPLES
*/
// ======================================================== scm ========================================================================
export const commitMessage_systemMessage = `
You are an expert software engineer AI assistant responsible for writing clear, concise Git commit messages.
You always respond with:
- The commit message wrapped in <output> tags
- A brief explanation of the reasoning behind the message, wrapped in <reasoning> tags
Example format:
<output>Fix login bug and improve error handling</output>
<reasoning>This commit updates the login handler to fix a redirect issue and improves frontend error messages for failed logins.</reasoning>
Do not include anything else outside of these tags.
Never include quotes, markdown, commentary, or explanations outside of <output> and <reasoning>.`.trim()
@@ -0,0 +1,19 @@
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export interface IVoidSCM {
readonly _serviceBrand: undefined;
/**
* Get git diff --stat
*
* @param path Path to the git repository
*/
gitStat(path: string): Promise<string>
/**
* Get git diff --stat for the top 10 most significantly changed files according to lines added/removed
*
* @param path Path to the git repository
*/
gitSampledDiffs(path: string): Promise<string>
}
export const IVoidSCM = createDecorator<IVoidSCM>('void-scm')
@@ -110,6 +110,7 @@ export const modelFilterOfFeatureName: {
'Chat': { filter: o => true, emptyMessage: null, },
'Ctrl+K': { filter: o => true, emptyMessage: null, },
'Apply': { filter: o => true, emptyMessage: null, },
'SCM': { filter: o => true, emptyMessage: null, },
}
@@ -207,9 +208,9 @@ const _validatedModelState = (state: Omit<VoidSettingsState, '_modelOptions'>):
const defaultState = () => {
const d: VoidSettingsState = {
settingsOfProvider: deepClone(defaultSettingsOfProvider),
modelSelectionOfFeature: { 'Chat': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null },
modelSelectionOfFeature: { 'Chat': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null, 'SCM': null },
globalSettings: deepClone(defaultGlobalSettings),
optionsOfModelSelection: { 'Chat': {}, 'Ctrl+K': {}, 'Autocomplete': {}, 'Apply': {} },
optionsOfModelSelection: { 'Chat': {}, 'Ctrl+K': {}, 'Autocomplete': {}, 'Apply': {}, 'SCM': {} },
overridesOfModel: deepClone(defaultOverridesOfModel),
_modelOptions: [], // computed later
}
@@ -272,6 +273,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
// autoapprove is now an obj not a boolean (1.2.5)
if (typeof readS.globalSettings.autoApprove === 'boolean') readS.globalSettings.autoApprove = {}
// 1.3.5 add source control feature
if (readS.modelSelectionOfFeature && !readS.modelSelectionOfFeature['SCM']) {
readS.modelSelectionOfFeature['SCM'] = deepClone(readS.modelSelectionOfFeature['Chat'])
readS.optionsOfModelSelection['SCM'] = deepClone(readS.optionsOfModelSelection['Chat'])
}
}
catch (e) {
readS = defaultState()
@@ -350,7 +350,7 @@ export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) =>
}
// this is a state
export const featureNames = ['Chat', 'Ctrl+K', 'Autocomplete', 'Apply'] as const
export const featureNames = ['Chat', 'Ctrl+K', 'Autocomplete', 'Apply', 'SCM'] as const
export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null>
export type FeatureName = keyof ModelSelectionOfFeature
@@ -365,6 +365,9 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => {
return 'Chat'
else if (featureName === 'Apply')
return 'Apply'
// source control:
else if (featureName === 'SCM')
return 'Source Control'
else
throw new Error(`Feature Name ${featureName} not allowed`)
}
@@ -0,0 +1,56 @@
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'
import { IVoidSCM } from '../common/voidSCM.js'
import { promisify } from 'util'
import { exec as _exec } from 'child_process'
interface NumStat {
file: string
added: number
removed: number
}
const exec = promisify(_exec)
const git = async (command: string, path: string): Promise<string> => {
const { stdout } = await exec(`${command}`, { cwd: path })
//TODO VoidSCM - handle stderr
return stdout
}
const getNumStat = async (path: string): Promise<NumStat[]> => {
const output = await git('git diff --numstat', path)
return output
.split('\n')
.map((line) => {
const [added, removed, file] = line.split('\t')
return {
file,
added: parseInt(added, 10) || 0,
removed: parseInt(removed, 10) || 0,
}
})
}
const getSampledDiff = async (file: string, path: string): Promise<string> => {
const diff = await git(`git diff --unified=0 --no-color -- "${file}"`, path)
return diff.slice(0, 2000)
}
export class VoidSCM implements IVoidSCM {
readonly _serviceBrand: undefined
async gitStat(path: string): Promise<string> {
return await git('git diff --stat', path)
}
async gitSampledDiffs(path: string): Promise<string> {
const numStatList = await getNumStat(path)
const topFiles = numStatList
.sort((a, b) => (b.added + b.removed) - (a.added + a.removed))
.slice(0, 10)
const diffs = await Promise.all(topFiles.map(async ({ file }) => ({ file, diff: await getSampledDiff(file, path) })))
return diffs.map(({ file, diff }) => `==== ${file} ====\n${diff}`).join('\n\n') //TODO VoidSCM - investigate why file can be undefined
}
}
registerSingleton(IVoidSCM, VoidSCM, InstantiationType.Delayed)