From 81302396f9b2a8592378c706aae1b9e5d430e9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 11:22:13 +0200 Subject: [PATCH 1/4] Add the ability to create AI generated commit messages --- src/main/accounts/cipher.ts | 6 +- src/main/ai/cipher.ts | 17 ++ src/main/ai/client.ts | 139 +++++++++ src/main/ai/commit-context.ts | 81 +++++ src/main/ai/commit-prompt.test.ts | 92 ++++++ src/main/ai/commit-prompt.ts | 114 +++++++ src/main/ai/providers.test.ts | 167 +++++++++++ src/main/ai/providers.ts | 251 ++++++++++++++++ src/main/ai/store.test.ts | 134 +++++++++ src/main/ai/store.ts | 153 ++++++++++ src/main/ipc/ai.ts | 104 +++++++ src/main/ipc/index.ts | 2 + src/preload/index.ts | 18 ++ src/renderer/src/App.tsx | 2 + src/renderer/src/components/app/AppModals.tsx | 3 +- .../components/changes/AiComposerControls.tsx | 268 +++++++++++++++++ .../src/components/changes/ChangesView.tsx | 23 +- .../src/components/changes/CommitComposer.tsx | 45 ++- .../src/components/settings/AiPane.tsx | 278 ++++++++++++++++++ .../components/settings/SettingsDialog.tsx | 23 +- src/renderer/src/lib/ai.test.ts | 43 +++ src/renderer/src/lib/ai.ts | 145 +++++++++ src/renderer/src/lib/icons.tsx | 16 +- src/renderer/src/styles/features/ai.css | 203 +++++++++++++ src/renderer/src/styles/features/changes.css | 22 ++ src/renderer/src/styles/global.css | 1 + src/shared/ipc.ts | 42 +++ src/shared/types.ts | 92 ++++++ 28 files changed, 2472 insertions(+), 12 deletions(-) create mode 100644 src/main/ai/cipher.ts create mode 100644 src/main/ai/client.ts create mode 100644 src/main/ai/commit-context.ts create mode 100644 src/main/ai/commit-prompt.test.ts create mode 100644 src/main/ai/commit-prompt.ts create mode 100644 src/main/ai/providers.test.ts create mode 100644 src/main/ai/providers.ts create mode 100644 src/main/ai/store.test.ts create mode 100644 src/main/ai/store.ts create mode 100644 src/main/ipc/ai.ts create mode 100644 src/renderer/src/components/changes/AiComposerControls.tsx create mode 100644 src/renderer/src/components/settings/AiPane.tsx create mode 100644 src/renderer/src/lib/ai.test.ts create mode 100644 src/renderer/src/lib/ai.ts create mode 100644 src/renderer/src/styles/features/ai.css diff --git a/src/main/accounts/cipher.ts b/src/main/accounts/cipher.ts index 81c2ec5..e28ebba 100644 --- a/src/main/accounts/cipher.ts +++ b/src/main/accounts/cipher.ts @@ -6,7 +6,11 @@ import { join } from 'node:path' import { app, safeStorage } from 'electron' import { type AccountCipher, AccountsStore } from './store' -function systemCipher(): AccountCipher { +/** + * The OS-vault cipher, shared with every store that keeps a secret at rest + * (accounts here, the AI backend key in main/ai/store.ts). + */ +export function systemCipher(): AccountCipher { return { available() { // safeStorage is only meaningful after app ready; callers run later. diff --git a/src/main/ai/cipher.ts b/src/main/ai/cipher.ts new file mode 100644 index 0000000..c89db8f --- /dev/null +++ b/src/main/ai/cipher.ts @@ -0,0 +1,17 @@ +// The app-wide AI store, wired to userData + the OS-vault cipher — the same +// safeStorage discipline as the accounts store (see accounts/cipher.ts). + +import { join } from 'node:path' +import { app } from 'electron' +import { systemCipher } from '../accounts/cipher' +import { AiStore } from './store' + +let shared: AiStore | null = null + +/** The app-wide AI backend store, lazily wired to userData + safeStorage. */ +export function aiStore(): AiStore { + if (!shared) { + shared = new AiStore(join(app.getPath('userData'), 'ai.json'), systemCipher()) + } + return shared +} diff --git a/src/main/ai/client.ts b/src/main/ai/client.ts new file mode 100644 index 0000000..b798e70 --- /dev/null +++ b/src/main/ai/client.ts @@ -0,0 +1,139 @@ +// The one place AI bytes cross the network: a fetch of a WireRequest plus an +// SSE reader. Everything about *what* is sent and *how* responses are read is +// pure and lives in providers.ts; this module only does I/O. Keys never leave +// the main process and are never logged. + +import type { AiErrorCode } from '@shared/types' +import { + type AiEndpoint, + buildChatRequest, + buildModelsRequest, + type ChatMessage, + extractStreamText, + parseModelList, + pickDefaultModel, + type WireRequest +} from './providers' + +/** A failure with a stable code the renderer maps to calm copy. */ +export class AiRequestError extends Error { + constructor( + readonly code: AiErrorCode, + message: string + ) { + super(message) + } +} + +/** How long a connect-time verification may take before failing as network. */ +const VERIFY_TIMEOUT_MS = 15_000 +/** Ceiling on one generation — a stuck stream must not spin forever. */ +const GENERATION_TIMEOUT_MS = 90_000 + +function classifyHttp(status: number): AiRequestError { + if (status === 401 || status === 403) + return new AiRequestError('unauthorized', 'The endpoint rejected the API key.') + if (status === 404) + return new AiRequestError('bad-endpoint', 'No compatible API at that address.') + return new AiRequestError('provider-error', `The endpoint answered with HTTP ${status}.`) +} + +async function send(request: WireRequest, signal: AbortSignal): Promise { + let response: Response + try { + response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + signal + }) + } catch (e) { + if (signal.aborted) throw new AiRequestError('cancelled', 'Cancelled.') + throw new AiRequestError( + 'network', + `Could not reach the endpoint${e instanceof Error && e.message ? ` (${e.message})` : ''}.` + ) + } + if (!response.ok) throw classifyHttp(response.status) + return response +} + +/** + * Verify an endpoint live and list its models — the connect-time handshake. + * Succeeding here is what "connected" means; nothing is saved before it. + */ +export async function verifyEndpoint( + endpoint: Omit +): Promise<{ models: string[]; defaultModel: string }> { + const response = await send(buildModelsRequest(endpoint), AbortSignal.timeout(VERIFY_TIMEOUT_MS)) + let payload: unknown + try { + payload = await response.json() + } catch { + throw new AiRequestError('bad-endpoint', 'That address did not answer with a model list.') + } + const models = parseModelList(endpoint.provider, payload) + if (models.length === 0) + throw new AiRequestError('bad-endpoint', 'The endpoint lists no usable models.') + return { models, defaultModel: pickDefaultModel(endpoint.provider, models) } +} + +/** + * Run one streaming chat completion. `onText` fires per streamed piece; + * resolves with the full text. Cancellation (the caller's signal) resolves + * with what was generated so far — a half-written suggestion is still a + * suggestion, and the composer keeps it editable. + */ +export async function streamChat( + endpoint: AiEndpoint, + messages: ChatMessage[], + opts: { signal: AbortSignal; onText: (text: string) => void } +): Promise { + const signal = AbortSignal.any([opts.signal, AbortSignal.timeout(GENERATION_TIMEOUT_MS)]) + const response = await send(buildChatRequest(endpoint, messages), signal) + if (!response.body) throw new AiRequestError('provider-error', 'The endpoint sent no stream.') + + // SSE: decode chunks, split into lines, read `data:` payloads. A JSON line + // that doesn't parse is skipped — proxies occasionally interleave comments. + const decoder = new TextDecoder() + let buffer = '' + let full = '' + const takeLine = (line: string) => { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) return + const data = trimmed.slice(5).trim() + if (!data || data === '[DONE]') return + try { + const text = extractStreamText(endpoint.provider, JSON.parse(data)) + if (text) { + full += text + opts.onText(text) + } + } catch { + // Not JSON — an SSE comment or keep-alive; skip. + } + } + + const reader = response.body.getReader() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let newline = buffer.indexOf('\n') + while (newline >= 0) { + takeLine(buffer.slice(0, newline)) + buffer = buffer.slice(newline + 1) + newline = buffer.indexOf('\n') + } + } + takeLine(buffer) + } catch (e) { + // A user cancel mid-stream keeps the partial text (see the contract above); + // the timeout or a dropped connection is a real failure. + if (opts.signal.aborted) return full.trim() + if (e instanceof AiRequestError) throw e + throw new AiRequestError('network', 'The stream ended unexpectedly.') + } + return full.trim() +} diff --git a/src/main/ai/commit-context.ts b/src/main/ai/commit-context.ts new file mode 100644 index 0000000..612173c --- /dev/null +++ b/src/main/ai/commit-context.ts @@ -0,0 +1,81 @@ +// Gathers what the commit-message prompt needs from the repository — the +// "RAG" is git itself. Runs entirely on the lock-free read side (getWorkingDiff +// / getLog), so a generation never blocks or queues behind a write. All output +// is size-capped (see DIFF_CAPS): a 90k-file selection must produce a bounded +// prompt, not an unbounded one. + +import type { AiCommitRequest, ChangedFile } from '@shared/types' +import { getLog, getWorkingDiff } from '../git/read' +import { capPatch, type CommitPromptInput, DIFF_CAPS } from './commit-prompt' + +/** `status\tpath` per file — cheap orientation even for files whose diff + * didn't fit the budget (binary, huge, or beyond maxFiles). */ +export function summarizeFiles(files: ChangedFile[]): string { + return files + .map((f) => { + const name = f.oldPath ? `${f.oldPath} → ${f.path}` : f.path + const counts = + f.insertions !== undefined || f.deletions !== undefined + ? ` (+${f.insertions ?? 0} −${f.deletions ?? 0})` + : '' + return `${f.status}\t${name}${counts}` + }) + .join('\n') +} + +export async function gatherCommitContext( + repoPath: string, + request: AiCommitRequest +): Promise { + const textFiles = request.files.filter((f) => !f.binary && !f.submodule) + + // Hunk patches of partially included files first: they're already exact + // (they ARE what will be committed), and they cost no git call. + const pieces: string[] = [] + let budget = DIFF_CAPS.totalBytes + const take = (patch: string) => { + if (budget <= 0 || !patch) return + const capped = capPatch(patch, Math.min(DIFF_CAPS.perFileBytes, budget)) + budget -= Buffer.byteLength(capped, 'utf8') + pieces.push(capped) + } + for (const patch of request.patches) take(patch) + + // Fully included files: read their HEAD → working-tree diffs, a bounded + // number of them, until the budget runs out. The remainder still appears in + // the file summary, so the model knows the commit is bigger than its diffs. + for (const file of textFiles.slice(0, DIFF_CAPS.maxFiles)) { + if (budget <= 0) break + try { + const payload = await getWorkingDiff(repoPath, file, 'all') + if (!payload.binary && !payload.lfs && !payload.submodule) take(payload.patch) + } catch { + // A file that can't be diffed (racing deletion, permissions) simply + // contributes no diff — the summary line still names it. + } + } + + // The repo's own subjects are the style guide (skip while amending HEAD's + // message would echo itself into the examples — it's passed separately). + let recentSubjects: string[] = [] + let previousMessage: string | undefined + try { + const log = await getLog(repoPath, { limit: DIFF_CAPS.recentSubjects }) + recentSubjects = log.map((c) => c.subject) + if (request.mode === 'amend' && log[0]) { + previousMessage = log[0].body ? `${log[0].subject}\n\n${log[0].body}` : log[0].subject + recentSubjects = recentSubjects.slice(1) + } + } catch { + // An unborn HEAD has no history — the prompt just carries no examples. + } + + return { + mode: request.mode, + options: request.options, + fileSummary: summarizeFiles(request.files) || '(no files — message-only amend)', + diffs: pieces.join('\n'), + recentSubjects, + previousMessage + } +} diff --git a/src/main/ai/commit-prompt.test.ts b/src/main/ai/commit-prompt.test.ts new file mode 100644 index 0000000..e7f40be --- /dev/null +++ b/src/main/ai/commit-prompt.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import type { AiCommitOptions } from '@shared/types' +import { buildCommitPrompt, capPatch, type CommitPromptInput } from './commit-prompt' + +const options = (over: Partial = {}): AiCommitOptions => ({ + length: 'medium', + tone: 'technical', + emojis: false, + ...over +}) + +const input = (over: Partial = {}): CommitPromptInput => ({ + mode: 'commit', + options: options(), + fileSummary: 'modified\tsrc/app.ts (+3 −1)', + diffs: 'diff --git a/src/app.ts b/src/app.ts\n+added line\n-removed line\n', + recentSubjects: ['feat: add graph tab', 'fix: stash panel overflow'], + ...over +}) + +const system = (i: CommitPromptInput) => buildCommitPrompt(i)[0].content +const user = (i: CommitPromptInput) => buildCommitPrompt(i)[1].content + +describe('buildCommitPrompt', () => { + test('one system + one user message, diff and files in the user half', () => { + const messages = buildCommitPrompt(input()) + expect(messages).toHaveLength(2) + expect(messages[0].role).toBe('system') + expect(messages[1].role).toBe('user') + expect(messages[1].content).toContain('modified\tsrc/app.ts') + expect(messages[1].content).toContain('+added line') + }) + + test('the repo’s recent subjects ride along as the style guide', () => { + expect(system(input())).toContain('feat: add graph tab') + expect(system(input({ recentSubjects: [] }))).not.toContain('recent commit subjects') + }) + + test('length maps to distinct rules', () => { + expect(system(input({ options: options({ length: 'short' }) }))).toContain('No body') + expect(system(input({ options: options({ length: 'long' }) }))).toContain('thorough body') + }) + + test('every tone has copy', () => { + for (const tone of ['technical', 'formal', 'informal', 'friendly'] as const) { + expect(system(input({ options: options({ tone }) }))).toContain('Tone:') + } + }) + + test('emojis are explicitly on or off — never unspecified', () => { + expect(system(input({ options: options({ emojis: true }) }))).toContain('emoji at the start') + expect(system(input())).toContain('Do not use emojis') + }) + + test('amend folds the previous message in', () => { + const amend = input({ mode: 'amend', previousMessage: 'fix: old subject\n\nold body' }) + expect(user(amend)).toContain('fix: old subject') + expect(user(amend)).toContain('amending') + // A plain commit never mentions amending. + expect(user(input())).not.toContain('amending') + }) + + test('stash asks for one short label instead of a commit message', () => { + const stash = system(input({ mode: 'stash' })) + expect(stash).toContain('stash') + expect(stash).not.toContain('imperative mood') + }) +}) + +describe('capPatch', () => { + test('short patches pass through untouched', () => { + expect(capPatch('short\n', 1024)).toBe('short\n') + }) + + test('long patches are cut at a line boundary and marked', () => { + const patch = `${'a'.repeat(50)}\n${'b'.repeat(50)}\n${'c'.repeat(50)}\n` + const capped = capPatch(patch, 110) + expect(capped.endsWith('[… diff truncated …]\n')).toBe(true) + expect(capped).toContain('a'.repeat(50)) + expect(capped).not.toContain('c'.repeat(50)) + // Never cuts mid-line: every kept line is intact. + const partial = capped.split('\n').some((l) => /^[abc]+$/.test(l) && l.length < 50) + expect(partial).toBe(false) + }) + + test('multi-byte characters never come out mangled', () => { + const patch = `${'é'.repeat(100)}\nnext\n` + const capped = capPatch(patch, 51) + expect(capped).not.toContain('�') + expect(capped.endsWith('[… diff truncated …]\n')).toBe(true) + }) +}) diff --git a/src/main/ai/commit-prompt.ts b/src/main/ai/commit-prompt.ts new file mode 100644 index 0000000..5531979 --- /dev/null +++ b/src/main/ai/commit-prompt.ts @@ -0,0 +1,114 @@ +// Commit/stash-message prompt assembly — pure functions over already-gathered +// context (commit-context.ts does the git reads), so what the model is asked +// is unit-testable byte for byte. The style contract: the repo's own recent +// subjects teach the convention (conventional commits, ticket prefixes, mood) +// so there is no "commit style" setting to configure — the repo is the config. + +import type { AiCommitOptions } from '@shared/types' +import type { ChatMessage } from './providers' + +/** Everything the prompt needs, gathered by commit-context.ts. */ +export interface CommitPromptInput { + mode: 'commit' | 'amend' | 'stash' + options: AiCommitOptions + /** One line per selected file: `status\tpath` (renames `status\told → new`). */ + fileSummary: string + /** Concatenated (size-capped) unified diffs of the selection. */ + diffs: string + /** Recent commit subjects, newest first — the repo's message style. */ + recentSubjects: string[] + /** HEAD's full message, for amend (the message being replaced). */ + previousMessage?: string +} + +/** Caps applied while gathering diffs, shared with commit-context.ts. */ +export const DIFF_CAPS = { + /** Per-file patch budget — enough to understand a change, not paste it. */ + perFileBytes: 16 * 1024, + /** Whole-prompt diff budget. */ + totalBytes: 96 * 1024, + /** Files diffed individually; beyond this they're listed by name only. */ + maxFiles: 60, + /** Style examples included from the repo's history. */ + recentSubjects: 30 +} as const + +/** + * Trim one patch to `maxBytes`, cutting at a line boundary and marking the + * cut — a silently truncated diff reads like a complete one and misleads. + */ +export function capPatch(patch: string, maxBytes: number): string { + if (Buffer.byteLength(patch, 'utf8') <= maxBytes) return patch + let cut = Buffer.from(patch, 'utf8').subarray(0, maxBytes).toString('utf8') + // A byte cut can split a multi-byte character; drop the mangled tail line. + cut = cut.slice(0, cut.lastIndexOf('\n') + 1) + return `${cut}[… diff truncated …]\n` +} + +const LENGTH_RULES: Record = { + short: 'Write ONLY a subject line (at most 65 characters). No body.', + medium: + 'Write a subject line (at most 65 characters), then a blank line, then a body of 1–3 short sentences — only when the change genuinely needs explaining. Trivial changes get a subject only.', + long: 'Write a subject line (at most 65 characters), then a blank line, then a thorough body explaining what changed and why. Use short paragraphs or "- " bullets.' +} + +const TONE_RULES: Record = { + technical: + 'Tone: technical and precise — name the actual functions, files and behaviours involved.', + formal: 'Tone: formal and neutral, suitable for an enterprise changelog.', + informal: 'Tone: relaxed and plain-spoken, like a quick note to a teammate.', + friendly: 'Tone: warm and approachable, still professional.' +} + +/** The system + user messages for one generation. */ +export function buildCommitPrompt(input: CommitPromptInput): ChatMessage[] { + const { mode, options } = input + + const rules: string[] = [] + if (mode === 'stash') { + rules.push( + 'You name work-in-progress stashes in a git client.', + 'Write ONLY one short label (at most 60 characters) describing the state of this unfinished work, e.g. "wip: half-migrated toolbar filters". No quotes, no trailing period.' + ) + } else { + rules.push( + 'You write git commit messages in a git client.', + LENGTH_RULES[options.length], + 'Use the imperative mood for the subject ("Add", "Fix", "Rename" — not "Added").', + 'Describe the change itself, never the act of committing. No quotes around the message, no markdown code fences.' + ) + } + rules.push(TONE_RULES[options.tone]) + rules.push( + options.emojis + ? 'Include one fitting emoji at the start of the subject line.' + : 'Do not use emojis.' + ) + if (input.recentSubjects.length > 0) { + rules.push( + 'Match the style of this repository’s recent commit subjects (prefixes, ticket ids, capitalization, language):', + input.recentSubjects.map((s) => ` ${s}`).join('\n') + ) + } + rules.push('Answer with the message text only — nothing before or after it.') + + const parts: string[] = [] + if (mode === 'amend' && input.previousMessage) { + parts.push( + 'The user is amending the last commit. Its current message is:', + input.previousMessage, + 'Write an updated message covering the original commit AND the additional changes below. Keep whatever from the original message is still accurate.' + ) + } else if (mode === 'stash') { + parts.push('Name a stash for these uncommitted changes.') + } else { + parts.push('Write the commit message for these staged changes.') + } + parts.push(`Files in this ${mode === 'stash' ? 'stash' : 'commit'}:\n${input.fileSummary}`) + if (input.diffs) parts.push(`Diffs:\n${input.diffs}`) + + return [ + { role: 'system', content: rules.join('\n\n') }, + { role: 'user', content: parts.join('\n\n') } + ] +} diff --git a/src/main/ai/providers.test.ts b/src/main/ai/providers.test.ts new file mode 100644 index 0000000..cc80f9d --- /dev/null +++ b/src/main/ai/providers.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'bun:test' +import { + type AiEndpoint, + buildChatRequest, + buildModelsRequest, + extractStreamText, + parseModelList, + pickDefaultModel, + resolveBaseUrl +} from './providers' + +const endpoint = (over: Partial = {}): AiEndpoint => ({ + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-test', + apiKey: 'sk-secret', + ...over +}) + +const messages = [ + { role: 'system' as const, content: 'You write commit messages.' }, + { role: 'user' as const, content: 'Diff here.' } +] + +describe('resolveBaseUrl', () => { + test('defaults from the provider when the input is empty', () => { + expect(resolveBaseUrl('openai')).toBe('https://api.openai.com/v1') + expect(resolveBaseUrl('ollama', '')).toBe('http://localhost:11434/v1') + }) + + test('normalizes user input (trim + trailing slashes)', () => { + expect(resolveBaseUrl('litellm', ' https://llm.corp/v1// ')).toBe('https://llm.corp/v1') + }) + + test('null when a required URL is missing', () => { + expect(resolveBaseUrl('litellm')).toBeNull() + expect(resolveBaseUrl('custom', ' ')).toBeNull() + }) +}) + +describe('buildChatRequest', () => { + test('openai-compatible: bearer auth, system message in-band, streaming on', () => { + const req = buildChatRequest(endpoint(), messages) + expect(req.url).toBe('https://api.openai.com/v1/chat/completions') + expect(req.headers.Authorization).toBe('Bearer sk-secret') + const body = JSON.parse(req.body ?? '') + expect(body.model).toBe('gpt-test') + expect(body.stream).toBe(true) + expect(body.messages).toEqual(messages) + }) + + test('keyless endpoints send no auth header at all', () => { + const req = buildChatRequest(endpoint({ provider: 'ollama', apiKey: null }), messages) + expect(req.headers.Authorization).toBeUndefined() + }) + + test('anthropic: x-api-key + version headers, system extracted', () => { + const req = buildChatRequest( + endpoint({ provider: 'anthropic', baseUrl: 'https://api.anthropic.com/v1' }), + messages + ) + expect(req.url).toBe('https://api.anthropic.com/v1/messages') + expect(req.headers['x-api-key']).toBe('sk-secret') + expect(req.headers['anthropic-version']).toBe('2023-06-01') + const body = JSON.parse(req.body ?? '') + expect(body.system).toBe('You write commit messages.') + expect(body.messages).toEqual([{ role: 'user', content: 'Diff here.' }]) + expect(body.stream).toBe(true) + }) + + test('gemini: model in the URL, SSE alt, systemInstruction part', () => { + const req = buildChatRequest( + endpoint({ + provider: 'gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + model: 'gemini-2.5-flash' + }), + messages + ) + expect(req.url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse' + ) + expect(req.headers['x-goog-api-key']).toBe('sk-secret') + const body = JSON.parse(req.body ?? '') + expect(body.systemInstruction.parts[0].text).toBe('You write commit messages.') + expect(body.contents[0].parts[0].text).toBe('Diff here.') + }) +}) + +describe('buildModelsRequest', () => { + test('openai-compatible and anthropic hit /models', () => { + expect(buildModelsRequest(endpoint()).url).toBe('https://api.openai.com/v1/models') + }) + + test('gemini pages wide so small models are not cut off', () => { + const req = buildModelsRequest( + endpoint({ provider: 'gemini', baseUrl: 'https://g/v1beta' }) + ) + expect(req.url).toBe('https://g/v1beta/models?pageSize=200') + }) +}) + +describe('parseModelList', () => { + test('openai/anthropic: data[].id', () => { + const payload = { data: [{ id: 'gpt-a' }, { id: 'gpt-b' }, { broken: true }] } + expect(parseModelList('openai', payload)).toEqual(['gpt-a', 'gpt-b']) + expect(parseModelList('anthropic', payload)).toEqual(['gpt-a', 'gpt-b']) + }) + + test('gemini: models[].name stripped of the models/ prefix, chat-capable only', () => { + const payload = { + models: [ + { name: 'models/gemini-2.5-flash', supportedGenerationMethods: ['generateContent'] }, + { name: 'models/embedding-001', supportedGenerationMethods: ['embedContent'] }, + { name: 'models/gemini-x' } + ] + } + expect(parseModelList('gemini', payload)).toEqual(['gemini-2.5-flash', 'gemini-x']) + }) + + test('garbage payloads yield an empty list, never a throw', () => { + expect(parseModelList('openai', null)).toEqual([]) + expect(parseModelList('gemini', 'nope')).toEqual([]) + expect(parseModelList('openai', { data: 'nope' })).toEqual([]) + }) +}) + +describe('pickDefaultModel', () => { + test('prefers the provider’s fast models, exact or dated variant', () => { + expect(pickDefaultModel('openai', ['gpt-4.1', 'gpt-4o-mini', 'o9'])).toBe('gpt-4o-mini') + expect(pickDefaultModel('anthropic', ['claude-opus-4-8', 'claude-haiku-4-5-20251001'])).toBe( + 'claude-haiku-4-5-20251001' + ) + }) + + test('falls back to the first served model', () => { + expect(pickDefaultModel('litellm', ['corp-model', 'other'])).toBe('corp-model') + expect(pickDefaultModel('openai', [])).toBe('') + }) +}) + +describe('extractStreamText', () => { + test('openai-compatible deltas', () => { + expect( + extractStreamText('openai', { choices: [{ delta: { content: 'Fix ' } }] }) + ).toBe('Fix ') + expect(extractStreamText('openai', { choices: [{ delta: { role: 'assistant' } }] })).toBe('') + }) + + test('anthropic content_block_delta only', () => { + expect( + extractStreamText('anthropic', { type: 'content_block_delta', delta: { text: 'Fix ' } }) + ).toBe('Fix ') + expect(extractStreamText('anthropic', { type: 'message_start' })).toBe('') + }) + + test('gemini candidate parts, concatenated', () => { + const event = { candidates: [{ content: { parts: [{ text: 'Fix ' }, { text: 'bug' }] } }] } + expect(extractStreamText('gemini', event)).toBe('Fix bug') + }) + + test('malformed events contribute nothing, never a throw', () => { + expect(extractStreamText('openai', null)).toBe('') + expect(extractStreamText('anthropic', { type: 'content_block_delta' })).toBe('') + expect(extractStreamText('gemini', { candidates: [{}] })).toBe('') + }) +}) diff --git a/src/main/ai/providers.ts b/src/main/ai/providers.ts new file mode 100644 index 0000000..c57234e --- /dev/null +++ b/src/main/ai/providers.ts @@ -0,0 +1,251 @@ +// The AI provider registry and its wire formats — all pure functions, so the +// exact requests GitGrove sends (URLs, headers, bodies) and how it reads the +// responses are unit-testable without a network. The impure half (fetch + +// SSE reading) lives in client.ts; this module never touches I/O. +// +// Three dialects cover every supported backend: the OpenAI-compatible chat +// API (openai, litellm, ollama, custom — the de-facto standard every proxy +// speaks), Anthropic's Messages API, and Google's Gemini API. + +import type { AiProvider } from '@shared/types' + +/** Static per-provider knowledge, driving both main and the settings UI copy. */ +export interface AiProviderMeta { + label: string + /** Default endpoint; null when the user must supply one (litellm/custom). */ + defaultBaseUrl: string | null + /** Whether the endpoint expects an API key. Ollama runs keyless locally. */ + needsKey: 'required' | 'optional' | 'none' + /** Where to create a key, opened prefilled from the settings pane. */ + keyUrl: string | null + /** + * Models to prefer when picking a default from the endpoint's list, best + * first — small, fast models: commit messages are short and latency is UX. + */ + preferredModels: string[] +} + +export const AI_PROVIDERS: Record = { + openai: { + label: 'OpenAI', + defaultBaseUrl: 'https://api.openai.com/v1', + needsKey: 'required', + keyUrl: 'https://platform.openai.com/api-keys', + preferredModels: ['gpt-5-mini', 'gpt-4.1-mini', 'gpt-4o-mini'] + }, + anthropic: { + label: 'Anthropic', + defaultBaseUrl: 'https://api.anthropic.com/v1', + needsKey: 'required', + keyUrl: 'https://console.anthropic.com/settings/keys', + preferredModels: ['claude-haiku-4-5', 'claude-3-5-haiku-latest'] + }, + gemini: { + label: 'Google Gemini', + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta', + needsKey: 'required', + keyUrl: 'https://aistudio.google.com/apikey', + preferredModels: ['gemini-2.5-flash', 'gemini-2.0-flash'] + }, + litellm: { + label: 'LiteLLM', + defaultBaseUrl: null, + needsKey: 'optional', + keyUrl: null, + preferredModels: [] + }, + ollama: { + label: 'Ollama', + defaultBaseUrl: 'http://localhost:11434/v1', + needsKey: 'none', + keyUrl: null, + preferredModels: ['qwen3', 'llama3.2', 'mistral'] + }, + custom: { + label: 'Custom endpoint', + defaultBaseUrl: null, + needsKey: 'optional', + keyUrl: null, + preferredModels: [] + } +} + +/** Everything a request needs, resolved (base URL defaulted, key decrypted). */ +export interface AiEndpoint { + provider: AiProvider + /** Base URL without a trailing slash. */ + baseUrl: string + model: string + apiKey: string | null +} + +export interface ChatMessage { + role: 'system' | 'user' + content: string +} + +/** A ready-to-send HTTP request, as data (client.ts does the fetch). */ +export interface WireRequest { + url: string + method: 'GET' | 'POST' + headers: Record + body?: string +} + +/** + * The user's base URL input, normalized: trimmed, trailing slashes dropped, + * and defaulted from the provider when empty. Null when the provider has no + * default and the user gave nothing — the settings pane blocks that before + * ever calling main. + */ +export function resolveBaseUrl(provider: AiProvider, input?: string): string | null { + const trimmed = (input ?? '').trim().replace(/\/+$/, '') + return trimmed || AI_PROVIDERS[provider].defaultBaseUrl +} + +/** Auth headers per dialect; empty when the endpoint runs keyless. */ +function authHeaders(endpoint: Pick): Record { + if (!endpoint.apiKey) return {} + switch (endpoint.provider) { + case 'anthropic': + return { 'x-api-key': endpoint.apiKey, 'anthropic-version': '2023-06-01' } + case 'gemini': + return { 'x-goog-api-key': endpoint.apiKey } + default: + return { Authorization: `Bearer ${endpoint.apiKey}` } + } +} + +/** The streaming chat-completion request for one prompt. */ +export function buildChatRequest(endpoint: AiEndpoint, messages: ChatMessage[]): WireRequest { + const headers = { 'content-type': 'application/json', ...authHeaders(endpoint) } + const system = messages + .filter((m) => m.role === 'system') + .map((m) => m.content) + .join('\n\n') + const user = messages + .filter((m) => m.role === 'user') + .map((m) => m.content) + .join('\n\n') + + switch (endpoint.provider) { + case 'anthropic': + return { + url: `${endpoint.baseUrl}/messages`, + method: 'POST', + headers, + body: JSON.stringify({ + model: endpoint.model, + // Generous ceiling — Anthropic requires one; prompts ask for brevity. + max_tokens: 1024, + stream: true, + ...(system ? { system } : {}), + messages: [{ role: 'user', content: user }] + }) + } + case 'gemini': + return { + // `alt=sse` turns the chunked JSON stream into standard SSE events. + url: `${endpoint.baseUrl}/models/${endpoint.model}:streamGenerateContent?alt=sse`, + method: 'POST', + headers, + body: JSON.stringify({ + ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + contents: [{ role: 'user', parts: [{ text: user }] }] + }) + } + default: + // OpenAI-compatible. No token cap on purpose: newer OpenAI models and + // older proxies disagree on the cap's field name, and the prompt already + // constrains the length. + return { + url: `${endpoint.baseUrl}/chat/completions`, + method: 'POST', + headers, + body: JSON.stringify({ model: endpoint.model, stream: true, messages }) + } + } +} + +/** The "list models" request used to verify an endpoint at connect time. */ +export function buildModelsRequest(endpoint: Omit): WireRequest { + const headers = authHeaders(endpoint) + const url = + endpoint.provider === 'gemini' + ? `${endpoint.baseUrl}/models?pageSize=200` + : `${endpoint.baseUrl}/models` + return { url, method: 'GET', headers } +} + +/** Model ids out of a models-list response, newest-ish first as served. */ +export function parseModelList(provider: AiProvider, payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return [] + const obj = payload as Record + if (provider === 'gemini') { + const models = Array.isArray(obj.models) ? obj.models : [] + return models + .filter((m): m is Record => !!m && typeof m === 'object') + .filter((m) => { + const methods = m.supportedGenerationMethods + return !Array.isArray(methods) || methods.includes('generateContent') + }) + .map((m) => String(m.name ?? '').replace(/^models\//, '')) + .filter(Boolean) + } + // OpenAI-compatible and Anthropic both answer `{ data: [{ id }] }`. + const data = Array.isArray(obj.data) ? obj.data : [] + return data + .filter((m): m is Record => !!m && typeof m === 'object') + .map((m) => String(m.id ?? '')) + .filter(Boolean) +} + +/** + * The model a fresh connection starts on: the first preferred model the + * endpoint actually serves (exact id or a dated/versioned variant of it), + * else the endpoint's first model. Never empty when `models` isn't. + */ +export function pickDefaultModel(provider: AiProvider, models: string[]): string { + for (const preferred of AI_PROVIDERS[provider].preferredModels) { + const hit = models.find((m) => m === preferred || m.startsWith(`${preferred}-`)) + if (hit) return hit + } + return models[0] ?? '' +} + +/** + * The text a single SSE event contributes to the generation, or '' for + * bookkeeping events (role deltas, stop reasons, pings). `data` is the parsed + * JSON of one `data:` line. + */ +export function extractStreamText(provider: AiProvider, data: unknown): string { + if (!data || typeof data !== 'object') return '' + const obj = data as Record + if (provider === 'anthropic') { + if (obj.type !== 'content_block_delta') return '' + const delta = obj.delta as Record | undefined + return typeof delta?.text === 'string' ? delta.text : '' + } + if (provider === 'gemini') { + const candidates = obj.candidates + if (!Array.isArray(candidates) || !candidates[0]) return '' + const content = (candidates[0] as Record).content as + | Record + | undefined + const parts = content?.parts + if (!Array.isArray(parts)) return '' + return parts + .map((p) => { + const part = p as Record | null + return typeof part?.text === 'string' ? part.text : '' + }) + .join('') + } + // OpenAI-compatible: choices[0].delta.content. + const choices = obj.choices + if (!Array.isArray(choices) || !choices[0]) return '' + const delta = (choices[0] as Record).delta as + | Record + | undefined + return typeof delta?.content === 'string' ? delta.content : '' +} diff --git a/src/main/ai/store.test.ts b/src/main/ai/store.test.ts new file mode 100644 index 0000000..e8ace59 --- /dev/null +++ b/src/main/ai/store.test.ts @@ -0,0 +1,134 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AccountCipher } from '../accounts/store' +import { type AiConfig, AiStore } from './store' + +let dir: string + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'gitgrove-ai-')) +}) + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** Reversible fake so tests can assert what hit the disk without an OS vault. */ +const fakeCipher = (available = true): AccountCipher => ({ + available: () => available, + encrypt: (text) => `enc:${Buffer.from(text).toString('base64')}`, + decrypt: (payload) => + payload.startsWith('enc:') ? Buffer.from(payload.slice(4), 'base64').toString() : null +}) + +const config = (over: Partial = {}): AiConfig => ({ + provider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + defaultBaseUrl: true, + model: 'claude-haiku-4-5', + models: ['claude-haiku-4-5', 'claude-sonnet-4-5'], + ...over +}) + +let seq = 0 +const newStore = (cipher = fakeCipher()) => { + const file = join(dir, `ai-${++seq}.json`) + return { store: new AiStore(file, cipher), file } +} + +describe('AiStore', () => { + test('starts unconfigured', () => { + const { store } = newStore() + expect(store.status()).toBeNull() + expect(store.endpoint()).toBeNull() + }) + + test('save → status hides the default base URL and never the key', () => { + const { store, file } = newStore() + const status = store.save(config(), 'sk-secret') + expect(status.provider).toBe('anthropic') + expect(status.model).toBe('claude-haiku-4-5') + expect(status.baseUrl).toBeNull() + expect(status.persisted).toBe(true) + // The key reaches disk only as ciphertext. + const raw = readFileSync(file, 'utf8') + expect(raw).not.toContain('sk-secret') + expect(raw).toContain('enc:') + }) + + test('a custom base URL surfaces in the status', () => { + const { store } = newStore() + const status = store.save( + config({ provider: 'litellm', baseUrl: 'https://llm.corp/v1', defaultBaseUrl: false }), + null + ) + expect(status.baseUrl).toBe('https://llm.corp/v1') + }) + + test('endpoint decrypts the key for generations', () => { + const { store } = newStore() + store.save(config(), 'sk-secret') + expect(store.endpoint()).toEqual({ + provider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + model: 'claude-haiku-4-5', + apiKey: 'sk-secret' + }) + }) + + test('keyless endpoints persist with a null key', () => { + const { store } = newStore() + const status = store.save( + config({ provider: 'ollama', baseUrl: 'http://localhost:11434/v1', model: 'llama3.2' }), + null + ) + expect(status.persisted).toBe(true) + expect(store.endpoint()?.apiKey).toBeNull() + }) + + test('without a usable cipher the key stays session-only', () => { + const { store, file } = newStore(fakeCipher(false)) + const status = store.save(config(), 'sk-secret') + expect(status.persisted).toBe(false) + expect(store.endpoint()?.apiKey).toBe('sk-secret') + // Nothing configured hit the disk — a restart forgets the backend. + expect(() => readFileSync(file, 'utf8')).toThrow() + }) + + test('setModel switches the generation model in place', () => { + const { store } = newStore() + store.save(config(), 'sk-secret') + store.setModel('claude-sonnet-4-5') + expect(store.status()?.model).toBe('claude-sonnet-4-5') + expect(store.endpoint()?.model).toBe('claude-sonnet-4-5') + expect(store.endpoint()?.apiKey).toBe('sk-secret') + }) + + test('clear forgets everything', () => { + const { store } = newStore() + store.save(config(), 'sk-secret') + store.clear() + expect(store.status()).toBeNull() + expect(store.endpoint()).toBeNull() + }) + + test('a malformed file reads as unconfigured, never as a broken endpoint', () => { + const { store, file } = newStore() + writeFileSync(file, '{"provider":"anthropic","model":""}', 'utf8') + expect(store.status()).toBeNull() + writeFileSync(file, 'not json', 'utf8') + expect(store.status()).toBeNull() + }) + + test('an undecryptable stored key yields a null-key endpoint (reconnect path)', () => { + const { store, file } = newStore() + store.save(config(), 'sk-secret') + const raw = JSON.parse(readFileSync(file, 'utf8')) + raw.keyCipher = 'garbage-from-another-machine' + writeFileSync(file, JSON.stringify(raw), 'utf8') + expect(store.status()).not.toBeNull() + expect(store.endpoint()?.apiKey).toBeNull() + }) +}) diff --git a/src/main/ai/store.ts b/src/main/ai/store.ts new file mode 100644 index 0000000..faa1390 --- /dev/null +++ b/src/main/ai/store.ts @@ -0,0 +1,153 @@ +// AI backend persistence. Non-secret config (provider, base URL, model, the +// model list) lives in a JSON file in userData; the API key is encrypted by +// the injected cipher (OS vault via Electron safeStorage in production) and +// only its ciphertext touches disk. When no real encryption is available +// (Linux without a keyring), the key is kept in memory for the session +// instead of being written ~plaintext — same contract as the accounts store. +// +// Deliberately Electron-free so the store is unit-testable with a temp file +// and a fake cipher (see cipher.ts for the production wiring). + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import type { AiProvider, AiStatus } from '@shared/types' +import type { AccountCipher } from '../accounts/store' +import type { AiEndpoint } from './providers' + +/** What a verified connect hands the store (the key passed separately). */ +export interface AiConfig { + provider: AiProvider + /** Resolved base URL (never null once connected). */ + baseUrl: string + /** True when the URL is just the provider's default — hidden in the UI. */ + defaultBaseUrl: boolean + model: string + models: string[] +} + +interface StoreFile extends AiConfig { + /** Ciphertext of the API key; null for keyless endpoints. */ + keyCipher: string | null +} + +const PROVIDERS: AiProvider[] = ['openai', 'anthropic', 'gemini', 'litellm', 'ollama', 'custom'] + +/** + * Defensive shape check for the file read back from disk — a hand-edit or + * partial write must yield "not configured", never a malformed endpoint. + */ +function isStoreFile(value: unknown): value is StoreFile { + if (!value || typeof value !== 'object') return false + const c = value as Record + return ( + PROVIDERS.includes(c.provider as AiProvider) && + typeof c.baseUrl === 'string' && + typeof c.defaultBaseUrl === 'boolean' && + typeof c.model === 'string' && + c.model.length > 0 && + Array.isArray(c.models) && + (c.keyCipher === null || typeof c.keyCipher === 'string') + ) +} + +export class AiStore { + /** Session-only fallbacks when the cipher can't protect the key at rest. */ + private sessionKey: string | null = null + private sessionConfig: AiConfig | null = null + + constructor( + private readonly file: string, + private readonly cipher: AccountCipher + ) {} + + /** The renderer-visible summary, or null when nothing is connected. */ + status(): AiStatus | null { + if (this.sessionConfig) return this.toStatus(this.sessionConfig, false) + const stored = this.read() + return stored ? this.toStatus(stored, true) : null + } + + /** The resolved endpoint for a generation, or null (not configured / key lost). */ + endpoint(): AiEndpoint | null { + if (this.sessionConfig) { + return { + provider: this.sessionConfig.provider, + baseUrl: this.sessionConfig.baseUrl, + model: this.sessionConfig.model, + apiKey: this.sessionKey + } + } + const stored = this.read() + if (!stored) return null + // A keyless endpoint decrypts to null harmlessly; an undecryptable stored + // key (OS reinstall) also yields null — the endpoint then answers 401 and + // the renderer's error copy points at reconnecting. + const apiKey = stored.keyCipher === null ? null : this.cipher.decrypt(stored.keyCipher) + return { provider: stored.provider, baseUrl: stored.baseUrl, model: stored.model, apiKey } + } + + /** Persist a *verified* backend (connect always verifies first). */ + save(config: AiConfig, apiKey: string | null): AiStatus { + this.clear() + if (apiKey !== null && !this.cipher.available()) { + this.sessionConfig = config + this.sessionKey = apiKey + return this.toStatus(config, false) + } + const keyCipher = apiKey === null ? null : this.cipher.encrypt(apiKey) + this.write({ ...config, keyCipher }) + return this.toStatus(config, true) + } + + /** Switch the generation model, keeping everything else (incl. the key). */ + setModel(model: string): void { + if (this.sessionConfig) { + this.sessionConfig = { ...this.sessionConfig, model } + return + } + const stored = this.read() + if (stored) this.write({ ...stored, model }) + } + + /** Forget the backend and its key. */ + clear(): void { + this.sessionConfig = null + this.sessionKey = null + try { + if (existsSync(this.file)) writeFileSync(this.file, JSON.stringify({}), 'utf8') + } catch { + // Non-fatal: with the session state dropped the backend reads as gone. + } + } + + private toStatus(config: AiConfig, persisted: boolean): AiStatus { + return { + provider: config.provider, + model: config.model, + models: config.models, + baseUrl: config.defaultBaseUrl ? null : config.baseUrl, + persisted + } + } + + private read(): StoreFile | null { + try { + if (!existsSync(this.file)) return null + const parsed = JSON.parse(readFileSync(this.file, 'utf8')) + return isStoreFile(parsed) ? parsed : null + } catch { + return null + } + } + + private write(data: StoreFile): void { + try { + mkdirSync(dirname(this.file), { recursive: true }) + writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8') + } catch { + // Non-fatal: the backend still works this session via the session state. + this.sessionConfig = { ...data } + this.sessionKey = data.keyCipher === null ? null : this.cipher.decrypt(data.keyCipher) + } + } +} diff --git a/src/main/ipc/ai.ts b/src/main/ipc/ai.ts new file mode 100644 index 0000000..5112a9c --- /dev/null +++ b/src/main/ipc/ai.ts @@ -0,0 +1,104 @@ +// AI assist: connect/verify the bring-your-own backend, and run generations. +// Generations stream their tokens back to the window that asked (never +// broadcast — another window's composer must not receive this one's text) +// and are cancellable by requestId. The API key never crosses to a renderer: +// it arrives once in aiConnect and lives encrypted in the AI store. + +import { IPC } from '@shared/ipc' +import type { AiChunk, AiCommitRequest, AiConnectInput, AiConnectResult } from '@shared/types' +import { ipcMain } from 'electron' +import { aiStore } from '../ai/cipher' +import { AiRequestError, streamChat, verifyEndpoint } from '../ai/client' +import { gatherCommitContext } from '../ai/commit-context' +import { buildCommitPrompt } from '../ai/commit-prompt' +import { resolveBaseUrl } from '../ai/providers' +import type { HandlerDeps } from './context' + +export function registerAiHandlers(deps: HandlerDeps): void { + const { broadcast } = deps + + ipcMain.handle(IPC.aiStatus, () => aiStore().status()) + + ipcMain.handle(IPC.aiConnect, async (_e, input: AiConnectInput): Promise => { + const baseUrl = resolveBaseUrl(input.provider, input.baseUrl) + if (!baseUrl) return { ok: false, code: 'bad-endpoint', detail: 'An endpoint URL is needed.' } + const apiKey = input.apiKey?.trim() || null + try { + // Verify live before saving anything: "connected" must mean "works". + const { models, defaultModel } = await verifyEndpoint({ + provider: input.provider, + baseUrl, + apiKey + }) + const status = aiStore().save( + { + provider: input.provider, + baseUrl, + defaultBaseUrl: !input.baseUrl?.trim(), + model: defaultModel, + models + }, + apiKey + ) + // Every window's ✨ buttons and settings pane flip to "connected". + broadcast(IPC.aiChanged) + return { ok: true, status } + } catch (e) { + if (e instanceof AiRequestError) return { ok: false, code: e.code, detail: e.message } + return { ok: false, code: 'network', detail: e instanceof Error ? e.message : undefined } + } + }) + + ipcMain.handle(IPC.aiSetModel, (_e, model: string) => { + aiStore().setModel(model) + broadcast(IPC.aiChanged) + }) + + ipcMain.handle(IPC.aiDisconnect, () => { + aiStore().clear() + broadcast(IPC.aiChanged) + }) + + // One AbortController per running generation, keyed by the renderer-chosen + // requestId — cancel is a plain lookup, and a window can run several + // generations (composer now, branch names later) without cross-talk. + const inFlight = new Map() + + ipcMain.handle( + IPC.aiCommitMessage, + async (e, repoPath: string, request: AiCommitRequest): Promise => { + const endpoint = aiStore().endpoint() + // The renderer gates the button on aiStatus, so this only races a + // just-disconnected backend — answer like any other failed generation. + if (!endpoint) throw new Error('No AI backend is connected — set one up in Settings.') + const controller = new AbortController() + inFlight.set(request.requestId, controller) + try { + const context = await gatherCommitContext(repoPath, request) + return await streamChat(endpoint, buildCommitPrompt(context), { + signal: controller.signal, + onText: (text) => { + if (e.sender.isDestroyed()) return + const chunk: AiChunk = { requestId: request.requestId, text } + e.sender.send(IPC.aiChunk, chunk) + } + }) + } catch (err) { + // Stable codes become calm copy here, so every AI surface fails the + // same way and no renderer ever parses provider error strings. + if (err instanceof AiRequestError) { + if (err.code === 'unauthorized') + throw new Error('The AI endpoint rejected the key — reconnect it in Settings.') + throw new Error(err.message) + } + throw err + } finally { + inFlight.delete(request.requestId) + } + } + ) + + ipcMain.handle(IPC.aiCancel, (_e, requestId: string) => { + inFlight.get(requestId)?.abort() + }) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index cb9351f..460a90a 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -10,6 +10,7 @@ import { IPC } from '@shared/ipc' import type { OpProgress } from '@shared/types' import { registerAccountHandlers } from './accounts' +import { registerAiHandlers } from './ai' import { registerAppHandlers } from './app' import { registerBranchHandlers } from './branches' import { registerCloneHandlers } from './clone' @@ -41,6 +42,7 @@ export function registerIpc(ctx: IpcContext): void { registerStagingHandlers(deps) registerSyncHandlers(deps) registerAccountHandlers(deps) + registerAiHandlers(deps) registerBranchHandlers() registerIntegrationHandlers() registerStashHandlers() diff --git a/src/preload/index.ts b/src/preload/index.ts index b23fd68..986bba2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { type GitGroveApi, IPC, type MenuCommand } from '@shared/ipc' import type { + AiChunk, ChangedFile, CloneProgress, CredentialPromptRequest, @@ -117,6 +118,13 @@ const api: GitGroveApi = { lfsEnable: (repoPath) => ipcRenderer.invoke(IPC.lfsEnable, repoPath), optimizeRepo: (repoPath) => ipcRenderer.invoke(IPC.optimizeRepo, repoPath), selectionSize: (repoPath, paths) => ipcRenderer.invoke(IPC.selectionSize, repoPath, paths), + aiStatus: () => ipcRenderer.invoke(IPC.aiStatus), + aiConnect: (input) => ipcRenderer.invoke(IPC.aiConnect, input), + aiSetModel: (model) => ipcRenderer.invoke(IPC.aiSetModel, model), + aiDisconnect: () => ipcRenderer.invoke(IPC.aiDisconnect), + aiCommitMessage: (repoPath, request) => + ipcRenderer.invoke(IPC.aiCommitMessage, repoPath, request), + aiCancel: (requestId) => ipcRenderer.invoke(IPC.aiCancel, requestId), cloneRepo: (url, targetPath) => ipcRenderer.invoke(IPC.cloneRepo, url, targetPath), defaultCloneDir: () => ipcRenderer.invoke(IPC.defaultCloneDir), checkCloneTarget: (targetPath) => ipcRenderer.invoke(IPC.checkCloneTarget, targetPath), @@ -198,6 +206,16 @@ const api: GitGroveApi = { ipcRenderer.on(IPC.opProgress, listener) return () => ipcRenderer.removeListener(IPC.opProgress, listener) }, + onAiChunk: (handler) => { + const listener = (_e: unknown, chunk: AiChunk) => handler(chunk) + ipcRenderer.on(IPC.aiChunk, listener) + return () => ipcRenderer.removeListener(IPC.aiChunk, listener) + }, + onAiChanged: (handler) => { + const listener = () => handler() + ipcRenderer.on(IPC.aiChanged, listener) + return () => ipcRenderer.removeListener(IPC.aiChanged, listener) + }, onUpdateStatus: (handler) => { const listener = (_e: unknown, status: UpdateStatus) => handler(status) ipcRenderer.on(IPC.updateStatus, listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8df7e7e..1de54ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1348,6 +1348,7 @@ export function App() { {modal?.kind === 'settings' && credentialPrompts.length === 0 && ( setModal(null)} @@ -1524,6 +1525,7 @@ export function App() { onCommit={doCommit} onStash={doStash} onOpenFileHistory={openFileHistory} + onSetupAi={() => setModal({ kind: 'settings', section: 'ai' })} />
diff --git a/src/renderer/src/components/app/AppModals.tsx b/src/renderer/src/components/app/AppModals.tsx index 78435a0..61758fe 100644 --- a/src/renderer/src/components/app/AppModals.tsx +++ b/src/renderer/src/components/app/AppModals.tsx @@ -9,6 +9,7 @@ import type { BranchChangesAction, BranchInfo, Commit, MergeKind, ResetMode } from '@shared/types' import { ConfirmDialog, PromptDialog, validateRefName } from '@/components/common/Dialog' import { InteractiveRebaseDialog } from '@/components/history/InteractiveRebaseDialog' +import type { SettingsSection } from '@/components/settings/SettingsDialog' import { CreateBranchDialog, type CreateBranchRequest } from './CreateBranchDialog' import { MergeDialog } from './MergeDialog' import { SubmodulesDialog } from './SubmodulesDialog' @@ -17,7 +18,7 @@ import { WorktreesDialog } from './WorktreesDialog' /** App-level modal dialogs (branch/tag/reset/rebase/clone/worktrees/…). */ export type Modal = - | { kind: 'settings' } + | { kind: 'settings'; section?: SettingsSection } | { kind: 'clone'; initial?: { url: string; baseDir: string } } | { kind: 'identity' } | { kind: 'merge'; name: string } diff --git a/src/renderer/src/components/changes/AiComposerControls.tsx b/src/renderer/src/components/changes/AiComposerControls.tsx new file mode 100644 index 0000000..f7f9833 --- /dev/null +++ b/src/renderer/src/components/changes/AiComposerControls.tsx @@ -0,0 +1,268 @@ +// The composer's AI cluster (top-right of the summary field): a ✨ button +// that streams a generated commit/stash message into the fields, plus a caret +// opening the style popover — length, tone, emojis, regenerate. The button is +// ALWAYS visible: with no backend connected it opens the setup teaser instead +// of generating, so the feature explains itself exactly where it's useful and +// setup is one click away (Settings → AI). styles: features/ai.css (.ai-*), +// positioning in changes.css (.composer__ai) + +import type { AiCommitOptions, ChangedFile } from '@shared/types' +import { useEffect, useRef, useState } from 'react' +import { Popover } from '@/components/common/Popover' +import { DEFAULT_AI_COMMIT_OPTIONS, splitCommitMessage, useAiStatus } from '@/lib/ai' +import { Icon } from '@/lib/icons' +import { usePersistentState } from '@/lib/persist' +import type { CommitMode } from './CommitComposer' + +const LENGTHS: Array<{ id: AiCommitOptions['length']; label: string }> = [ + { id: 'short', label: 'Short' }, + { id: 'medium', label: 'Medium' }, + { id: 'long', label: 'Long' } +] + +const TONES: Array<{ id: AiCommitOptions['tone']; label: string }> = [ + { id: 'technical', label: 'Technical' }, + { id: 'formal', label: 'Formal' }, + { id: 'informal', label: 'Informal' }, + { id: 'friendly', label: 'Friendly' } +] + +interface Props { + repoPath: string + mode: CommitMode + /** Blocks generating (not the teaser): busy, committing, empty selection. */ + disabledReason: string | null + /** Snapshot of the checkbox selection, taken when generating starts. */ + buildSelection: () => { files: ChangedFile[]; patches: string[] } + /** Receives the (streaming) generated message, already split for the fields. */ + onMessage: (summary: string, description: string) => void + /** Open Settings → AI (the teaser's one button). */ + onSetupAi: () => void + onError: (e: unknown) => void +} + +export function AiComposerControls({ + repoPath, + mode, + disabledReason, + buildSelection, + onMessage, + onSetupAi, + onError +}: Props) { + const status = useAiStatus() + const [options, setOptions] = usePersistentState( + 'gg.aiCommitOptions', + DEFAULT_AI_COMMIT_OPTIONS + ) + const [teaserOpen, setTeaserOpen] = useState(false) + const [optionsOpen, setOptionsOpen] = useState(false) + const [generating, setGenerating] = useState(false) + const clusterRef = useRef(null) + + // The running generation: its id (chunk filter + cancellation) and the text + // accumulated so far. Refs, not state — chunks arrive faster than React + // needs to re-render this component (the fields update via onMessage). + const requestIdRef = useRef(null) + const accumulatedRef = useRef('') + + useEffect(() => { + return window.gitgrove.onAiChunk((chunk) => { + if (chunk.requestId !== requestIdRef.current) return + accumulatedRef.current += chunk.text + const { summary, description } = splitCommitMessage(accumulatedRef.current) + onMessage(summary, description) + }) + }, [onMessage]) + + // Repo switches invalidate a stream mid-flight — stop listening to it. + // biome-ignore lint/correctness/useExhaustiveDependencies: repoPath is the intentional reset trigger + useEffect(() => { + const requestId = requestIdRef.current + requestIdRef.current = null + if (requestId) window.gitgrove.aiCancel(requestId).catch(() => {}) + setGenerating(false) + }, [repoPath]) + + const generate = async (opts: AiCommitOptions) => { + const requestId = crypto.randomUUID() + requestIdRef.current = requestId + accumulatedRef.current = '' + setGenerating(true) + try { + const { files, patches } = buildSelection() + const message = await window.gitgrove.aiCommitMessage(repoPath, { + requestId, + files, + patches, + mode, + options: opts + }) + // Superseded by a newer run or a repo switch — its text is not ours. + if (requestIdRef.current !== requestId) return + const { summary, description } = splitCommitMessage(message) + onMessage(summary, description) + } catch (e) { + if (requestIdRef.current === requestId) onError(e) + } finally { + if (requestIdRef.current === requestId) { + requestIdRef.current = null + setGenerating(false) + } + } + } + + const stop = () => { + const requestId = requestIdRef.current + if (requestId) window.gitgrove.aiCancel(requestId).catch(() => {}) + } + + const onSparkle = () => { + if (!status) { + setOptionsOpen(false) + setTeaserOpen(true) + return + } + if (generating) stop() + else if (!disabledReason) generate(options) + } + + const verb = mode === 'stash' ? 'Name this stash' : 'Write this message' + const tip = generating + ? 'Stop generating' + : status + ? (disabledReason ?? `${verb} with AI`) + : `${verb} with AI — click to set up` + + return ( +
+ + + + setTeaserOpen(false)} + align="right" + width={280} + > +
+
+ {verb} with AI +
+

+ Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — your + key, sent only to your provider. GitGrove writes the message from exactly the changes + you selected. +

+ +
+
+ + setOptionsOpen(false)} + align="right" + width={252} + > +
+
+ Message style + +
+
+ Length +
+ {LENGTHS.map((l) => ( + + ))} +
+
+
+ Tone + +
+
+ Use emojis + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/changes/ChangesView.tsx b/src/renderer/src/components/changes/ChangesView.tsx index 82dca02..88bb58e 100644 --- a/src/renderer/src/components/changes/ChangesView.tsx +++ b/src/renderer/src/components/changes/ChangesView.tsx @@ -72,6 +72,8 @@ interface Props { onStash: (message: string) => Promise /** Open the File History overlay for a working-tree file (baseRef = null). */ onOpenFileHistory: (path: string, mode: FileHistoryMode, baseRef: string | null) => void + /** Open Settings → AI (the composer ✨ teaser when no backend is connected). */ + onSetupAi: () => void } const OP_LABEL: Record, string> = { @@ -163,7 +165,8 @@ export function ChangesView({ onError, onCommit, onStash, - onOpenFileHistory + onOpenFileHistory, + onSetupAi }: Props) { const gg = window.gitgrove @@ -749,6 +752,24 @@ export function ChangesView({ if (ok) setMode('commit') return ok }} + buildAiSelection={() => { + // Snapshot the checkboxes the same way the commit does (see + // lib/commit-selection.ts): fully included files travel as files + // (main reads their diffs, size-capped), partially included ones as + // their exact hunk patches — the message describes precisely what + // will be committed. + const aiFiles: ChangedFile[] = [] + const aiPatches: string[] = [] + for (const f of changes) { + if (f.status === 'conflicted') continue + const sel = selections.get(f.path) ?? 'all' + if (sel === 'all') aiFiles.push(f) + else if (sel !== 'none') for (const block of sel.values()) aiPatches.push(block.patch) + } + return { files: aiFiles, patches: aiPatches } + }} + onSetupAi={onSetupAi} + onError={onError} /> {confirmDiscard && ( diff --git a/src/renderer/src/components/changes/CommitComposer.tsx b/src/renderer/src/components/changes/CommitComposer.tsx index 1fac612..a23fd56 100644 --- a/src/renderer/src/components/changes/CommitComposer.tsx +++ b/src/renderer/src/components/changes/CommitComposer.tsx @@ -13,11 +13,12 @@ // commit message), the button becomes "Complete merge" and stays disabled, // explaining itself, until every conflict is resolved. -import type { RepoOpKind } from '@shared/types' +import type { ChangedFile, RepoOpKind } from '@shared/types' import { useCallback, useEffect, useRef, useState } from 'react' import { formatBytes, pluralize } from '@/lib/format' import { Icon } from '@/lib/icons' import { isCmdOrCtrl, modKeyLabel } from '@/lib/platform' +import { AiComposerControls } from './AiComposerControls' export type CommitMode = 'commit' | 'amend' | 'stash' @@ -71,6 +72,12 @@ interface Props { onCommit: (message: string, amend: boolean) => Promise /** Stash the checked files with an optional message. */ onStash: (message: string) => Promise + /** Snapshot of the checkbox selection, for the AI message generator. */ + buildAiSelection: () => { files: ChangedFile[]; patches: string[] } + /** Open Settings → AI (the ✨ button's teaser when nothing is connected). */ + onSetupAi: () => void + /** Surface a failed generation (toast). */ + onError: (e: unknown) => void } export function CommitComposer({ @@ -89,7 +96,10 @@ export function CommitComposer({ modeMenuRef, onOpenModeMenu, onCommit, - onStash + onStash, + buildAiSelection, + onSetupAi, + onError }: Props) { const [summary, setSummary] = useState('') const [description, setDescription] = useState('') @@ -243,6 +253,25 @@ export function CommitComposer({ ? 'Write a commit summary to continue' : null + // Why the ✨ button can't generate right now (its tooltip). Independent of + // the commit button's gating: a message-only amend can generate, and an + // empty summary is exactly when generating is most useful. + const aiDisabledReason = + busy || committing + ? 'Wait for the current operation to finish' + : includedCount === 0 && !amend + ? mode === 'stash' + ? 'Select some files to name a stash for' + : 'Select some changes to describe' + : null + + // A generated message lands like typed text: draft bookkeeping untouched, so + // mode flips (amend/merge) keep restoring exactly what the fields held. + const onAiMessage = useCallback((aiSummary: string, aiDescription: string) => { + setSummary(aiSummary.slice(0, 500)) + setDescription(aiDescription) + }, []) + const size = commitSize !== null && commitSize > 0 ? ` · ${formatBytes(commitSize)}` : '' const label = merging ? `Complete merge${mergeSource ? ` of ${mergeSource}` : ''} into ${branch}` @@ -258,6 +287,18 @@ export function CommitComposer({ return (
+ {/* Merges keep git's prepared message — no AI second-guessing there. */} + {!merging && !blockedByOp && ( + + )} (null) + // True right after a connect completes, for the one-time "try it" hint. + const [justConnected, setJustConnected] = useState(false) + + if (status === undefined) { + return ( +
+
+
+ ) + } + + if (status !== null) { + return ( + { + setJustConnected(false) + setChoice(null) + }} + /> + ) + } + + if (choice) { + return ( + setChoice(null)} + onConnected={() => setJustConnected(true)} + /> + ) + } + + return ( + <> +

+ Bring your own AI — GitGrove writes commit messages and more, using your key, sent only to + the provider you pick. Nothing is shared with anyone else. +

+
+ {AI_PROVIDER_CHOICES.map((c) => ( + + ))} +
+ + ) +} + +/** Step two: endpoint/key form → Connect (verifies live, then saves). */ +function ConnectForm({ + choice, + onBack, + onConnected +}: { + choice: AiProviderChoice + onBack: () => void + onConnected: () => void +}) { + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const canConnect = !busy && (choice.keyOptional || apiKey.trim().length > 0) + + const connect = async (e: FormEvent) => { + e.preventDefault() + if (!canConnect) return + setBusy(true) + setError(null) + const result = await window.gitgrove.aiConnect({ + provider: choice.id, + baseUrl: baseUrl.trim() || (choice.id === 'ollama' ? choice.baseUrlPlaceholder : undefined), + apiKey: apiKey.trim() || undefined + }) + setBusy(false) + if (result.ok) onConnected() + else setError(aiErrorCopy(result.code, result.detail)) + } + + return ( +
+

+ {choice.id === 'ollama' ? ( + <> + Connect the Ollama running on this machine — free, private, no key. GitGrove picks an + installed model automatically. + + ) : choice.needsBaseUrl ? ( + <>Point GitGrove at your OpenAI-compatible endpoint. + ) : ( + <> + Paste a {choice.label} key — GitGrove verifies it and picks a fast + model automatically. Your key is stored in the system keychain, never shared. + + )} +

+ {choice.needsBaseUrl && ( +
+ + { + setError(null) + setBaseUrl(e.target.value) + }} + /> +
+ )} + {choice.needsKey && ( +
+ +
+ { + setError(null) + setApiKey(e.target.value) + }} + /> + {choice.keyUrl && ( + + )} +
+
+ )} + {error &&

{error}

} +
+ + +
+
+ ) +} + +/** The connected state: one calm card — model picker, disconnect, privacy note. */ +function ConnectedCard({ + status, + justConnected, + onDisconnected +}: { + status: AiStatus + justConnected: boolean + onDisconnected: () => void +}) { + const [confirmDisconnect, setConfirmDisconnect] = useState(false) + + return ( + <> + {justConnected && ( +

+ Connected — try the button in the + commit box. +

+ )} +
+
+ + + +
+ + {aiProviderLabel(status.provider)} + {!status.persisted && ( + + this session only + + )} + + {status.baseUrl ?? 'AI features enabled'} +
+
+ +
+
+
+
+ + +
+

+ Your key stays on this machine{status.persisted ? ', encrypted in the system keychain' : ''} + . Diffs and commit history snippets are sent only to this endpoint, only when you ask for a + generation. +

+ + {confirmDisconnect && ( + + GitGrove forgets the {aiProviderLabel(status.provider)} connection and its key. AI + buttons stay visible and will offer to set it up again. + + } + confirmLabel="Disconnect" + onConfirm={async () => { + setConfirmDisconnect(false) + await window.gitgrove.aiDisconnect() + onDisconnected() + }} + onCancel={() => setConfirmDisconnect(false)} + /> + )} + + ) +} diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx index 4e67eb9..2bee19d 100644 --- a/src/renderer/src/components/settings/SettingsDialog.tsx +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -9,33 +9,45 @@ import { DialogShell } from '@/components/common/Dialog' import { Icon } from '@/lib/icons' import type { ThemePref } from '@/lib/theme' import { AccountsPane } from './AccountsPane' +import { AiPane } from './AiPane' import { AppearancePane } from './AppearancePane' import { IdentityPane } from './IdentityPane' -type Section = 'accounts' | 'identity' | 'appearance' +export type SettingsSection = 'accounts' | 'identity' | 'ai' | 'appearance' -const SECTIONS: Array<{ id: Section; label: string }> = [ +const SECTIONS: Array<{ id: SettingsSection; label: string }> = [ { id: 'accounts', label: 'Accounts' }, { id: 'identity', label: 'Identity' }, + { id: 'ai', label: 'AI' }, { id: 'appearance', label: 'Appearance' } ] interface Props { /** Open repository, if any — Identity uses it to surface local overrides. */ repoPath?: string + /** Section to open on — AI teasers deep-link straight to their setup. */ + initialSection?: SettingsSection themePref: ThemePref onThemePref: (pref: ThemePref) => void onClose: () => void } -export function SettingsDialog({ repoPath, themePref, onThemePref, onClose }: Props) { - const [section, setSection] = useState
('accounts') +export function SettingsDialog({ + repoPath, + initialSection, + themePref, + onThemePref, + onClose +}: Props) { + const [section, setSection] = useState(initialSection ?? 'accounts') - const sectionIcon = (id: Section) => + const sectionIcon = (id: SettingsSection) => id === 'accounts' ? ( ) : id === 'identity' ? ( + ) : id === 'ai' ? ( + ) : ( ) @@ -59,6 +71,7 @@ export function SettingsDialog({ repoPath, themePref, onThemePref, onClose }: Pr
{section === 'accounts' && } {section === 'identity' && } + {section === 'ai' && } {section === 'appearance' && }
diff --git a/src/renderer/src/lib/ai.test.ts b/src/renderer/src/lib/ai.test.ts new file mode 100644 index 0000000..23045e9 --- /dev/null +++ b/src/renderer/src/lib/ai.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { splitCommitMessage } from './ai' + +describe('splitCommitMessage', () => { + test('subject only', () => { + expect(splitCommitMessage('Fix stash panel overflow')).toEqual({ + summary: 'Fix stash panel overflow', + description: '' + }) + }) + + test('subject + body split on the first newline, blank line eaten', () => { + expect(splitCommitMessage('Fix overflow\n\nThe panel grew unbounded.\nNow it clamps.')).toEqual( + { + summary: 'Fix overflow', + description: 'The panel grew unbounded.\nNow it clamps.' + } + ) + }) + + test('streams cleanly: a partial message is still well-formed', () => { + expect(splitCommitMessage('Fix over')).toEqual({ summary: 'Fix over', description: '' }) + expect(splitCommitMessage('Fix overflow\nThe pa')).toEqual({ + summary: 'Fix overflow', + description: 'The pa' + }) + }) + + test('strips the wrappers models sneak in despite instructions', () => { + expect(splitCommitMessage('```\nFix overflow\n```')).toEqual({ + summary: 'Fix overflow', + description: '' + }) + expect(splitCommitMessage('"Fix overflow"')).toEqual({ + summary: 'Fix overflow', + description: '' + }) + }) + + test('whitespace-only input yields empty fields', () => { + expect(splitCommitMessage(' \n ')).toEqual({ summary: '', description: '' }) + }) +}) diff --git a/src/renderer/src/lib/ai.ts b/src/renderer/src/lib/ai.ts new file mode 100644 index 0000000..04a0293 --- /dev/null +++ b/src/renderer/src/lib/ai.ts @@ -0,0 +1,145 @@ +// Shared AI-assist logic for every AI surface (the composer's ✨ today, +// branch names and conflict help tomorrow): the provider metadata the +// onboarding shows, the status hook the buttons key off, and the pure +// message-shaping helpers. Talking to the backend happens in main — the +// renderer only ever sees text. + +import type { AiCommitOptions, AiErrorCode, AiProvider, AiStatus } from '@shared/types' +import { useEffect, useState } from 'react' + +/** One provider card in the settings onboarding, in display order. */ +export interface AiProviderChoice { + id: AiProvider + label: string + sub: string + /** Where to create a key ("Create key…" button); null when keyless/self-hosted. */ + keyUrl: string | null + /** Whether the pane asks for an endpoint URL, and what it hints. */ + needsBaseUrl: boolean + baseUrlPlaceholder?: string + /** Whether the pane asks for a key, and whether it may be left empty. */ + needsKey: boolean + keyOptional: boolean +} + +export const AI_PROVIDER_CHOICES: AiProviderChoice[] = [ + { + id: 'openai', + label: 'OpenAI', + sub: 'GPT models — platform.openai.com key', + keyUrl: 'https://platform.openai.com/api-keys', + needsBaseUrl: false, + needsKey: true, + keyOptional: false + }, + { + id: 'anthropic', + label: 'Anthropic', + sub: 'Claude models — console.anthropic.com key', + keyUrl: 'https://console.anthropic.com/settings/keys', + needsBaseUrl: false, + needsKey: true, + keyOptional: false + }, + { + id: 'gemini', + label: 'Google Gemini', + sub: 'Gemini models — aistudio.google.com key', + keyUrl: 'https://aistudio.google.com/apikey', + needsBaseUrl: false, + needsKey: true, + keyOptional: false + }, + { + id: 'ollama', + label: 'Ollama', + sub: 'Local models on this machine — no key needed', + keyUrl: null, + needsBaseUrl: true, + baseUrlPlaceholder: 'http://localhost:11434/v1', + needsKey: false, + keyOptional: true + }, + { + id: 'litellm', + label: 'LiteLLM / custom endpoint', + sub: 'Any OpenAI-compatible server or proxy', + keyUrl: null, + needsBaseUrl: true, + baseUrlPlaceholder: 'https://llm.example.com/v1', + needsKey: true, + keyOptional: true + } +] + +/** Display label for a connected provider (settings summary card). */ +export function aiProviderLabel(provider: AiProvider): string { + return AI_PROVIDER_CHOICES.find((c) => c.id === provider)?.label ?? 'Custom endpoint' +} + +/** Human copy for the stable failure codes a connect can come back with. */ +export function aiErrorCopy(code: AiErrorCode, detail?: string): string { + switch (code) { + case 'unauthorized': + return 'The endpoint did not accept that key.' + case 'network': + return 'Could not reach the endpoint — check the address and your connection.' + case 'bad-endpoint': + return detail ?? 'No compatible API answered at that address.' + case 'provider-error': + return detail ?? 'The endpoint answered with an error.' + case 'cancelled': + return '' + } +} + +/** + * The connected AI backend, kept fresh across every window: undefined while + * loading, null when none is connected. Every AI surface keys its button off + * this — visible either way, but unconfigured clicks open the setup teaser. + */ +export function useAiStatus(): AiStatus | null | undefined { + const [status, setStatus] = useState(undefined) + useEffect(() => { + let stale = false + const load = () => + window.gitgrove + .aiStatus() + .then((s) => { + if (!stale) setStatus(s) + }) + .catch(() => {}) + load() + const unsubscribe = window.gitgrove.onAiChanged(load) + return () => { + stale = true + unsubscribe() + } + }, []) + return status +} + +export const DEFAULT_AI_COMMIT_OPTIONS: AiCommitOptions = { + length: 'medium', + tone: 'technical', + emojis: false +} + +/** + * Split a (possibly still-streaming) generated message into the composer's + * summary + description fields, defensively stripping the wrappers models + * sometimes add despite instructions (code fences, surrounding quotes). + */ +export function splitCommitMessage(text: string): { summary: string; description: string } { + let cleaned = text.replace(/^\s*```[a-z]*\n?/, '').replace(/\n?```\s*$/, '') + cleaned = cleaned.trim() + if (cleaned.startsWith('"') && cleaned.endsWith('"') && cleaned.length > 1) { + cleaned = cleaned.slice(1, -1) + } + const newline = cleaned.indexOf('\n') + if (newline < 0) return { summary: cleaned, description: '' } + return { + summary: cleaned.slice(0, newline).trim(), + description: cleaned.slice(newline + 1).replace(/^\n+/, '').trimEnd() + } +} diff --git a/src/renderer/src/lib/icons.tsx b/src/renderer/src/lib/icons.tsx index d10d561..92d7a6d 100644 --- a/src/renderer/src/lib/icons.tsx +++ b/src/renderer/src/lib/icons.tsx @@ -249,9 +249,21 @@ export const Icon = { ), + // The modern AI glyph: a four-point sparkle with concave (pinched) edges — + // each side curves toward the center via a control point AT the center, the + // trick behind the Gemini/Copilot "shimmer" star — paired with a smaller + // companion spark for that premium, twinkly feel. Filled with soft (round) + // tips rather than a thin outline so it reads as a jewel, not a doodle. Sparkle: (p: IconProps) => ( - - + + + ), Sun: (p: IconProps) => ( diff --git a/src/renderer/src/styles/features/ai.css b/src/renderer/src/styles/features/ai.css new file mode 100644 index 0000000..bfb403e --- /dev/null +++ b/src/renderer/src/styles/features/ai.css @@ -0,0 +1,203 @@ +/* ── AI assist (.ai-*) ────────────────────────────────────────────────────── + Every AI surface: the composer's ✨ cluster (AiComposerControls.tsx — its + position in the composer lives in changes.css), the setup teaser and style + popovers, and the Settings → AI pane (AiPane.tsx). One namespace so "where + is this styled?" is always answerable: .ai-* → this file. */ + +/* The ✨ / caret pair: quiet ghost buttons living inside an input field — + visible on approach, never shouting over the text they assist. */ +.ai-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--fg-muted); + cursor: pointer; +} +.ai-btn:hover { + background: var(--accent-soft); + color: var(--accent); +} +/* The sparkle earns its "wow" on approach: a soft accent glow + a one-shot + twinkle (scale/rotate pop). Motion lives on the glyph, not the button, so + the hit target stays calm. */ +.ai-btn svg { + transition: filter 0.2s ease; +} +.ai-btn:hover svg { + filter: drop-shadow(0 0 5px var(--accent-border)); + animation: ai-twinkle 0.55s ease; +} +.ai-btn.is-generating { + color: var(--accent); +} +.ai-btn[aria-disabled='true'] { + opacity: 0.45; + cursor: default; +} +.ai-btn[aria-disabled='true']:hover { + background: none; + color: var(--fg-muted); +} +.ai-btn--caret { + width: 16px; + margin-left: -2px; +} +/* Streaming: the sparkle gives way to a small spinner (shared keyframes). */ +.ai-btn__spinner { + width: 12px; + height: 12px; + border: 2px solid var(--accent-soft); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* ── Setup teaser: the "how do I enable this?" popover every unconfigured AI + button opens. One pitch, one button — never a nag, gone on outside click. */ +.ai-teaser { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; +} +.ai-teaser__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 600; + color: var(--fg); +} +.ai-teaser__title svg { + color: var(--accent); +} +.ai-teaser__body { + margin: 0; + font-size: 11.5px; + line-height: 1.5; + color: var(--fg-muted); +} +.ai-teaser__cta { + align-self: flex-start; +} + +/* ── Style popover: length / tone / emojis + regenerate. */ +.ai-options { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px 12px; +} +.ai-options__head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + color: var(--fg); +} +.ai-options__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.ai-options__label { + font-size: 12px; + color: var(--fg-muted); +} +.ai-options__segmented button { + font-size: 11px; + padding: 3px 8px; +} + +/* A compact select shared by the tone row and the settings model picker. */ +.ai-select { + max-width: 100%; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--fg); + font-family: var(--font-ui); + font-size: 12px; +} +.ai-select:focus { + outline: none; + border-color: var(--accent-border); +} + +/* The emoji toggle: a plain switch, no library. */ +.ai-switch { + position: relative; + width: 32px; + height: 18px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--bg-hover); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.ai-switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--fg-muted); + transition: transform 0.15s ease, background 0.15s ease; +} +.ai-switch.is-on { + background: var(--accent-soft); + border-color: var(--accent-border); +} +.ai-switch.is-on .ai-switch__thumb { + transform: translateX(14px); + background: var(--accent); +} + +/* ── Settings → AI pane. */ +.ai-provider-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + flex: 0 0 auto; +} +.ai-connected-hint { + display: flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + font-size: 12px; + color: var(--fg); +} +.ai-connected-hint svg { + color: var(--accent); +} + +/* The sparkle's one-shot pop: a quick over-scale + gentle turn that settles, + so hovering the AI button feels alive without ever spinning distractingly. */ +@keyframes ai-twinkle { + 0% { + transform: scale(1) rotate(0deg); + } + 45% { + transform: scale(1.25) rotate(12deg); + } + 100% { + transform: scale(1) rotate(0deg); + } +} diff --git a/src/renderer/src/styles/features/changes.css b/src/renderer/src/styles/features/changes.css index 302990f..7421143 100644 --- a/src/renderer/src/styles/features/changes.css +++ b/src/renderer/src/styles/features/changes.css @@ -92,6 +92,8 @@ } /* ── Commit composer ─────────────────────────────────────────────────────── */ .composer { + /* Anchors the AI cluster (.composer__ai) inside the summary field. */ + position: relative; display: flex; flex-direction: column; gap: 7px; @@ -104,6 +106,21 @@ border-top: none; padding-top: 4px; } +/* The AI cluster (AiComposerControls.tsx) floats at the summary field's right + edge; the field keeps typed text clear of it via padding-right below. The + cluster's own look lives in features/ai.css (.ai-btn). The 14px offsets = + the composer's 10px padding + 4px inside the field's border. */ +.composer__ai { + position: absolute; + top: 14px; + right: 14px; + display: flex; + align-items: center; + z-index: 1; +} +.composer-head + .composer .composer__ai { + top: 8px; +} .composer__summary, .composer__description { width: 100%; @@ -116,6 +133,11 @@ font-family: var(--font-ui); font-size: 12.5px; } +/* After the shared padding shorthand above, so the right inset survives — + it keeps typed text clear of the floating AI cluster. */ +.composer__summary { + padding-right: 56px; +} .composer__summary:focus, .composer__description:focus { outline: none; diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index a703564..2469104 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -26,3 +26,4 @@ @import './features/banners.css'; @import './features/dialogs.css'; @import './features/image-diff.css'; +@import './features/ai.css'; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1076efb..4b7cc14 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -4,6 +4,11 @@ import type { AddAccountResult, + AiChunk, + AiCommitRequest, + AiConnectInput, + AiConnectResult, + AiStatus, AppInfo, AvatarLookupResult, BlameLine, @@ -148,6 +153,13 @@ export const IPC = { lfsEnable: 'repo:lfs:enable', optimizeRepo: 'repo:optimize', selectionSize: 'repo:selection-size', + // ai assist (bring-your-own backend) + aiStatus: 'ai:status', + aiConnect: 'ai:connect', + aiSetModel: 'ai:set-model', + aiDisconnect: 'ai:disconnect', + aiCommitMessage: 'ai:commit-message', + aiCancel: 'ai:cancel', // clone cloneRepo: 'repo:clone', defaultCloneDir: 'repo:clone:default-dir', @@ -189,6 +201,10 @@ export const IPC = { accountReposPage: 'accounts:repos-page', /** Determinate progress of a running checkout/fetch/pull/push (OpProgress). */ opProgress: 'repo:op-progress', + /** A streamed piece of a running AI generation (AiChunk). */ + aiChunk: 'ai:chunk', + /** The AI backend was connected/changed/disconnected — refetch aiStatus. */ + aiChanged: 'ai:changed', updateStatus: 'update:status', windowMaximized: 'window:maximized' } as const @@ -480,6 +496,28 @@ export interface GitGroveApi { optimizeRepo(repoPath: string): Promise /** Sum of the on-disk sizes (bytes) of the given repo-relative paths. */ selectionSize(repoPath: string, paths: string[]): Promise + // ── AI assist ── + /** The connected AI backend (metadata only — the key stays in main), or null. */ + aiStatus(): Promise + /** + * Verify an AI backend live (a real models call) and save it on success — + * `ok` always means "ready to generate". The key crosses the bridge exactly + * once, here, and is stored encrypted (safeStorage), like account tokens. + */ + aiConnect(input: AiConnectInput): Promise + /** Switch the model used for generations (one of aiStatus().models). */ + aiSetModel(model: string): Promise + /** Forget the AI backend and its key. */ + aiDisconnect(): Promise + /** + * Generate a commit/stash message for the checkbox selection. Tokens stream + * in via onAiChunk (matched by `requestId`); resolves with the complete + * message. Rejects with a human-readable message on failure — except + * cancellation (aiCancel), which resolves with what was generated so far. + */ + aiCommitMessage(repoPath: string, request: AiCommitRequest): Promise + /** Cancel a running generation by its requestId. */ + aiCancel(requestId: string): Promise // ── Clone ── /** * Clone `url` into `targetPath` — the exact directory the new repo lands in @@ -553,6 +591,10 @@ export interface GitGroveApi { onAccountReposPage(handler: (page: RemoteRepoPage) => void): () => void /** Subscribe to determinate progress of running checkout/fetch/pull/push ops. */ onOpProgress(handler: (progress: OpProgress) => void): () => void + /** Subscribe to streamed pieces of running AI generations. */ + onAiChunk(handler: (chunk: AiChunk) => void): () => void + /** Subscribe to AI backend changes (connected/model switched/disconnected). */ + onAiChanged(handler: () => void): () => void /** Subscribe to auto-update lifecycle pushes. Returns an unsubscribe fn. */ onUpdateStatus(handler: (status: UpdateStatus) => void): () => void } diff --git a/src/shared/types.ts b/src/shared/types.ts index 1fe9450..c891c74 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -809,6 +809,98 @@ export interface AppInfo { repoUrl: string } +// ── AI assist ──────────────────────────────────────────────────────────────── + +/** + * AI backends GitGrove can talk to. GitGrove ships no AI service of its own — + * the user brings their own endpoint and key. `openai`, `litellm`, `ollama` + * and `custom` all speak the OpenAI-compatible chat API (they differ only in + * default endpoint and whether a key is required); `anthropic` and `gemini` + * speak their native dialects. + */ +export type AiProvider = 'openai' | 'anthropic' | 'gemini' | 'litellm' | 'ollama' | 'custom' + +/** + * The connected AI backend as the renderer sees it: metadata only. The API + * key stays in the main process (encrypted at rest via safeStorage) and never + * crosses the IPC boundary — the same rule as account tokens. + */ +export interface AiStatus { + provider: AiProvider + /** Model used for generations, changeable from the settings pane. */ + model: string + /** Models the endpoint offered at connect time, for the model picker. */ + models: string[] + /** Endpoint base URL when it isn't the provider's default (litellm/ollama/custom). */ + baseUrl: string | null + /** + * False when OS-level encryption was unavailable (no Linux keyring): the + * key is kept in memory for this session only, never written to disk. + */ + persisted: boolean +} + +/** + * What the settings pane submits to connect a backend. The key crosses the + * IPC boundary exactly once, here, and is never readable back. + */ +export interface AiConnectInput { + provider: AiProvider + /** Required for litellm/custom, prefilled for ollama; ignored elsewhere. */ + baseUrl?: string + /** Optional for keyless endpoints (ollama, an open proxy). */ + apiKey?: string +} + +/** Why an AI call failed — stable codes the UI maps to copy. */ +export type AiErrorCode = + | 'unauthorized' + | 'network' + | 'bad-endpoint' + | 'provider-error' + | 'cancelled' + +/** + * Outcome of connecting an AI backend. The endpoint is verified live (a real + * models call) before anything is saved, so `ok` always means "ready to + * generate" — never "saved but untested". + */ +export type AiConnectResult = + | { ok: true; status: AiStatus } + | { ok: false; code: AiErrorCode; detail?: string } + +/** The commit-message generator's style options (the ✨ popover). */ +export interface AiCommitOptions { + length: 'short' | 'medium' | 'long' + tone: 'technical' | 'formal' | 'informal' | 'friendly' + emojis: boolean +} + +/** + * What the composer sends to generate a commit/stash message: the checkbox + * selection as data — fully included files plus the standalone hunk patches of + * partially included ones — so the message describes exactly what will be + * committed, never the whole working tree. + */ +export interface AiCommitRequest { + /** Correlates streamed AiChunk pushes and cancellation with this call. */ + requestId: string + /** Fully included files (diffs are read in main, with size caps). */ + files: ChangedFile[] + /** Hunk patches (HEAD → working tree) of partially included files. */ + patches: string[] + /** Amend folds HEAD's message in; stash asks for a short label instead. */ + mode: 'commit' | 'amend' | 'stash' + options: AiCommitOptions +} + +/** One streamed piece of a running generation, pushed while it runs. */ +export interface AiChunk { + requestId: string + /** Text appended by this chunk. */ + text: string +} + /** Lifecycle of an auto-update check, pushed from main to the renderer. */ export type UpdateState = | 'checking' From 090b70c553d5844981a563ce6fce6ca75568bc85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 11:34:39 +0200 Subject: [PATCH 2/4] Improve AI provider feedback and document AI features Surface provider error bodies through AiRequestError, keep the Anthropic version header for keyless requests, and cap commit prompt file summaries. Add docs/ai-features.md and make Icon.Sparkle use a collision-free gradient mark. --- docs/ai-features.md | 172 ++++++++++++++++++++++++++++++++++ src/main/ai/client.ts | 22 ++++- src/main/ai/commit-context.ts | 25 +++-- src/main/ai/providers.test.ts | 25 +++++ src/main/ai/providers.ts | 39 +++++++- 5 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 docs/ai-features.md diff --git a/docs/ai-features.md b/docs/ai-features.md new file mode 100644 index 0000000..1bd258c --- /dev/null +++ b/docs/ai-features.md @@ -0,0 +1,172 @@ +# AI in GitGrove — where it shines + +Premise: **silly-simple UI, beast of an engine, bring-your-own AI backend.** No GitGrove +AI service, no bill. The user points GitGrove at any OpenAI-compatible endpoint +(OpenAI, Anthropic, Gemini, LiteLLM, Ollama, OpenRouter, a corporate proxy) and every +feature below lights up. No key configured → the features simply don't exist in the UI. +No nagging, no upsell. + +The killer insight for a git client: **the "RAG" is already there — it's the repo.** +`git log`, `blame`, `fileHistory`, `patch-id`, `merge-tree` give exact, deterministic +context. No vector DB, no indexing service. The main-process git layer already exposes +everything needed; AI features are mostly *prompt assembly over existing reads*. + +--- + +## Tier 1 — daily drivers (do these first) + +### 1. Commit message generation ✨ +**Where:** `CommitComposer` (Changes tab). +**UX:** one sparkle button (or auto ghost-text in the empty summary field, Tab to accept). +**Engine — why ours beats everyone else's:** +- Generate from **exactly the selected hunks**, not the whole working tree. + `lib/staging.ts` already renders the checkbox state to unified patches — feed those. + No other client gets this right. +- **Style matching:** include the last ~30 commit subjects from `getLog` so the message + matches the repo's convention (conventional commits, ticket prefixes, mood) without a + single setting. +- Amend mode: include the previous message (`lastCommitMessage`) as the base to refine. + +### 2. Branch name from pending changes +**Where:** `CreateBranchDialog` — exactly the flow you described. The dialog already +handles dirty state via `PendingChangesChoice` (bring/leave changes). +**UX:** the name field is prefilled with a suggested slug (`fix/stash-panel-empty-state`) +as placeholder ghost text; typing replaces it, Enter accepts it. +**Engine:** working diff summary → slug, constrained by `validateRefName` and the repo's +observed naming style (scan existing branch names for `feat/`-vs-`feature/` conventions). + +### 3. AI conflict resolution (the crown jewel) +**Where:** `ConflictPanel`. Also `MergeDialog` — `mergePreview` (dry-run `merge-tree`) +already knows the conflicted paths *before* merging, so the dialog can say +*"3 conflicts — AI can propose resolutions"* up front. +**UX:** one button per conflicted file: **"Resolve with AI"**. The proposal appears in +the existing three-way viewer as a fourth "proposed" side with a one-line rationale per +conflict region. Accept writes the file + `markResolved`. **Never auto-applies.** +**Engine — repo history as context, all from existing reads:** +- `conflictSides` → base / ours / theirs content +- `getLog ours..theirs -- ` → the commits (messages + diffs) that *created* each + side — this is what a human reads to resolve, and no GUI client does it +- `getBlame` on the conflicted region → who touched it and why +- `getFileHistory` → recent evolution of the file +Same engine reused for cherry-pick, rebase, revert and stash-apply conflicts for free +(they all flow through the same `RepoState` + `ConflictPanel`). + +### 4. "Explain this" — commits and diffs +**Where:** context menu / one icon in `DiffViewer`, `CommitSummary`, and the Graph +detail pane. +**UX:** hover-card or side-note: what changed, why it likely changed, what to watch out +for. Works on a commit, a file diff, or the whole working tree. +**Engine:** `commitDiff`/`workingDiff` + the commit message + touched files' recent +history. Streamed, cached per commit hash (immutable → cache forever). + +--- + +## Tier 2 — power features that stay silly-simple + +### 5. Interactive rebase copilot +**Where:** `InteractiveRebaseDialog`. +**UX:** one button: **"Clean up"** → proposes a todo list (squash the three "fix typo" +commits into their parent, reword vague messages, reorder safely). Shown in the existing +drag-and-drop editor for review; user tweaks, then runs. +**Engine:** commit list + diffs; `patch-id` data already detects duplicate changes. +Rewording reuses the message generator (#1) per commit. + +### 6. PR title + description on push +**Where:** the push flow / `SyncButton` (PR state already surfaced via +`pullRequestsForBranches`). +**UX:** after pushing a branch with no PR: *"Create PR"* with title/body prefilled from +the branch's commits. Edit, confirm, done. +**Engine:** `getUnpushedCommits` / `rangeDiff` against the base branch; GitHub account +integration already exists for the API call. + +### 7. Ask your repo (semantic history search) +**Where:** the History tab search box — same box, no new UI. A natural-language query +("when did the retry logic change and why?") just works. +**Engine:** agentic, not embeddings: the model translates the question into `read.ts` +queries (`log -S`, `-G`, `--follow`, blame), runs them, and answers **with commit +citations** that link into the History view. Zero indexing, zero storage, works on any +repo instantly. + +### 8. Blame "why?" +**Where:** `BlamePane` line context menu. +**UX:** "Why is this line like this?" → short answer built from the blame chain. +**Engine:** the reblame walk already exists; feed the chain of commits + messages + +diffs for that line. This turns blame from *who* into *why*. + +### 9. Error → plain English + one fix +**Where:** `ErrorDialog` and op-failure banners. +**UX:** git's stderr ("non-fast-forward", "refusing to merge unrelated histories") +becomes one human sentence plus **one** suggested action button (pull --rebase, force +push with lease…). Destructive suggestions still confirm once, per house rules. +**Engine:** stderr + repo state snapshot. Small, cheap, huge perceived quality. + +### 10. Pre-push review +**Where:** a quiet action on unpushed commits ("Review before push"). +**UX:** flags leftover debug prints, secrets/keys, accidental large or generated files, +obvious bugs — as dismissible notes, never a gate. +**Engine:** `getUnpushedCommits` + `rangeDiff`. Secrets check can be regex-first +(free, offline) with AI only for the fuzzy cases. + +### 11. Auto-named stashes +**Where:** `stashSave` (Stash mode in `CommitComposer`, auto-stash on switch). +**UX:** invisible. Stashes are just… named ("wip: half-migrated GraphToolbar filters") +instead of "WIP on main". `StashPanel` becomes readable for free. + +### 12. Release notes from the Graph +**Where:** Graph tab — release lines and tags are already first-class (`releases.ts`, +backport twin detection). +**UX:** right-click a release line / tag range → "Draft release notes" → grouped, +human-readable changelog in a copyable panel. +**Engine:** `rangeFiles` + commits between tags; backport links annotate what was +already shipped in maintenance lines. + +### 13. .gitignore suggestions +**Where:** the untracked section of `WorkingFileList`. +**UX:** when build junk floods untracked files: one banner (one button, per house +rules) — "Ignore build artifacts?" → proposed patterns via existing `ignorePatterns`. + +--- + +## Architecture (mirrors the git layer's philosophy) + +``` +src/main/ai/ + provider.ts one entry point, like exec.ts is for git — streaming chat completion + adapters/ openai-compatible (covers LiteLLM/Ollama/Gemini-compat/OpenRouter), anthropic + context/ prompt assembly from read.ts outputs (pure, unit-testable, size-capped) + features/ one file per feature: commit-message.ts, conflict.ts, branch-name.ts … +``` + +- **Keys in main only**, encrypted with `safeStorage` — exactly like OAuth tokens today. + The renderer never sees the key or talks to the provider. +- **IPC:** follow the spine. `aiCapabilities`, `aiGenerate(feature, args)` + + `onAiToken` streaming push, modeled on `onOpProgress`. Cancellable. +- **Settings:** a fourth pane in `SettingsDialog` — *AI* next to Accounts / Identity / + Appearance. Three fields: base URL, API key, model. A "Test" button. That's the whole + config. Optional per-repo "never send this repo's code" toggle for private work. +- **Context building is pure functions** over `read.ts` output with hard size caps + (same discipline as `MAX_PATCH_BYTES`) → unit-testable without any network, per the + testing rules. +- **Perf:** all context reads use the existing lock-free read side; AI calls never touch + the write queue; suggestions stream so perceived latency ≈ 0; immutable inputs + (commit hashes) cache forever. + +## UX laws for every AI feature + +1. **AI proposes, the user disposes.** No AI output ever reaches git without an accept. +2. **One affordance per surface** — a single ✨ button or ghost text, never a panel of knobs. +3. **Always visible, never nagging.** The ✨ affordance shows even with no backend + connected — clicking it opens a small teaser (one pitch, one "Set up AI…" button that + deep-links to Settings → AI). People can't want what they can't see; but AI never + interrupts, only answers a click. +4. **Degrade silently.** Endpoint down → a calm toast on demand; git never waits on AI. +5. **Streaming always** — ghost text and proposals render token-by-token. + +## Suggested build order + +1. Settings pane + `src/main/ai/` provider layer (unlocks everything) +2. Commit messages (#1) — highest daily value, simplest context +3. Branch names (#2) + stash names (#11) — same machinery, nearly free +4. Explain this (#4) + error explainer (#9) — read-only, low risk +5. Conflict resolution (#3) — the differentiator; ship when it's *great* +6. Rebase copilot (#5), PR descriptions (#6), ask-your-repo (#7), the rest diff --git a/src/main/ai/client.ts b/src/main/ai/client.ts index b798e70..6510430 100644 --- a/src/main/ai/client.ts +++ b/src/main/ai/client.ts @@ -10,6 +10,7 @@ import { buildModelsRequest, type ChatMessage, extractStreamText, + parseErrorMessage, parseModelList, pickDefaultModel, type WireRequest @@ -30,12 +31,20 @@ const VERIFY_TIMEOUT_MS = 15_000 /** Ceiling on one generation — a stuck stream must not spin forever. */ const GENERATION_TIMEOUT_MS = 90_000 -function classifyHttp(status: number): AiRequestError { +/** + * Turn a failed response into a stable code plus the endpoint's OWN words — + * "HTTP 400" helps nobody, "max_tokens: field required" fixes itself. + */ +function classifyHttp(status: number, bodyText: string): AiRequestError { + const detail = parseErrorMessage(bodyText) if (status === 401 || status === 403) - return new AiRequestError('unauthorized', 'The endpoint rejected the API key.') + return new AiRequestError('unauthorized', detail ?? 'The endpoint rejected the API key.') if (status === 404) - return new AiRequestError('bad-endpoint', 'No compatible API at that address.') - return new AiRequestError('provider-error', `The endpoint answered with HTTP ${status}.`) + return new AiRequestError('bad-endpoint', detail ?? 'No compatible API at that address.') + return new AiRequestError( + 'provider-error', + detail ? `The endpoint answered: ${detail}` : `The endpoint answered with HTTP ${status}.` + ) } async function send(request: WireRequest, signal: AbortSignal): Promise { @@ -54,7 +63,10 @@ async function send(request: WireRequest, signal: AbortSignal): Promise '') + throw classifyHttp(response.status, bodyText) + } return response } diff --git a/src/main/ai/commit-context.ts b/src/main/ai/commit-context.ts index 612173c..d535ec0 100644 --- a/src/main/ai/commit-context.ts +++ b/src/main/ai/commit-context.ts @@ -8,19 +8,24 @@ import type { AiCommitRequest, ChangedFile } from '@shared/types' import { getLog, getWorkingDiff } from '../git/read' import { capPatch, type CommitPromptInput, DIFF_CAPS } from './commit-prompt' +/** Listed individually in the summary; a 90k-file selection must not become a + * 90k-line prompt (the endpoint would reject it as too long). */ +const MAX_SUMMARY_FILES = 300 + /** `status\tpath` per file — cheap orientation even for files whose diff * didn't fit the budget (binary, huge, or beyond maxFiles). */ export function summarizeFiles(files: ChangedFile[]): string { - return files - .map((f) => { - const name = f.oldPath ? `${f.oldPath} → ${f.path}` : f.path - const counts = - f.insertions !== undefined || f.deletions !== undefined - ? ` (+${f.insertions ?? 0} −${f.deletions ?? 0})` - : '' - return `${f.status}\t${name}${counts}` - }) - .join('\n') + const lines = files.slice(0, MAX_SUMMARY_FILES).map((f) => { + const name = f.oldPath ? `${f.oldPath} → ${f.path}` : f.path + const counts = + f.insertions !== undefined || f.deletions !== undefined + ? ` (+${f.insertions ?? 0} −${f.deletions ?? 0})` + : '' + return `${f.status}\t${name}${counts}` + }) + const overflow = files.length - MAX_SUMMARY_FILES + if (overflow > 0) lines.push(`…and ${overflow} more files`) + return lines.join('\n') } export async function gatherCommitContext( diff --git a/src/main/ai/providers.test.ts b/src/main/ai/providers.test.ts index cc80f9d..01ee152 100644 --- a/src/main/ai/providers.test.ts +++ b/src/main/ai/providers.test.ts @@ -4,6 +4,7 @@ import { buildChatRequest, buildModelsRequest, extractStreamText, + parseErrorMessage, parseModelList, pickDefaultModel, resolveBaseUrl @@ -54,6 +55,14 @@ describe('buildChatRequest', () => { expect(req.headers.Authorization).toBeUndefined() }) + test('anthropic keeps its protocol version header even with no key', () => { + // The version header is protocol, not auth: dropping it with the key + // turns "reconnect your key" (401) into an inscrutable 400. + const req = buildChatRequest(endpoint({ provider: 'anthropic', apiKey: null }), messages) + expect(req.headers['anthropic-version']).toBe('2023-06-01') + expect(req.headers['x-api-key']).toBeUndefined() + }) + test('anthropic: x-api-key + version headers, system extracted', () => { const req = buildChatRequest( endpoint({ provider: 'anthropic', baseUrl: 'https://api.anthropic.com/v1' }), @@ -139,6 +148,22 @@ describe('pickDefaultModel', () => { }) }) +describe('parseErrorMessage', () => { + test('every dialect answers { error: { message } }', () => { + expect(parseErrorMessage('{"error":{"message":"max_tokens: field required"}}')).toBe( + 'max_tokens: field required' + ) + expect(parseErrorMessage('{"error":"model overloaded"}')).toBe('model overloaded') + expect(parseErrorMessage('{"message":"proxy says no"}')).toBe('proxy says no') + }) + + test('plain-text bodies pass through (truncated), HTML gateway pages do not', () => { + expect(parseErrorMessage('upstream timeout')).toBe('upstream timeout') + expect(parseErrorMessage('502')).toBeNull() + expect(parseErrorMessage('')).toBeNull() + }) +}) + describe('extractStreamText', () => { test('openai-compatible deltas', () => { expect( diff --git a/src/main/ai/providers.ts b/src/main/ai/providers.ts index c57234e..0048f86 100644 --- a/src/main/ai/providers.ts +++ b/src/main/ai/providers.ts @@ -103,16 +103,22 @@ export function resolveBaseUrl(provider: AiProvider, input?: string): string | n return trimmed || AI_PROVIDERS[provider].defaultBaseUrl } -/** Auth headers per dialect; empty when the endpoint runs keyless. */ +/** + * Per-dialect auth + protocol headers. The anthropic-version header is part + * of the PROTOCOL, not the auth: it must ride along even when the key is + * missing (keyless proxy, undecryptable stored key), or Anthropic answers + * 400 "anthropic-version required" instead of the honest 401 that routes the + * user to reconnect. + */ function authHeaders(endpoint: Pick): Record { - if (!endpoint.apiKey) return {} + const key = endpoint.apiKey switch (endpoint.provider) { case 'anthropic': - return { 'x-api-key': endpoint.apiKey, 'anthropic-version': '2023-06-01' } + return { 'anthropic-version': '2023-06-01', ...(key ? { 'x-api-key': key } : {}) } case 'gemini': - return { 'x-goog-api-key': endpoint.apiKey } + return key ? { 'x-goog-api-key': key } : {} default: - return { Authorization: `Bearer ${endpoint.apiKey}` } + return key ? { Authorization: `Bearer ${key}` } : {} } } @@ -213,6 +219,29 @@ export function pickDefaultModel(provider: AiProvider, models: string[]): string return models[0] ?? '' } +/** + * The endpoint's own error message out of a failed response body, or null. + * All three dialects (and every OpenAI-compatible proxy) answer errors as + * `{ error: { message } }` — surfacing it turns "HTTP 400" into "max_tokens: + * …" and makes misconfigurations self-explanatory. + */ +export function parseErrorMessage(bodyText: string): string | null { + try { + const parsed = JSON.parse(bodyText) as Record + const error = parsed?.error as Record | string | undefined + if (typeof error === 'string' && error) return error + if (error && typeof error === 'object' && typeof error.message === 'string' && error.message) + return error.message + if (typeof parsed?.message === 'string' && parsed.message) return parsed.message + } catch { + // Not JSON — an HTML gateway page or plain text; a short raw excerpt + // still beats a bare status code. + const trimmed = bodyText.trim() + if (trimmed && !trimmed.startsWith('<')) return trimmed.slice(0, 200) + } + return null +} + /** * The text a single SSE event contributes to the generation, or '' for * bookkeeping events (role deltas, stop reasons, pings). `data` is the parsed From 73760578989ad7a6027de31c3d3f4e93e248c73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 11:39:03 +0200 Subject: [PATCH 3/4] Improve the AI icon --- src/renderer/src/lib/icons.tsx | 48 +++++++++++++++---------- src/renderer/src/styles/features/ai.css | 9 ++--- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/lib/icons.tsx b/src/renderer/src/lib/icons.tsx index 92d7a6d..4c5ee47 100644 --- a/src/renderer/src/lib/icons.tsx +++ b/src/renderer/src/lib/icons.tsx @@ -1,7 +1,7 @@ // Minimal inline SVG icon set (stroke-based, 1.6px) so the app ships with no // icon-font dependency. Each icon inherits `currentColor`. -import type { SVGProps } from 'react' +import { type SVGProps, useId } from 'react' type IconProps = SVGProps & { size?: number } @@ -249,23 +249,35 @@ export const Icon = { ), - // The modern AI glyph: a four-point sparkle with concave (pinched) edges — - // each side curves toward the center via a control point AT the center, the - // trick behind the Gemini/Copilot "shimmer" star — paired with a smaller - // companion spark for that premium, twinkly feel. Filled with soft (round) - // tips rather than a thin outline so it reads as a jewel, not a doodle. - Sparkle: (p: IconProps) => ( - - - - - ), + // The AI mark — the ONE deliberate colored exception in this currentColor + // icon set. AI is the app's signature "wow" feature, so it earns a signature + // gradient: one bold four-point sparkle plus a small companion spark. The big + // star nearly fills the 24-box (like every other nav icon) so it never reads + // as the runt of the row, yet its sides pinch enough (control points at ~⅓ of + // the radius on each diagonal) to stay a crisp *sparkle* rather than a fat + // diamond. The gradient runs cyan → app-blue → magenta on the TL→BR diagonal: + // a WIDE hue spread with high contrast so it reads as an actual gradient even + // at 14px (a narrow blue→purple ramp just averages to flat indigo at that + // size). The cyan end bridges toward the app-icon palette (green→blue→indigo) + // and the blue midpoint echoes the accent, tying the mark to the brand while + // the magenta says "AI". Ignores `currentColor` on purpose; a fresh `useId` + // per instance keeps the gradient collision-free wherever it renders. + Sparkle: ({ size = 16, ...props }: IconProps) => { + const id = `ai-spark-${useId().replace(/:/g, '')}` + return ( + + + + + + + + + + + + ) + }, Sun: (p: IconProps) => ( diff --git a/src/renderer/src/styles/features/ai.css b/src/renderer/src/styles/features/ai.css index bfb403e..40179a3 100644 --- a/src/renderer/src/styles/features/ai.css +++ b/src/renderer/src/styles/features/ai.css @@ -23,14 +23,15 @@ background: var(--accent-soft); color: var(--accent); } -/* The sparkle earns its "wow" on approach: a soft accent glow + a one-shot - twinkle (scale/rotate pop). Motion lives on the glyph, not the button, so - the hit target stays calm. */ +/* The gradient sparkle (Icon.Sparkle — the one colored icon) sits at ease at + rest, then earns its "wow" on approach: a soft violet glow keyed to the mark + itself + a one-shot twinkle (scale/rotate pop). Motion lives on the glyph, + not the button, so the hit target stays calm. */ .ai-btn svg { transition: filter 0.2s ease; } .ai-btn:hover svg { - filter: drop-shadow(0 0 5px var(--accent-border)); + filter: drop-shadow(0 0 5px rgba(139, 92, 246, 0.55)); animation: ai-twinkle 0.55s ease; } .ai-btn.is-generating { From 6b7d260c64475c1c3e97bfc19eef9b3762dc3bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 11:50:25 +0200 Subject: [PATCH 4/4] Apply Biome formatting to AI files --- src/main/ai/commit-context.ts | 2 +- src/main/ai/commit-prompt.test.ts | 2 +- src/main/ai/providers.test.ts | 8 ++------ src/main/ai/providers.ts | 4 +--- .../src/components/changes/AiComposerControls.tsx | 6 +++--- src/renderer/src/lib/ai.ts | 5 ++++- 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/main/ai/commit-context.ts b/src/main/ai/commit-context.ts index d535ec0..89c9f96 100644 --- a/src/main/ai/commit-context.ts +++ b/src/main/ai/commit-context.ts @@ -6,7 +6,7 @@ import type { AiCommitRequest, ChangedFile } from '@shared/types' import { getLog, getWorkingDiff } from '../git/read' -import { capPatch, type CommitPromptInput, DIFF_CAPS } from './commit-prompt' +import { type CommitPromptInput, capPatch, DIFF_CAPS } from './commit-prompt' /** Listed individually in the summary; a 90k-file selection must not become a * 90k-line prompt (the endpoint would reject it as too long). */ diff --git a/src/main/ai/commit-prompt.test.ts b/src/main/ai/commit-prompt.test.ts index e7f40be..eca6787 100644 --- a/src/main/ai/commit-prompt.test.ts +++ b/src/main/ai/commit-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { AiCommitOptions } from '@shared/types' -import { buildCommitPrompt, capPatch, type CommitPromptInput } from './commit-prompt' +import { buildCommitPrompt, type CommitPromptInput, capPatch } from './commit-prompt' const options = (over: Partial = {}): AiCommitOptions => ({ length: 'medium', diff --git a/src/main/ai/providers.test.ts b/src/main/ai/providers.test.ts index 01ee152..7b3509d 100644 --- a/src/main/ai/providers.test.ts +++ b/src/main/ai/providers.test.ts @@ -102,9 +102,7 @@ describe('buildModelsRequest', () => { }) test('gemini pages wide so small models are not cut off', () => { - const req = buildModelsRequest( - endpoint({ provider: 'gemini', baseUrl: 'https://g/v1beta' }) - ) + const req = buildModelsRequest(endpoint({ provider: 'gemini', baseUrl: 'https://g/v1beta' })) expect(req.url).toBe('https://g/v1beta/models?pageSize=200') }) }) @@ -166,9 +164,7 @@ describe('parseErrorMessage', () => { describe('extractStreamText', () => { test('openai-compatible deltas', () => { - expect( - extractStreamText('openai', { choices: [{ delta: { content: 'Fix ' } }] }) - ).toBe('Fix ') + expect(extractStreamText('openai', { choices: [{ delta: { content: 'Fix ' } }] })).toBe('Fix ') expect(extractStreamText('openai', { choices: [{ delta: { role: 'assistant' } }] })).toBe('') }) diff --git a/src/main/ai/providers.ts b/src/main/ai/providers.ts index 0048f86..1d04a8e 100644 --- a/src/main/ai/providers.ts +++ b/src/main/ai/providers.ts @@ -273,8 +273,6 @@ export function extractStreamText(provider: AiProvider, data: unknown): string { // OpenAI-compatible: choices[0].delta.content. const choices = obj.choices if (!Array.isArray(choices) || !choices[0]) return '' - const delta = (choices[0] as Record).delta as - | Record - | undefined + const delta = (choices[0] as Record).delta as Record | undefined return typeof delta?.content === 'string' ? delta.content : '' } diff --git a/src/renderer/src/components/changes/AiComposerControls.tsx b/src/renderer/src/components/changes/AiComposerControls.tsx index f7f9833..fcc233d 100644 --- a/src/renderer/src/components/changes/AiComposerControls.tsx +++ b/src/renderer/src/components/changes/AiComposerControls.tsx @@ -172,9 +172,9 @@ export function AiComposerControls({ {verb} with AI

- Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — your - key, sent only to your provider. GitGrove writes the message from exactly the changes - you selected. + Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — your key, + sent only to your provider. GitGrove writes the message from exactly the changes you + selected.