Skip to content

feat(source-control): AI commit message generation#1487

Open
leynier wants to merge 13 commits intostablyai:mainfrom
leynier:feat/source-control-ai-commit-messages
Open

feat(source-control): AI commit message generation#1487
leynier wants to merge 13 commits intostablyai:mainfrom
leynier:feat/source-control-ai-commit-messages

Conversation

@leynier
Copy link
Copy Markdown
Contributor

@leynier leynier commented May 6, 2026

Closes #1484.

Summary

Adds an opt-in "Generate with AI" affordance inside the commit area of the Source Control panel. The feature ships Claude Code and Codex as preset agents and a Custom option for any other CLI the user wants to use (Ollama, OpenCode, gateway proxy, etc.).

UX

  • A small Sparkles icon sits inside the commit textarea (top-right). It only mounts when the user opts into the feature in Settings → Git → AI Commit Messages.
  • Click → spinner replaces the icon while generating; hovering or focusing the spinner swaps it for a stop icon (destructive tint) so the run can be canceled mid-stream.
  • The button is disabled while the textarea has content (with a tooltip explaining why), so generation never overwrites in-progress edits.
  • The generated message is dropped into the textarea only if it is still empty when the agent returns - race-protected against the user typing during generation.
  • SSH parity: works identically on local and SSH-connected worktrees. The agent runs on whichever host owns the worktree (matches how Orca already runs interactive TUI agents).

Settings (nested under Git)

  • Toggle to enable the feature (off by default).
  • Agent dropdown with the agent's icon + label. Includes a Custom entry that swaps the model/thinking dropdowns for a single command-line field.
  • Model dropdown per agent.
  • Thinking effort dropdown per model (only rendered when the model declares supported levels - Claude Haiku is non-reasoning, Codex Spark is, etc.).
  • Custom prompt textarea for style overrides (Conventional Commits, gitmoji, ticket prefixes, etc.).
  • Custom command field (only when agent = Custom): a single-line input with {prompt} as the placeholder. Argv-based, never shell-evaluated; {prompt} and "{prompt}" produce identical argv.
  • Defaults pick the smallest model and lowest effort: Claude Haiku 4.5 with no effort, or gpt-5.4-mini with low if the user switches to Codex.

