Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
@@ -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 -- <file>` → 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
6 changes: 5 additions & 1 deletion src/main/accounts/cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/main/ai/cipher.ts
Original file line number Diff line number Diff line change
@@ -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
}
151 changes: 151 additions & 0 deletions src/main/ai/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// 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,
parseErrorMessage,
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

/**
* 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', detail ?? 'The endpoint rejected the API key.')
if (status === 404)
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<Response> {
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) {
const bodyText = await response.text().catch(() => '')
throw classifyHttp(response.status, bodyText)
}
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<AiEndpoint, 'model'>
): 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<string> {
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()
}
Loading
Loading