diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f763c08d..63b5c6da 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes(events)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 537a9acd..b0196e7f 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -40,8 +40,12 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import type { SettingsService } from '../../services/settings-service.js'; -export function createWorktreeRoutes(events: EventEmitter): Router { +export function createWorktreeRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -69,7 +73,7 @@ export function createWorktreeRoutes(events: EventEmitter): Router { '/generate-commit-message', validatePathParams('worktreePath'), requireGitRepoOnly, - createGenerateCommitMessageHandler() + createGenerateCommitMessageHandler(settingsService) ); router.post( '/push', diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index 69e44058..09e79641 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -1,7 +1,8 @@ /** * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff * - * Uses Claude Haiku to generate a concise, conventional commit message from git changes. + * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise, + * conventional commit message from git changes. Defaults to Claude Haiku for speed. */ import type { Request, Response } from 'express'; @@ -9,12 +10,26 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; -import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { mergeCommitMessagePrompts } from '@automaker/prompts'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('GenerateCommitMessage'); const execAsync = promisify(exec); +/** + * Get the effective system prompt for commit message generation. + * Uses custom prompt from settings if enabled, otherwise falls back to default. + */ +async function getSystemPrompt(settingsService?: SettingsService): Promise { + const settings = await settingsService?.getGlobalSettings(); + const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage); + return prompts.systemPrompt; +} + interface GenerateCommitMessageRequestBody { worktreePath: string; } @@ -29,23 +44,6 @@ interface GenerateCommitMessageErrorResponse { error: string; } -const SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided. - -Rules: -- Output ONLY the commit message, nothing else -- First line should be a short summary (50 chars or less) in imperative mood -- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, etc.) -- Keep it concise and descriptive -- Focus on WHAT changed and WHY (if clear from the diff), not HOW -- No quotes, backticks, or extra formatting -- If there are multiple changes, provide a brief summary on the first line - -Examples: -- feat: Add dark mode toggle to settings -- fix: Resolve login validation edge case -- refactor: Extract user authentication logic -- docs: Update installation instructions`; - async function extractTextFromStream( stream: AsyncIterable<{ type: string; @@ -73,10 +71,9 @@ async function extractTextFromStream( return responseText; } -export function createGenerateCommitMessageHandler(): ( - req: Request, - res: Response -) => Promise { +export function createGenerateCommitMessageHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { const { worktreePath } = req.body as GenerateCommitMessageRequestBody; @@ -136,21 +133,66 @@ export function createGenerateCommitMessageHandler(): ( const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; - const stream = query({ - prompt: userPrompt, - options: { - model: CLAUDE_MODEL_MAP.haiku, - systemPrompt: SYSTEM_PROMPT, + // Get model from phase settings + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; + const { model } = resolvePhaseModel(phaseModelEntry); + + logger.info(`Using model for commit message: ${model}`); + + // Get the effective system prompt (custom or default) + const systemPrompt = await getSystemPrompt(settingsService); + + let message: string; + + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for model: ${model}`); + + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + + let responseText = ''; + for await (const msg of provider.executeQuery({ + prompt: cursorPrompt, + model: bareModel, + cwd: worktreePath, maxTurns: 1, allowedTools: [], - permissionMode: 'default', - }, - }); + readOnly: true, + })) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + } - const message = await extractTextFromStream(stream); + message = responseText.trim(); + } else { + // Use Claude SDK for Claude models + const stream = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + message = await extractTextFromStream(stream); + } if (!message || message.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { success: false, error: 'Failed to generate commit message - empty response', diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 492f671f..591a703a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -13,6 +13,7 @@ import { Label } from '@/components/ui/label'; import { GitCommit, Loader2, Sparkles } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; interface WorktreeInfo { path: string; @@ -39,6 +40,7 @@ export function CommitWorktreeDialog({ const [isLoading, setIsLoading] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); + const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages); const handleCommit = async () => { if (!worktree || !message.trim()) return; @@ -83,19 +85,24 @@ export function CommitWorktreeDialog({ } }; - // Generate AI commit message when dialog opens + // Generate AI commit message when dialog opens (if enabled) useEffect(() => { if (open && worktree) { // Reset state setMessage(''); setError(null); + + // Only generate AI commit message if enabled + if (!enableAiCommitMessages) { + return; + } + setIsGenerating(true); const generateMessage = async () => { try { const api = getElectronAPI(); if (!api?.worktree?.generateCommitMessage) { - setError('AI commit message generation not available'); setIsGenerating(false); return; } @@ -120,7 +127,7 @@ export function CommitWorktreeDialog({ generateMessage(); } - }, [open, worktree]); + }, [open, worktree, enableAiCommitMessages]); if (!worktree) return null; diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 9dbfc942..23e7e890 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -44,6 +44,8 @@ export function SettingsView() { setEnableDependencyBlocking, skipVerificationInAutoMode, setSkipVerificationInAutoMode, + enableAiCommitMessages, + setEnableAiCommitMessages, useWorktrees, setUseWorktrees, muteDoneSound, @@ -182,11 +184,13 @@ export function SettingsView() { skipVerificationInAutoMode={skipVerificationInAutoMode} defaultPlanningMode={defaultPlanningMode} defaultRequirePlanApproval={defaultRequirePlanApproval} + enableAiCommitMessages={enableAiCommitMessages} onDefaultSkipTestsChange={setDefaultSkipTests} onEnableDependencyBlockingChange={setEnableDependencyBlocking} onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode} onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} + onEnableAiCommitMessagesChange={setEnableAiCommitMessages} /> ); case 'worktrees': diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index c3b4e9ae..36e86f5a 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -10,6 +10,7 @@ import { ScrollText, ShieldCheck, FastForward, + Sparkles, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { @@ -28,11 +29,13 @@ interface FeatureDefaultsSectionProps { skipVerificationInAutoMode: boolean; defaultPlanningMode: PlanningMode; defaultRequirePlanApproval: boolean; + enableAiCommitMessages: boolean; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; onSkipVerificationInAutoModeChange: (value: boolean) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; + onEnableAiCommitMessagesChange: (value: boolean) => void; } export function FeatureDefaultsSection({ @@ -41,11 +44,13 @@ export function FeatureDefaultsSection({ skipVerificationInAutoMode, defaultPlanningMode, defaultRequirePlanApproval, + enableAiCommitMessages, onDefaultSkipTestsChange, onEnableDependencyBlockingChange, onSkipVerificationInAutoModeChange, onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, + onEnableAiCommitMessagesChange, }: FeatureDefaultsSectionProps) { return (
+ + {/* Separator */} +
+ + {/* AI Commit Messages Setting */} +
+ onEnableAiCommitMessagesChange(checked === true)} + className="mt-1" + data-testid="enable-ai-commit-messages-checkbox" + /> +
+ +