Architecture

  • src/shared/commit-message-agent-spec.ts - preset registry (Claude + Codex with the 3 + 6 models from each CLI's official picker).
  • src/shared/commit-message-prompt.ts - base prompt, custom-suffix builder, diff truncation (200 KB), agent-error JSON extractor (parses ERROR: {...} lines so Codex/Claude failures surface a legible message instead of dumping the runtime preamble), and the POSIX-style tokenizer for custom commands (groups via single/double quotes; does not expand $VAR, backticks, or globs).
  • src/shared/commit-message-plan.ts - single planner shared by the local generator and the SSH provider. Validates agent/model/thinking, builds argv via the spec's buildArgs, and returns a unified plan ({ binary, args, stdinPayload, label }).
  • src/main/git/commit-message-generator.ts - local spawn with timeout (60 s), tree-kill on cancel/timeout (taskkill /T /F on Windows because npm-installed CLIs are .cmd shims), per-worktree cancel-token map.
  • src/relay/agent-exec-handler.ts - new JSON-RPC method agent.execNonInteractive for non-PTY exec on the remote, plus agent.cancelExec keyed by cwd. Mirrors the local cancel/tree-kill semantics on the remote host.
  • src/main/providers/ssh-git-provider.ts - orchestrates remote diff fetch + relay request via the shared planner.
  • src/main/ipc/filesystem.ts - IPC handlers git:generateCommitMessage and git:cancelGenerateCommitMessage (both branch on connectionId → SSH provider vs local). The IPC reads enableGitHubAttribution from the persisted store and applies the existing Orca trailer to the generated message when the toggle is on (paritying the terminal git shim's behavior).
  • src/shared/orca-attribution.ts - single source of truth for Co-authored-by: Orca <help@stably.ai>, shared between the terminal shim and the AI commit generator.
  • src/renderer/src/components/right-sidebar/SourceControl.tsx - Generate / Cancel UI inside CommitArea plus per-worktree state; mirrors the existing commit-in-flight pattern.
  • src/renderer/src/components/settings/CommitMessageAiPane.tsx - rendered nested inside the Git settings section.

Notes

  • All settings flow through the existing updateSettings IPC, which debounces disk writes via persistence.ts:300ms - no extra debounce needed in the textareas.
  • Tokenizer is intentionally not full-shell. $VAR, command substitution, and globs are not expanded - keeps cross-platform behavior predictable and avoids a shell-evaluation surface we don't need.
  • Tree-kill is duplicated between commit-message-generator.ts (local) and agent-exec-handler.ts (relay) by design: the relay is a separate runtime that ships independently to remote hosts and can't share main-process modules.

Test plan

  • pnpm typecheck, pnpm lint, pnpm test clean for the new files (55 unit tests covering spec, prompt builder, error extractor, tokenizer, planner, and the CommitArea component).
  • Local - Claude: stage changes, agent=Claude, model=Haiku (default). Click Generate → message lands in the textarea. Switch to Sonnet → confirm --model claude-sonnet-4-6 and the effort dropdown appear; rerun.
  • Local - Codex: agent=Codex, model=GPT-5.4 Mini, thinking=Low. Run; switch thinking to High and rerun. Try Spark - verify the "model not supported" error is surfaced legibly when the account tier doesn't permit it (the runtime preamble is no longer dumped).
  • Local - Custom: enter claude -p (no {prompt} → stdin) and codex exec {prompt} (argv). Both should produce the same draft as the equivalent preset. Verify {prompt} and "{prompt}" behave identically.
  • Cancel: start a generation, hover the spinner → swaps to stop icon → click → spinner clears, no error shown.
  • Disabled states: textarea with content → button disabled with tooltip; 0 staged files → disabled; settings off → button hidden.
  • SSH: open a worktree on an SSH host, configure agent, click Generate → message comes from the remote. If the agent CLI isn't installed remotely, the error explains which binary to install.
  • Custom prompt: set Use Conventional Commits. and verify the output starts with feat: / fix: / etc.
  • Orca Attribution: toggle on → generated message ends with Co-authored-by: Orca <help@stably.ai> (no trailing blank line).
  • Cross-platform smoke: Windows + macOS - execFile argv avoids shell quoting issues. Tree-kill on Windows verified to terminate the .cmd-wrapped child.

Screenshots

image image image image image

leynier added 12 commits May 5, 2026 18:44
Adds a "Generate with AI" button to the Source Control panel that drafts a
commit message from the staged diff using a non-interactive agent CLI. Ships
Claude Code and Codex; the spec abstraction is built so additional agents can
be added later by appending entries.

The agent + model + per-model thinking effort + custom prompt suffix are
configured under Settings → AI Commit Messages. The button only appears once
the user opts in, only enables when the textarea is empty, and routes through
the SSH relay (new agent.execNonInteractive RPC) when committing on a remote
worktree so the agent runs on the same host as the rest of the SSH session.

Defaults pick the smallest model and lowest effort: Claude Haiku 4.5 with
effort=low; if the user switches to Codex the default lands on gpt-5.4-mini
with reasoning-effort=low.
- Codex models now render in version-descending order (5.5 → 5.2) to match
  the official `codex` model picker.
- Swap thinking-effort support between Haiku 4.5 (removed — non-reasoning
  model) and gpt-5.3-codex-spark (added — accepts model_reasoning_effort).
- Generator extracts the actionable error from agent CLIs that print a
  multi-line preamble before failing (Codex Spark with a non-Pro account
  now surfaces "The 'gpt-5.3-codex-spark' model is not supported …" instead
  of dumping the full runtime banner).
- AI commit settings nest inside the existing Git section instead of
  living as a standalone Settings entry.
- Respect the existing Orca Attribution toggle: when on, the generator
  appends the same Co-authored-by trailer the terminal git/gh shim uses,
  via a single shared constant in src/shared/orca-attribution.ts.
- Add a space before the ellipsis in the "Generating …" button label.

Tests: extractAgentErrorMessage covers the Codex Spark JSON case, raw-payload
fallback, multiple ERROR lines, plain `Error:` prefix, and no-error input.
Spec tests assert the new model order, Haiku without effort, and Spark with
effort.
Replaces the dedicated full-width "Generate with AI" button below the
textarea with a small Sparkles icon button positioned absolutely in the
top-right of the textarea itself. Switches to a circular RefreshCw spinner
during generation and reserves right-padding on the textarea so typed text
does not slide under the icon. Disabled state, tooltips, and the underlying
handleGenerate flow are unchanged.
Adds a stop affordance on the in-textarea AI icon: while generating, the
spinning RefreshCw stays in place by default, but on hover/focus it swaps
to a filled Square ("stop") with a destructive tint. Clicking it cancels
the run.

The cancel path:
- Renderer calls window.api.git.cancelGenerateCommitMessage({worktreePath,
  connectionId}) — fire-and-forget; the in-flight generateCommitMessage
  promise resolves with `{success: false, canceled: true}` once the kill
  propagates, which clears the spinner and (because canceled is treated as
  intentional) leaves the inline error empty.
- Local: cancelGenerateCommitMessageLocal looks up the in-flight child
  process by worktreePath and SIGKILLs it.
- SSH: SshGitProvider sends `agent.cancelExec({cwd})` to the relay; the
  relay's AgentExecHandler tracks running children by cwd and SIGKILLs
  the matching one, returning the captured stdout/stderr with
  `canceled: true` so the SSH provider can map it to the same
  `{canceled: true}` response shape.

The renderer's handleGenerate detects the `canceled` flag and skips the
inline error, so a user-initiated cancel feels like a no-op rather than a
failure.
On Windows, npm-installed CLIs like `claude` and `codex` are `.cmd` shims
that Node spawns through an implicit `cmd.exe /d /s /c` wrapper. Calling
`child.kill('SIGKILL')` only terminates the cmd.exe wrapper while the real
node.exe doing the inference keeps running until completion — which is why
hitting Stop appeared to do nothing on Windows.

Adds a `killProcessTree(child)` helper that uses `taskkill /T /F` on
Windows (walks the tree from the spawned PID) and falls back to
`child.kill('SIGKILL')` on POSIX. Used in three places per side:
user cancel, generation timeout, and stdout/stderr buffer overflow. The
helper lives in both the local generator and the relay handler — kept
duplicated rather than shared because the relay is a separate runtime
that ships independently.

Also reword the Agent dropdown description in Settings → Git to make
clear that the agent CLI must be installed on whichever machine hosts
the worktree (laptop for local worktrees, SSH host for remote ones).
"Which agent CLI Orca runs in the background…" parsed ambiguously — the
"CLI Orca" could be read as a noun phrase ("Orca, which is a CLI"). Rewrite
so the subject is clear: "Which agent drafts your commit messages. Orca
invokes its CLI in the background…".
Adds a "Custom" entry to the AI commit messages agent dropdown. Selecting
it shows a single command-line field where the user types the binary plus
arguments to spawn for generation.

The placeholder is `{prompt}`. Substitution is argv-based, never
shell-evaluated:
- Without `{prompt}` → prompt is piped via stdin (matches `claude -p`).
- With `{prompt}` (bare or quoted) → substituted into that argv slot. Quotes
  in the template are tokenizer-only grouping, so `{prompt}` and "{prompt}"
  produce identical argv. No double-quoting risk.

Tokenizer is POSIX-shell-style for grouping only: single + double quotes,
backslash escapes inside double quotes. We do NOT expand `$VAR`, backticks,
or globs — keeps cross-platform behavior predictable.

Wiring:
- `planCustomCommand(template, prompt)` in shared/commit-message-prompt.ts
  produces `{ binary, args, stdinPayload }`.
- `runAgent` in commit-message-generator.ts is binary-agnostic; the local
  generator branches on `isCustomAgentId(params.agentId)` to either tokenize
  or look up a preset spec.
- SshGitProvider mirrors the branch — the relay's existing
  `agent.execNonInteractive` already accepts arbitrary `binary + args`,
  so no relay changes were needed.
- Settings pane hides the model and thinking dropdowns for "custom" and
  shows the command input with helper text explaining the placeholder.
- Generate button stays disabled until the user types a non-empty command.

Tests cover the tokenizer (whitespace, quotes, escapes, unclosed errors),
planCustomCommand (stdin-vs-argv routing, embedded substitution, equivalence
of `{prompt}` and "{prompt}"), and the new `isCustomAgentId` helper.
Replaces em-dashes with regular hyphens in:
- the base prompt sent to the agent
- the Agent and Custom command setting descriptions

Code comments keep em-dashes since they are not user-facing.
The local generator and the SSH provider duplicated the entire
spec/model/thinking validation chain plus the build-args logic — about
60 lines each, only differing in variable names. Extract into a single
`planCommitMessageGeneration()` in src/shared/commit-message-plan.ts that
returns a unified `CommitMessagePlan` (binary, args, stdinPayload, label).

This collapses runAgent's parameter sprawl too: it now takes the plan
object plus the runtime concerns (cwd, attributionEnabled) instead of
six positional args.

Net: ~150 lines removed; the SSH provider's generateCommitMessage shrinks
to just orchestration (diff fetch, plan, relay request, response mapping)
with no remaining duplication of the validation logic.

Also tighten one comment in cleanGeneratedCommitMessage that narrated
WHAT the code did instead of WHY.
- Add `cursor-pointer` to the Agent / Model / Thinking effort SelectItems
  in the AI Commit Messages settings pane. shadcn/ui's base SelectItem
  ships with `cursor-default`; without an override the items stayed on the
  arrow cursor instead of the expected hand pointer.
- Drop the trailing `\n` from `applyOrcaAttribution`. With it, the trailer
  ended with a newline that the commit textarea rendered as an empty line
  below the attribution. The blank line separating the body from the
  trailer block (required by `git interpret-trailers`) is preserved.
`applyOrcaAttribution` already returned the trailer without a final
newline, but the no-attribution branch passed the message straight
through. If the agent returned a final stray whitespace character that
slipped past `cleanGeneratedCommitMessage`, the textarea rendered an
empty line at the bottom.

Trim trailing whitespace on both paths and skip the trailer if the
already-trimmed message includes it.
@AmethystLiang AmethystLiang self-requested a review May 6, 2026 06:34
@AmethystLiang
Copy link
Copy Markdown
Contributor

Good idea! I'll check out the PR tmr

@AmethystLiang AmethystLiang requested review from brennanb2025 and removed request for AmethystLiang May 6, 2026 06:38
…-ai-commit-messages

# Conflicts:
#	src/main/ipc/filesystem.ts
#	src/renderer/src/components/right-sidebar/CommitArea.test.tsx
#	src/renderer/src/components/right-sidebar/SourceControl.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: AI-generated commit messages from staged changes in the Source Control panel

3 participants