From 954655915ae34fd592cfb88281298c192daa052e Mon Sep 17 00:00:00 2001 From: TELVIN TEUM Date: Sat, 4 Jul 2026 13:28:17 +0300 Subject: [PATCH] feat: AI commit-message sub-agent with live streaming + commit bar controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an isolated, tool-less one-shot Claude Agent SDK run that proposes commit messages from the staged diff and streams them live into the git panel over a dedicated git:commit-message-stream IPC event — never through the conversation store or agent lifecycle. - GitManager.buildCommitContext: main-side staged diff (60 KB cap), binary-flagged file list, recent subjects for style inference, all argv-only git with credential redaction - AgentManager.generateCommitMessage: pinned Haiku model, maxTurns 1, allowedTools [] + deny-all canUseTool, no resume/transcript/MCP, cancelable per workspace and hooked into cleanup() - Commit bar: narrow Commit button plus Stage all / Unstage all and a Generate/Cancel control in one row, same tokens and design language - useGitStore: draft lives in the store, streams append live, failed or canceled runs restore the user's pre-generation draft - Hardened IPC: sender-validated handle(), assertId-only renderer input, GIT_LIMITS.commitGen caps, redacted errors/logs Co-Authored-By: Claude Fable 5 --- src/main/ipc/gitHandlers.ts | 24 ++- src/main/ipc/index.ts | 2 +- src/main/managers/AgentManager.ts | 268 ++++++++++++++++++++++++- src/main/managers/GitManager.ts | 68 ++++++- src/preload/index.ts | 8 + src/renderer/features/git/GitPanel.tsx | 87 ++++++-- src/renderer/stores/useGitStore.ts | 88 +++++++- src/shared/constants.ts | 11 + src/shared/ipc-channels.ts | 4 + src/shared/types.ts | 37 ++++ 10 files changed, 579 insertions(+), 18 deletions(-) diff --git a/src/main/ipc/gitHandlers.ts b/src/main/ipc/gitHandlers.ts index 4afd38e..c8b5cff 100644 --- a/src/main/ipc/gitHandlers.ts +++ b/src/main/ipc/gitHandlers.ts @@ -8,6 +8,7 @@ import { IpcChannels } from '@shared/ipc-channels'; import { GIT_LIMITS } from '@shared/constants'; import type { + GenerateCommitMessageResult, GitBlameLine, GitBranch, GitCheckoutResult, @@ -23,6 +24,7 @@ import type { } from '@shared/types'; import { handle } from './registry'; import type { GitManager } from '../managers/GitManager'; +import type { AgentManager } from '../managers/AgentManager'; function assertId(id: unknown, label = 'id'): asserts id is string { if (typeof id !== 'string' || id.length === 0 || id.length > 128) { @@ -55,7 +57,7 @@ function assertText(value: unknown, max: number, label: string): asserts value i } } -export function registerGitHandlers(git: GitManager): void { +export function registerGitHandlers(git: GitManager, agent: AgentManager): void { handle<[string], GitStatus>(IpcChannels.gitStatus, (_e, wsId) => { assertId(wsId, 'workspaceId'); return git.status(wsId); @@ -100,6 +102,26 @@ export function registerGitHandlers(git: GitManager): void { return git.commit(wsId, message); }); + // AI commit-message generation: the ONLY renderer input is the workspace id — + // all git context (status / staged diff / recent subjects) is assembled in the + // main process by GitManager and size-capped by GIT_LIMITS.commitGen. The + // sub-agent run is tool-less and only proposes text; it never commits. + handle<[string], GenerateCommitMessageResult>( + IpcChannels.gitCommitMessageGenerate, + async (_e, wsId) => { + assertId(wsId, 'workspaceId'); + const ctx = await git.buildCommitContext(wsId); + if (!ctx) return { ok: false, reason: 'error', error: 'Not a git repository' }; + if (ctx.files.length === 0) return { ok: false, reason: 'no-staged' }; + return agent.generateCommitMessage(wsId, ctx); + }, + ); + + handle<[string], void>(IpcChannels.gitCommitMessageCancel, (_e, wsId) => { + assertId(wsId, 'workspaceId'); + agent.cancelCommitMessage(wsId); + }); + handle<[string, { limit?: number; offset?: number }?], GitCommit[]>( IpcChannels.gitLog, (_e, wsId, opts) => { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 40eb297..f2af127 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -59,7 +59,7 @@ export function registerAllIpc(deps: IpcDeps): void { registerAgentHandlers(deps.agent); registerFsHandlers(deps.fs); registerTerminalHandlers(deps.terminal); - registerGitHandlers(deps.git); + registerGitHandlers(deps.git, deps.agent); registerWorktreeHandlers(deps.worktree); registerServiceHandlers(deps.services); registerMemoryHandlers(deps.memory); diff --git a/src/main/managers/AgentManager.ts b/src/main/managers/AgentManager.ts index dd0aa37..b504e96 100644 --- a/src/main/managers/AgentManager.ts +++ b/src/main/managers/AgentManager.ts @@ -44,6 +44,9 @@ import type { DiagnosticSeverity, FileChange, FileChangeStatus, + GenerateCommitMessageResult, + GitCommitContext, + GitCommitMessageStreamEvent, PermissionDecision, PermissionRequest, PlanMeta, @@ -59,7 +62,7 @@ import type { TerminalCommandRecord, ToolRisk, } from '@shared/types'; -import { ACTIVITY_LIMITS, AGENT_LIMITS } from '@shared/constants'; +import { ACTIVITY_LIMITS, AGENT_LIMITS, GIT_LIMITS } from '@shared/constants'; import { IpcEvents } from '@shared/ipc-channels'; import { getDb } from '../db/database'; import { logger } from '../logger'; @@ -172,6 +175,26 @@ const IDLE_REQUEST: RequestState = { const DELTA_FLUSH_CHARS = 24; const DELTA_FLUSH_MS = 16; +/* ------------------------------------------------------------------ */ +/* Git commit-message sub-agent — an isolated, tool-less one-shot run. */ +/* ------------------------------------------------------------------ */ + +/** + * Pinned haiku-class model for utility one-shots (commit messages). Deliberately + * NOT the user's configured chat model: summarizing a staged diff into a ≤72-char + * subject is fast-model work, and pinning keeps latency/cost predictable. + */ +const COMMIT_MESSAGE_MODEL = 'claude-haiku-4-5-20251001'; + +const COMMIT_SYSTEM_PROMPT = + 'You write git commit messages. Output ONLY the commit message text — no ' + + 'preamble, no explanations, no code fences, no surrounding quotes. First ' + + 'line: an imperative-mood subject of at most 72 characters. If the change ' + + 'needs explanation, add one blank line then a short body wrapped at ~72 ' + + 'columns. If the repository\'s recent commit subjects follow a consistent ' + + 'convention (e.g. "feat:", "fix(scope):"), match it; otherwise use a plain ' + + 'imperative subject.'; + function classifyTool(name: string): ToolRisk { if (WRITE_TOOLS.has(name)) return 'write'; if (COMMAND_TOOLS.has(name)) return 'command'; @@ -350,6 +373,15 @@ export class AgentManager { * whichever session most recently touched the state. */ private readonly requests = new Map(); + /** + * One in-flight commit-message generation per workspace. Kept fully apart + * from {@link runs}: these one-shots never touch lifecycle, transcripts, + * plans, or the conversation event stream. + */ + private readonly commitGenRuns = new Map< + string, + { abort: AbortController; query: { close?: () => void } | null } + >(); /** Pending permission prompts awaiting a renderer decision, keyed by request id. */ private readonly pending = new Map< string, @@ -858,11 +890,202 @@ export class AgentManager { /** Abort every active run + stop all supervision timers. Called on quit. */ cleanup(): void { for (const sessionId of [...this.runs.keys()]) this.stop(sessionId); + for (const workspaceId of [...this.commitGenRuns.keys()]) this.cancelCommitMessage(workspaceId); this.stopHeartbeat(); this.clearRateLimitTimer(); this.clearIdleTimer(); } + /* ---------------------------------------------------------------- */ + /* Git commit-message sub-agent (isolated one-shot) */ + /* ---------------------------------------------------------------- */ + + /** + * Generate a commit message for the staged changes described by `ctx` (built + * main-side by GitManager — never renderer-supplied). Runs an isolated, + * tool-less, single-turn SDK query and streams the text to the renderer over + * `IpcEvents.gitCommitMessageStream`. It only PROPOSES a message: nothing here + * ever calls `git commit`. The run never touches lifecycle, transcripts, + * memory/search context, MCP servers, or the conversation event stream. + */ + async generateCommitMessage( + workspaceId: string, + ctx: GitCommitContext, + ): Promise { + const install = this.getInstall(); + if (!install.installed) { + return { ok: false, reason: 'agent-unavailable', error: install.error }; + } + if (this.state.lifecycle === 'rate-limited') { + return { + ok: false, + reason: 'rate-limited', + error: this.state.rateLimit?.message ?? 'The agent is rate limited right now.', + }; + } + if (this.commitGenRuns.has(workspaceId)) { + return { ok: false, reason: 'busy', error: 'A commit message is already being generated.' }; + } + + const requestId = newId(); + const abort = new AbortController(); + const run: { abort: AbortController; query: { close?: () => void } | null } = { + abort, + query: null, + }; + this.commitGenRuns.set(workspaceId, run); + + const emit = (ev: Omit): void => { + const payload: GitCommitMessageStreamEvent = { workspaceId, requestId, ...ev }; + this.broadcastChannel(IpcEvents.gitCommitMessageStream, payload); + }; + + // Coalesced-delta state, mirroring runOnce's DELTA_FLUSH_* pattern. + let pendingDelta = ''; + let flushTimer: NodeJS.Timeout | null = null; + const flushDelta = (): void => { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (pendingDelta.length === 0) return; + const text = pendingDelta; + pendingDelta = ''; + emit({ kind: 'delta', text }); + }; + const queueDelta = (text: string): void => { + pendingDelta += text; + if (pendingDelta.length >= DELTA_FLUSH_CHARS) flushDelta(); + else if (!flushTimer) flushTimer = setTimeout(flushDelta, DELTA_FLUSH_MS); + }; + + try { + const prompt = buildCommitPrompt(ctx); + if (prompt.length > AGENT_LIMITS.promptMax) { + // Belt + braces: GitManager's commitGen caps keep us far below this. + throw new Error('Commit context too large.'); + } + const sdk = await loadSdk(); + const options = this.buildUtilityOptions(ctx.root, abort); + const q = sdk.query({ prompt, options }) as unknown as AsyncIterable & { + close?: () => void; + }; + run.query = q; + + let finalText = ''; + let resultOk: boolean | null = null; + let resultText = ''; + for await (const msg of q) { + if (abort.signal.aborted) break; + switch (msg.type) { + case 'stream_event': { + const ev = msg.event as unknown as { + type?: string; + delta?: { type?: string; text?: string }; + }; + if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) { + queueDelta(ev.delta.text); + } + break; + } + case 'assistant': { + if (msg.error) throw new Error(String(msg.error)); + const content = (msg.message?.content ?? []) as unknown as Array>; + const text = content + .filter((b) => b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join(''); + if (text.trim().length > 0) finalText = text; + break; + } + case 'result': { + resultOk = msg.subtype === 'success'; + resultText = 'result' in msg && typeof msg.result === 'string' ? msg.result : ''; + break; + } + default: + break; + } + } + flushDelta(); + + if (abort.signal.aborted) { + emit({ kind: 'canceled' }); + return { ok: false, reason: 'canceled' }; + } + if (resultOk === false) { + throw new Error(resultText || 'The commit-message run ended with errors.'); + } + const message = polishCommitMessage(finalText || resultText); + if (!message) throw new Error('The model returned an empty commit message.'); + emit({ kind: 'done', text: message }); + return { ok: true, message }; + } catch (err) { + if (abort.signal.aborted) { + emit({ kind: 'canceled' }); + return { ok: false, reason: 'canceled' }; + } + const raw = err instanceof Error ? err.message : String(err); + const safe = redact(raw); + logger.warn('[claude:commit-msg] generation failed', safe); + const cls = classifyAgentError(raw); + const reason: GenerateCommitMessageResult['reason'] = + cls.outcome === 'auth-required' + ? 'agent-unavailable' + : cls.outcome === 'rate-limited' + ? 'rate-limited' + : 'error'; + emit({ kind: 'error', error: safe }); + return { ok: false, reason, error: safe }; + } finally { + if (flushTimer) clearTimeout(flushTimer); + this.commitGenRuns.delete(workspaceId); + } + } + + /** Abort an in-flight commit-message generation for a workspace. */ + cancelCommitMessage(workspaceId: string): void { + const run = this.commitGenRuns.get(workspaceId); + if (!run) return; + run.abort.abort(); + try { + run.query?.close?.(); + } catch { + /* already closed */ + } + } + + /** + * Options for utility one-shots — deliberately parallel to (not shared with) + * {@link buildOptions}; the divergence list is the point: tool-less + * (`allowedTools: []` AND a deny-all canUseTool, belt + braces), single-turn, + * no thinking, plain-string system prompt (no claude_code preset, no + * memory/search append), no settings sources, no resume, no MCP servers. + * The model can only emit text. + */ + private buildUtilityOptions(cwd: string, abort: AbortController): Options { + const options: Options = { + cwd, + model: COMMIT_MESSAGE_MODEL, + maxTurns: 1, + includePartialMessages: true, + abortController: abort, + thinking: { type: 'disabled' }, + systemPrompt: COMMIT_SYSTEM_PROMPT, + allowedTools: [], + canUseTool: async () => ({ + behavior: 'deny' as const, + message: 'Tools are disabled for commit-message generation.', + }), + settingSources: [], + env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '0' }, + stderr: (data: string) => logger.warn('[claude:commit-msg]', redact(data)), + }; + const claudeExe = resolveClaudeExecutable(); + if (claudeExe) options.pathToClaudeCodeExecutable = claudeExe; + return options; + } + /** * Run a prompt for a session. Streams the agent's work as structured events, * with transparent recovery on transient failures. A failed *request* never @@ -2241,6 +2464,49 @@ export class AgentManager { /* Pure helpers */ /* ------------------------------------------------------------------ */ +/** Render the main-side git context into the sub-agent's user prompt. */ +function buildCommitPrompt(ctx: GitCommitContext): string { + const parts: string[] = []; + if (ctx.branch) parts.push(`Branch: ${ctx.branch}`); + + const fileLines = ctx.files.map((f) => { + const counts = + f.binary + ? ' (binary)' + : typeof f.adds === 'number' || typeof f.dels === 'number' + ? ` (+${f.adds ?? 0} -${f.dels ?? 0})` + : ''; + return `- ${f.status}: ${f.path}${counts}`; + }); + parts.push(`Staged files (${ctx.files.length}):\n${fileLines.join('\n')}`); + + if (ctx.recentSubjects.length > 0) { + parts.push(`Recent commit subjects (newest first):\n${ctx.recentSubjects.map((s) => `- ${s}`).join('\n')}`); + } else { + parts.push("This is the repository's first commit."); + } + + if (ctx.diff.trim().length > 0) { + parts.push( + `Staged diff${ctx.diffTruncated ? ' (truncated)' : ''}:\n\`\`\`diff\n${ctx.diff}\n\`\`\``, + ); + } + + parts.push('Write the commit message for these staged changes.'); + return parts.join('\n\n'); +} + +/** Normalize the model's output into a clean, size-capped commit message. */ +function polishCommitMessage(raw: string): string { + let text = (raw ?? '').trim(); + // Strip a single wrapping code fence the model may have added despite orders. + const fence = /^```[a-z]*\n([\s\S]*?)\n?```$/i.exec(text); + if (fence) text = fence[1].trim(); + // Strip one pair of wrapping quotes. + if (/^"[\s\S]*"$/.test(text) || /^'[\s\S]*'$/.test(text)) text = text.slice(1, -1).trim(); + return text.slice(0, GIT_LIMITS.commitGen.messageMax); +} + function mapThinking(thinking: 'off' | 'on' | 'adaptive'): Options['thinking'] { if (thinking === 'off') return { type: 'disabled' }; if (thinking === 'on') return { type: 'enabled', budgetTokens: 10_000 }; diff --git a/src/main/managers/GitManager.ts b/src/main/managers/GitManager.ts index 3680289..bf6bdb3 100644 --- a/src/main/managers/GitManager.ts +++ b/src/main/managers/GitManager.ts @@ -28,6 +28,7 @@ import type { GitCheckoutResult, GitCheckpoint, GitCommit, + GitCommitContext, GitCommitDetail, GitFileChange, GitFileDiff, @@ -281,6 +282,71 @@ export class GitManager { return commit; } + /** + * Assemble the size-capped, redacted context the AI commit-message sub-agent + * prompts with. Read-only: entirely `runGit` output, nothing renderer-supplied + * beyond the workspace id, and no `notifyChanged`. Returns null for non-repos; + * a repo with nothing staged returns `files: []` (the handler maps that to a + * `no-staged` result before any model run starts). + */ + async buildCommitContext(workspaceId: string): Promise { + const root = await this.resolveRoot(workspaceId); + if (!root) return null; + const caps = GIT_LIMITS.commitGen; + + const status = await this.status(workspaceId); + const stagedFiles = status.files.filter((f) => f.staged).slice(0, caps.filesMax); + if (stagedFiles.length === 0) { + return { root, branch: status.branch, files: [], diff: '', diffTruncated: false, recentSubjects: [] }; + } + + // Binary detection: numstat prints `-` for binary files, which parseNumstat + // skips — a staged file with zero adds+dels and a non-empty diff is binary + // for our purposes; re-check via `--numstat` directly for accuracy. + const numstat = await runGit(root, ['diff', '--cached', '--numstat', '-z']); + const binaryPaths = new Set(); + for (const entry of numstat.stdout.split('\0')) { + const m = /^-\t-\t(.+)$/.exec(entry); + if (m) binaryPaths.add(m[1]); + } + + const diffRes = await runGit(root, ['diff', '--cached', '--no-color', '--no-ext-diff'], { + maxBuffer: GIT_LIMITS.diffBytesMax + 1024, + }); + const rawDiff = diffRes.stdout; + const diffTruncated = rawDiff.length > caps.diffCharsMax; + const diff = redactRemote(diffTruncated ? rawDiff.slice(0, caps.diffCharsMax) : rawDiff); + + // Recent subjects for style inference (unborn repo → empty list). + const subjectsRes = await runGit(root, [ + 'log', + '-n', + String(caps.subjectsMax), + '--pretty=format:%s', + ]); + const recentSubjects = subjectsRes.ok + ? subjectsRes.stdout + .split('\n') + .map((s) => redactRemote(s).trim()) + .filter(Boolean) + : []; + + return { + root, + branch: status.branch, + files: stagedFiles.map((f) => ({ + path: f.path, + status: f.status, + adds: f.adds, + dels: f.dels, + binary: binaryPaths.has(f.path) || undefined, + })), + diff, + diffTruncated, + recentSubjects, + }; + } + /** `-c user.name/email` overrides from settings (blank = inherit git config). */ private identityArgs(): string[] { const g = this.settings.getAll().git; @@ -748,7 +814,7 @@ function classifyPullError(out: string): Partial { } /** Strip any embedded credentials from a remote URL before it reaches the UI/log. */ -function redactRemote(text: string): string { +export function redactRemote(text: string): string { if (typeof text !== 'string') return ''; return text.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1'); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a21948..5c07cd3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,12 +25,14 @@ import type { FileHistoryEntry, FileReadResult, FileTree, + GenerateCommitMessageResult, GitBlameLine, GitBranch, GitCheckoutResult, GitCheckpoint, GitCommit, GitCommitDetail, + GitCommitMessageStreamEvent, GitFileChange, GitFileDiff, GitPullResult, @@ -365,6 +367,10 @@ const gitApi = { ipcRenderer.invoke(IpcChannels.gitDiscard, workspaceId, path), commit: (workspaceId: string, message: string): Promise => ipcRenderer.invoke(IpcChannels.gitCommit, workspaceId, message), + generateCommitMessage: (workspaceId: string): Promise => + ipcRenderer.invoke(IpcChannels.gitCommitMessageGenerate, workspaceId), + cancelCommitMessage: (workspaceId: string): Promise => + ipcRenderer.invoke(IpcChannels.gitCommitMessageCancel, workspaceId), log: (workspaceId: string, opts?: { limit?: number; offset?: number }): Promise => ipcRenderer.invoke(IpcChannels.gitLog, workspaceId, opts), commitDetail: (workspaceId: string, hash: string): Promise => @@ -418,6 +424,8 @@ const gitApi = { subscribe<{ workspaceId: string }>(IpcEvents.gitChanged, cb), onCheckpointsChanged: (cb: (payload: { sessionId: string }) => void): (() => void) => subscribe<{ sessionId: string }>(IpcEvents.gitCheckpointsChanged, cb), + onCommitMessageStream: (cb: (ev: GitCommitMessageStreamEvent) => void): (() => void) => + subscribe(IpcEvents.gitCommitMessageStream, cb), }; const memoryApi = { diff --git a/src/renderer/features/git/GitPanel.tsx b/src/renderer/features/git/GitPanel.tsx index 6ef92e8..2668b5f 100644 --- a/src/renderer/features/git/GitPanel.tsx +++ b/src/renderer/features/git/GitPanel.tsx @@ -16,14 +16,18 @@ import { GitBranch, History, ListChecks, + Minus, + Plus, RefreshCw, Save, + Sparkles, UploadCloud, X, } from 'lucide-react'; import type { GitFileChange } from '@shared/types'; import { EmptyState, IconButton, Spinner } from '@/renderer/components/ui'; import { cn } from '@/renderer/lib/cn'; +import { useAgentStore } from '@/renderer/stores/useAgentStore'; import { useGitStore, type GitView } from '@/renderer/stores/useGitStore'; import { useLayoutStore } from '@/renderer/stores/useLayoutStore'; import { useSettingsStore } from '@/renderer/stores/useSettingsStore'; @@ -301,14 +305,20 @@ function ChangesView() { const stageAll = useGitStore((s) => s.stageAll); const unstageAll = useGitStore((s) => s.unstageAll); const commit = useGitStore((s) => s.commit); + const message = useGitStore((s) => s.commitMessage); + const setMessage = useGitStore((s) => s.setCommitMessage); + const generating = useGitStore((s) => s.generatingMessage); + const generateMessage = useGitStore((s) => s.generateCommitMessage); + const cancelGenerate = useGitStore((s) => s.cancelCommitMessage); + const agentReady = useAgentStore((s) => s.install.installed); const template = useSettingsStore((s) => s.settings.git.commitMessageTemplate); const addToast = useUIStore((s) => s.addToast); const [expanded, setExpanded] = useState>(new Set()); - const [message, setMessage] = useState(''); const [committing, setCommitting] = useState(false); useEffect(() => { - setMessage((m) => (m === '' && template ? template : m)); + const { commitMessage, setCommitMessage } = useGitStore.getState(); + if (commitMessage === '' && template) setCommitMessage(template); }, [template]); const { staged, unstaged } = useMemo(() => splitFiles(status?.files ?? []), [status?.files]); @@ -322,7 +332,7 @@ function ChangesView() { const doCommit = async () => { const trimmed = message.trim(); - if (!trimmed) return; + if (!trimmed || generating) return; setCommitting(true); try { const ok = await commit(trimmed); @@ -414,22 +424,73 @@ function ChangesView() {