+ When enabled, opening the commit dialog will automatically generate a commit message + using AI based on your staged or unstaged changes. You can configure the model used in + Model Defaults. +

+
+
); diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx index 6c69ceb7..37f3e72d 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx @@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [ label: 'Image Descriptions', description: 'Analyzes and describes context images', }, + { + key: 'commitMessageModel', + label: 'Commit Messages', + description: 'Generates git commit messages from diffs', + }, ]; const VALIDATION_TASKS: PhaseConfig[] = [ diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index e7039840..460f5ac5 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1538,11 +1538,10 @@ function createMockWorktreeAPI(): WorktreeAPI { }, generateCommitMessage: async (worktreePath: string) => { - console.log('[Mock] Generating commit message:', { worktreePath }); + console.log('[Mock] Generating commit message for:', worktreePath); return { success: true, - message: - 'feat: Add new feature implementation\n\nThis is a mock AI-generated commit message.', + message: 'feat: Add mock commit message generation', }; }, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ca415ed6..e09ab4ec 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -536,6 +536,7 @@ export interface AppState { defaultSkipTests: boolean; // Default value for skip tests when creating new features enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) + enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch @@ -932,6 +933,7 @@ export interface AppActions { setDefaultSkipTests: (skip: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void; setSkipVerificationInAutoMode: (enabled: boolean) => Promise; + setEnableAiCommitMessages: (enabled: boolean) => Promise; setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise; setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise; @@ -1218,6 +1220,7 @@ const initialState: AppState = { defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) + enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages) planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch) addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults) useWorktrees: true, // Default to enabled (git worktree isolation) @@ -1848,6 +1851,17 @@ export const useAppStore = create()((set, get) => ({ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, + setEnableAiCommitMessages: async (enabled) => { + const previous = get().enableAiCommitMessages; + set({ enableAiCommitMessages: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync enableAiCommitMessages setting to server - reverting'); + set({ enableAiCommitMessages: previous }); + } + }, setPlanUseSelectedWorktreeBranch: async (enabled) => { const previous = get().planUseSelectedWorktreeBranch; set({ planUseSelectedWorktreeBranch: enabled }); diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index b253129f..4d699f89 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -157,6 +157,10 @@ export interface PhaseModelConfig { // Memory tasks - for learning extraction and memory operations /** Model for extracting learnings from completed agent sessions */ memoryExtractionModel: PhaseModelEntry; + + // Quick tasks - commit messages + /** Model for generating git commit messages from diffs */ + commitMessageModel: PhaseModelEntry; } /** Keys of PhaseModelConfig for type-safe access */ @@ -398,6 +402,10 @@ export interface GlobalSettings { /** Priority for ntfy notifications (1-5, default: 3) */ ntfyPriority: 1 | 2 | 3 | 4 | 5; + // AI Commit Message Generation + /** Enable AI-generated commit messages when opening commit dialog (default: true) */ + enableAiCommitMessages: boolean; + // AI Model Selection (per-phase configuration) /** Phase-specific AI model configuration */ phaseModels: PhaseModelConfig; @@ -669,6 +677,9 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { // Memory - use fast model for learning extraction (cost-effective) memoryExtractionModel: { model: 'haiku' }, + + // Commit messages - use fast model for speed + commitMessageModel: { model: 'haiku' }, }; /** Current version of the global settings schema */ @@ -724,6 +735,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { ntfyTopic: '', ntfyAuthToken: undefined, ntfyPriority: 3, + enableAiCommitMessages: true, phaseModels: DEFAULT_PHASE_MODELS, enhancementModel: 'sonnet', validationModel: 'opus',