From a7cc035b0bf7c608e4ec2fbf776ac25dc39ba310 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 16:26:03 +0100 Subject: [PATCH 01/14] feat: add commit message generation feature --- .../commit-message-implementation-plan.md | 623 ++++++++++++++++++ .../docs/commit-message-reimplementation.md | 455 +++++++++++++ packages/kilo-vscode/package.json | 13 + packages/kilo-vscode/src/extension.ts | 4 + .../src/services/cli-backend/http-client.ts | 12 + .../commit-message/__tests__/index.spec.ts | 193 ++++++ .../src/services/commit-message/index.ts | 67 ++ packages/opencode/src/cli/cmd/commit.ts | 48 ++ .../commit-message/__tests__/generate.test.ts | 165 +++++ .../__tests__/git-context.test.ts | 335 ++++++++++ .../opencode/src/commit-message/generate.ts | 123 ++++ .../src/commit-message/git-context.ts | 125 ++++ packages/opencode/src/commit-message/index.ts | 2 + packages/opencode/src/commit-message/types.ts | 27 + packages/opencode/src/index.ts | 2 + .../src/server/routes/commit-message.ts | 40 ++ packages/opencode/src/server/server.ts | 2 + 17 files changed, 2236 insertions(+) create mode 100644 packages/kilo-vscode/docs/commit-message-implementation-plan.md create mode 100644 packages/kilo-vscode/docs/commit-message-reimplementation.md create mode 100644 packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts create mode 100644 packages/kilo-vscode/src/services/commit-message/index.ts create mode 100644 packages/opencode/src/cli/cmd/commit.ts create mode 100644 packages/opencode/src/commit-message/__tests__/generate.test.ts create mode 100644 packages/opencode/src/commit-message/__tests__/git-context.test.ts create mode 100644 packages/opencode/src/commit-message/generate.ts create mode 100644 packages/opencode/src/commit-message/git-context.ts create mode 100644 packages/opencode/src/commit-message/index.ts create mode 100644 packages/opencode/src/commit-message/types.ts create mode 100644 packages/opencode/src/server/routes/commit-message.ts diff --git a/packages/kilo-vscode/docs/commit-message-implementation-plan.md b/packages/kilo-vscode/docs/commit-message-implementation-plan.md new file mode 100644 index 0000000000..235115fc5a --- /dev/null +++ b/packages/kilo-vscode/docs/commit-message-implementation-plan.md @@ -0,0 +1,623 @@ +# Commit Message Generation — Implementation Plan + +## 1. Overview + +This plan adds **LLM-powered commit message generation** to the Kilo platform with three surfaces: a **CLI command** (`kilo commit`), an **HTTP route** (`POST /commit-message`), and a **VS Code SCM panel button**. All three delegate to a shared core module that handles git context gathering, prompt building, and LLM interaction. + +### How this differs from the old implementation + +The old extension (kilocode-5) called LLMs directly from the extension process using `buildApiHandler()` and provider-specific handlers. This architecture uses a **shared backend module** instead — the backend handles model selection, prompt building, and LLM communication. The extension is a thin HTTP client, and the CLI command calls the core directly. + +| Aspect | Old extension | This architecture | +|--------|--------------|-------------------| +| LLM calls | Direct from extension → LLM provider | Shared core module in CLI backend | +| Auth | API keys stored in extension settings | OAuth/API key managed by CLI backend's `Auth` module | +| Model selection | User-configurable `commitMessageApiConfigId` | Automatic: `Provider.getSmallModel()` | +| Prompt location | In extension code | In shared core module | +| Surfaces | VS Code + JetBrains adapters | CLI command + HTTP route + VS Code | +| Git context | Gathered in extension | Gathered server-side in shared core | +| Prompt customization | Custom template override setting | Not in v1 | + +--- + +## 2. Backend Investigation + +Investigation of the CLI backend (`packages/opencode/`) revealed existing infrastructure that the commit message feature can reuse directly. + +### What EXISTS in the backend + +| Component | Location | Description | +|-----------|----------|-------------| +| `small_model` config | [`config.ts:1133`](../../packages/opencode/src/config/config.ts:1133) | Optional config field: `small_model: ModelId.describe("Small model to use for tasks like title generation").optional()` | +| `Provider.getSmallModel()` | [`provider.ts:1171`](../../packages/opencode/src/provider/provider.ts:1171) | Resolves small model with priority: user-configured → auto-detected → kilo fallback → undefined | +| Title generation | [`summary.ts:130`](../../packages/opencode/src/session/summary.ts:130) | Uses small_model + "title" agent — reference pattern | +| Agent prompts | `src/agent/prompt/` | Existing agents: ask, compaction, debug, explore, orchestrator, summary, title | +| Server routes | [`server.ts:227`](../../packages/opencode/src/server/server.ts:227) | Hono-based HTTP server with existing route patterns | +| `LLM.stream()` | Various | Streaming LLM infrastructure with auth already handled | +| CLI commands | [`src/cli/cmd/`](../../packages/opencode/src/cli/cmd/) | 18 commands using yargs + [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper | +| `bootstrap()` | [`bootstrap.ts:4`](../../packages/opencode/src/cli/bootstrap.ts:4) | Initializes project context so Provider/LLM APIs are available | + +### What does NOT exist + +- No commit message generation logic +- No generic chat completions endpoint +- No "commit" agent or prompt +- No `kilo commit` CLI command + +### Implications + +Because `Provider.getSmallModel()`, `LLM.stream()`, `bootstrap()`, and the agent prompt infrastructure already exist, the recommended approach is a **shared core module** that both the HTTP route and CLI command delegate to. The extension calls the HTTP route; the CLI command calls the core directly with no HTTP round-trip. + +--- + +## 3. Architecture + +### Three-Layer Design + +``` ++-----------------------------------------------------+ +| Shared Core Module | +| packages/opencode/src/commit-message/ | +| - generate.ts git context + LLM call | +| - git-context.ts diff/branch/log gathering | +| - types.ts CommitMessageRequest/Response | ++----------+------------------+-----------+-----------+ + | | | + +------+------+ +------+------+ +-------------------+ + | CLI Command | | HTTP Route | | VS Code Extension | + | kilo commit | | POST /commit| | SCM panel button | + | cmd/commit.ts| | -message | | calls HTTP route | + +-------------+ +-------------+ +-------------------+ +``` + +**Key design decision:** Git context is gathered **server-side** in the shared core module. Both the CLI command and HTTP route run in the backend process with filesystem access. The VS Code extension does NOT gather git context — it sends the workspace path and the backend does the rest. This avoids duplicating git logic across surfaces. + +### Request Flow — VS Code + +```mermaid +sequenceDiagram + participant User + participant VSCode as VS Code SCM Panel + participant HTTP as HttpClient + participant Route as POST /commit-message + participant Core as Shared Core Module + participant Git as Git CLI + participant SmallModel as Provider.getSmallModel + participant LLM as LLM Provider + + User->>VSCode: Click generate button + VSCode->>HTTP: generateCommitMessage with path + HTTP->>Route: POST /commit-message + Route->>Core: generateCommitMessage with path + Core->>Git: git diff, branch, log + Git-->>Core: diffs + metadata + Core->>SmallModel: Resolve model + SmallModel-->>Core: Model ID + Core->>Core: Build prompt from template + git context + Core->>LLM: LLM.stream with commit prompt + LLM-->>Core: Generated message + Core-->>Route: Commit message string + Route-->>HTTP: JSON response + HTTP-->>VSCode: Commit message string + VSCode-->>User: Message appears in commit input box +``` + +### Request Flow — CLI + +```mermaid +sequenceDiagram + participant User + participant CLI as kilo commit + participant Bootstrap as bootstrap + participant Core as Shared Core Module + participant Git as Git CLI + participant SmallModel as Provider.getSmallModel + participant LLM as LLM Provider + + User->>CLI: kilo commit + CLI->>Bootstrap: Initialize project context + Bootstrap-->>CLI: Context ready + CLI->>Core: generateCommitMessage with cwd + Core->>Git: git diff, branch, log + Git-->>Core: diffs + metadata + Core->>SmallModel: Resolve model + SmallModel-->>Core: Model ID + Core->>Core: Build prompt from template + git context + Core->>LLM: LLM.stream with commit prompt + LLM-->>Core: Generated message + Core-->>CLI: Commit message string + CLI-->>User: Print to stdout +``` + +### Component Diagram + +```mermaid +graph TD + CMD[kilo commit CLI] --> CORE[Shared Core: generate.ts] + ROUTE[POST /commit-message route] --> CORE + EXT[VS Code Extension] --> HC[HttpClient.generateCommitMessage] + HC --> ROUTE + + CORE --> GC[git-context.ts] + CORE --> SM[Provider.getSmallModel] + CORE --> LLMS[LLM.stream] + + CMD --> BOOT[bootstrap] + EXT --> CS[KiloConnectionService] + CS --> HC + + style CORE fill:#ff9,stroke:#f90,stroke-width:2px + style GC fill:#ff9,stroke:#f90,stroke-width:2px + style ROUTE fill:#ff9,stroke:#f90,stroke-width:2px + style CMD fill:#ff9,stroke:#f90,stroke-width:2px + style HC fill:#ff9,stroke:#f90,stroke-width:2px +``` + +Yellow-highlighted components are new code that needs to be written. + +### Design Rationale: Backend vs Gateway + +Two approaches were considered: + +| Aspect | Option A: Backend endpoint — RECOMMENDED | Option B: Gateway endpoint | +|--------|------------------------------------------|---------------------------| +| Endpoint | `POST /commit-message` in opencode server | `POST /kilo/chat` in kilo-gateway | +| Model selection | Backend uses `Provider.getSmallModel()` directly | Extension must read config and pass model | +| Prompt | Backend shared core module | Extension builds prompt locally | +| Auth | Handled by existing `LLM.stream()` | Separate gateway auth flow | +| Consistency | Same pattern as title generation | Different pattern from other features | +| CLI reuse | CLI command shares the same core logic | CLI would need its own implementation | + +Option A is recommended because it enables a shared core used by both command-line and HTTP surfaces, reuses existing infrastructure, and minimizes extension-side complexity. + +--- + +## 4. Implementation Phases + +### Phase 1: Shared Core Module (packages/opencode) + +**Scope:** Core logic for generating commit messages, shared by all surfaces. + +**Files to create:** + +| File | Purpose | +|------|---------| +| `src/commit-message/generate.ts` | Main `generateCommitMessage()` function — orchestrates git context, prompt building, LLM call | +| `src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status | +| `src/commit-message/types.ts` | `CommitMessageRequest` and `CommitMessageResponse` types | + +**`generateCommitMessage()` function:** + +```typescript +// src/commit-message/generate.ts + +import { getGitContext } from "./git-context" +import type { CommitMessageRequest, CommitMessageResponse } from "./types" + +export async function generateCommitMessage( + request: CommitMessageRequest +): Promise { + // 1. Gather git context from the working directory + const context = await getGitContext(request.path, request.selectedFiles) + + // 2. Resolve small model via Provider.getSmallModel() + // 3. Build prompt: Conventional Commits template + git context + // 4. Call LLM.stream() with the commit prompt + // 5. Clean and return the commit message string + + return { message: cleanedMessage } +} +``` + +**`getGitContext()` function:** + +```typescript +// src/commit-message/git-context.ts + +export interface GitContext { + stagedFiles: FileChange[] + diffs: Map + branch: string + recentCommits: string[] +} + +export interface FileChange { + status: "added" | "modified" | "deleted" | "renamed" | "untracked" + path: string +} + +export async function getGitContext( + repoPath: string, + selectedFiles?: string[] +): Promise +``` + +**Types:** + +```typescript +// src/commit-message/types.ts + +export interface CommitMessageRequest { + path: string // workspace/repo path + selectedFiles?: string[] // optional file subset +} + +export interface CommitMessageResponse { + message: string // the generated commit message +} +``` + +### Phase 2: HTTP Route (packages/opencode) + +**Scope:** `POST /commit-message` route that delegates to the shared core. + +**Files to create/modify:** + +| File | Change | +|------|--------| +| `src/server/routes/commit-message.ts` | New route handler — validates request, calls `generateCommitMessage()`, returns JSON | +| [`src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route | + +**HTTP interface:** + +Request: +```typescript +POST /commit-message +{ + path: string // workspace/repo path + selectedFiles?: string[] // optional file subset +} +``` + +Response: +```typescript +{ + message: string // the generated commit message +} +``` + +The route handler is thin — it validates the request body, calls [`generateCommitMessage()`](../../packages/opencode/src/commit-message/generate.ts), and returns the result as JSON. + +### Phase 3: CLI Command (packages/opencode) + +**Scope:** `kilo commit` command that delegates to the shared core. + +**Files to create/modify:** + +| File | Change | +|------|--------| +| `src/cli/cmd/commit.ts` | New CLI command using [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper | +| [`src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` | + +**Command:** `kilo commit [--auto]` + +| Flag | Default | Description | +|------|---------|-------------| +| `--auto` | `false` | Skip confirmation, auto-stage + commit with the generated message | +| (no flags) | — | Generate and print the commit message to stdout | + +**Usage examples:** +```bash +# Generate and print to stdout +kilo commit + +# Pipe to git commit +kilo commit | git commit -F - + +# Auto-stage and commit +kilo commit --auto +``` + +**Implementation:** + +```typescript +// src/cli/cmd/commit.ts + +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { generateCommitMessage } from "../../commit-message/generate" + +export const CommitCommand = cmd({ + command: "commit", + describe: "Generate a commit message using AI", + builder: (yargs) => + yargs.option("auto", { + type: "boolean", + describe: "Auto-stage and commit with the generated message", + default: false, + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const result = await generateCommitMessage({ + path: process.cwd(), + }) + + if (args.auto) { + // Stage all changes + git commit -m + execSync("git add -A", { cwd: process.cwd() }) + execSync(`git commit -m ${shellEscape(result.message)}`, { + cwd: process.cwd(), + stdio: "inherit", + }) + } else { + // Print to stdout for piping + process.stdout.write(result.message + "\n") + } + }) + }, +}) +``` + +**Registration in [`src/index.ts`](../../packages/opencode/src/index.ts:122):** +```typescript +import { CommitCommand } from "./cli/cmd/commit" +// ... +.command(CommitCommand) +``` + +### Phase 4: VS Code Extension (packages/kilo-vscode) + +**Scope:** Extension-side changes to call the backend endpoint and display results. + +#### 4a. HttpClient — `generateCommitMessage()` method + +New method in [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts): + +```typescript +async generateCommitMessage(request: { + path: string + selectedFiles?: string[] +}): Promise +``` + +- POST to `${this.baseUrl}/commit-message` +- Returns the commit message string from the JSON response +- No SSE parsing — simple request/response + +#### 4b. Commit Message Service + +New service at `src/services/commit-message/`: + +| File | Purpose | +|------|---------| +| [`index.ts`](src/services/commit-message/index.ts) | `registerCommitMessageService()` entry point | +| [`CommitMessageService.ts`](src/services/commit-message/CommitMessageService.ts) | Orchestrates HTTP call → write to SCM input box | + +**CommitMessageService responsibilities:** +1. Determine the workspace/repo path from VS Code's git extension +2. Optionally determine selected files from the SCM view +3. Call `connectionService.getHttpClient().generateCommitMessage({ path, selectedFiles })` +4. Clean response (strip code blocks, quotes if present) +5. Write result to `repository.inputBox.value` + +Note: The extension does NOT gather git context — that's handled server-side by the shared core module. This keeps the extension simple. + +#### 4c. VS Code Integration + +**Changes to [`package.json`](package.json):** +```json +{ + "contributes": { + "commands": [ + { + "command": "kilo-code.new.generateCommitMessage", + "title": "Generate Commit Message", + "icon": "$(sparkle)", + "category": "Kilo Code" + } + ], + "menus": { + "scm/title": [ + { + "command": "kilo-code.new.generateCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } + ] + } + } +} +``` + +**Changes to [`src/extension.ts`](src/extension.ts):** +```typescript +import { registerCommitMessageService } from "./services/commit-message" + +// In activate(): +registerCommitMessageService(context, connectionService) +``` + +**Progress UI:** +```typescript +await vscode.window.withProgress( + { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, + async () => { /* generation logic */ } +) +``` + +### Phase 5: Testing + +**Backend tests (PR A):** +- `packages/opencode/src/commit-message/__tests__/generate.spec.ts` +- `packages/opencode/src/commit-message/__tests__/git-context.spec.ts` +- `packages/opencode/src/cli/cmd/__tests__/commit.spec.ts` + +**Extension tests (PR B):** +- `src/services/commit-message/__tests__/CommitMessageService.spec.ts` + +--- + +## 5. File-by-File Changes + +### New Files — Backend (PR A) + +| File | Purpose | +|------|---------| +| `packages/opencode/src/commit-message/generate.ts` | Main `generateCommitMessage()` function — shared core | +| `packages/opencode/src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status, lock file exclusion | +| `packages/opencode/src/commit-message/types.ts` | `CommitMessageRequest`, `CommitMessageResponse`, `GitContext`, `FileChange` | +| `packages/opencode/src/server/routes/commit-message.ts` | HTTP route handler delegating to shared core | +| `packages/opencode/src/cli/cmd/commit.ts` | `kilo commit` CLI command delegating to shared core | + +### Modified Files — Backend (PR A) + +| File | Change | +|------|--------| +| [`packages/opencode/src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route | +| [`packages/opencode/src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` | + +### New Files — Extension (PR B) + +| File | Purpose | +|------|---------| +| `src/services/commit-message/index.ts` | `registerCommitMessageService()` entry point | +| `src/services/commit-message/CommitMessageService.ts` | Orchestrates HTTP call → write to SCM input box | +| `src/services/commit-message/__tests__/CommitMessageService.spec.ts` | Service tests | + +### Modified Files — Extension (PR B) + +| File | Change | +|------|--------| +| [`package.json`](package.json) | Add command + `scm/title` menu contribution | +| [`src/extension.ts`](src/extension.ts) | Import and call `registerCommitMessageService()` | +| [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts) | Add `generateCommitMessage()` method | + +--- + +## 6. Git Context Gathering (Server-Side) + +The shared core module in [`git-context.ts`](../../packages/opencode/src/commit-message/git-context.ts) runs git CLI commands against the provided workspace path. This runs in the backend process (CLI or HTTP server), which has direct filesystem access. + +### Git Commands + +| Command | Purpose | +|---------|---------| +| `git rev-parse --show-toplevel` | Find repo root | +| `git diff --name-status --cached` | List staged file changes with status | +| `git status --porcelain` | Fallback: list all changes when nothing is staged | +| `git diff --cached -- ` | Per-file diff content (staged) | +| `git diff -- ` | Per-file diff content (unstaged fallback) | +| `git branch --show-current` | Current branch name | +| `git log --oneline -5` | Last 5 commit messages for context | + +### File Processing Rules + +1. **Lock file exclusion:** Files matching lock file patterns are excluded +2. **Binary files:** Replaced with placeholder `"Binary file has been modified"` +3. **Untracked files:** Replaced with placeholder `"New untracked file: "` +4. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged +5. **Selected files:** If `selectedFiles` is provided, only those files are included in the diff +6. **Large diffs:** Truncate individual file diffs at ~4000 chars; include file name even if diff is cut + +### Lock File Patterns + +```typescript +const LOCK_FILE_PATTERNS = [ + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Cargo.lock", + "poetry.lock", + "Pipfile.lock", + "Gemfile.lock", + "composer.lock", + "go.sum", + "bun.lockb", + // ... ~50 more patterns +] +``` + +--- + +## 7. Prompt Engineering + +The Conventional Commits prompt is embedded in the shared core module (either as an inline template in [`generate.ts`](../../packages/opencode/src/commit-message/generate.ts) or as a separate `prompt.txt` file alongside it), consistent with the pattern used by title generation in [`summary.ts`](../../packages/opencode/src/session/summary.ts:130). + +### Commit Prompt Template + +``` +You are a commit message generator. Generate a concise commit message following the Conventional Commits specification. + +Format: type(scope): description + +Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert + +Rules: +- Keep the subject line under 72 characters +- Use imperative mood ("add feature" not "added feature") +- Do not end the subject with a period +- The scope is optional but encouraged when changes are focused +- For multiple unrelated changes, use the most significant change as the type +- Output ONLY the commit message, nothing else +``` + +The shared core combines this system prompt with the gathered git context to form the full LLM request. + +--- + +## 8. Error Handling + +### CLI errors + +| Scenario | Handling | +|----------|---------| +| Not in a git repository | Print error to stderr, exit code 1 | +| No changes detected | Print message to stderr, exit code 0 | +| No provider configured | Print setup instructions to stderr, exit code 1 | +| LLM request fails | Print error to stderr, exit code 1 | + +### HTTP route errors + +| Scenario | Handling | +|----------|---------| +| Missing `path` in request | 400 Bad Request | +| Path is not a git repository | 400 Bad Request with message | +| No changes detected | 200 with empty message + info field | +| No `small_model` available | Backend falls back through auto-detection chain | +| LLM request fails | 500 Internal Server Error with message | + +### VS Code extension errors + +| Scenario | Handling | +|----------|---------| +| CLI backend not connected | `vscode.window.showErrorMessage` — check `connectionService` state | +| Not authenticated | `vscode.window.showErrorMessage` with sign-in prompt | +| Backend returns error | `vscode.window.showErrorMessage` with error details | +| Empty response from backend | Show error; do not write to input box | +| User cancels during progress | Abort gracefully via `CancellationToken` | + +--- + +## 9. Simplifications (v1 scope) + +What we are **NOT** implementing in v1: + +| Feature | Reason | +|---------|--------| +| JetBrains adapter | VS Code extension only — no JetBrains in this codebase | +| User model selection | Backend uses `Provider.getSmallModel()` automatically | +| Custom prompt template override | Keep it simple for v1; can add later | +| `.kilocode-ignore` support | Can add later; lock file exclusion covers the main case | +| Re-generation detection | Can add in v2; first version generates fresh each time | +| Custom instructions from rules files | Can add later | +| Concurrent request debouncing | Low priority for v1 | +| `--auto` with selective staging | v1 `--auto` stages everything; selective staging can come later | +| Interactive confirmation in CLI | v1 just prints to stdout; interactive mode can come later | + +--- + +## 10. PR Sequence + +1. **PR A: Backend changes** (`packages/opencode/`) — Shared core module + HTTP route + CLI command. Contains: + - `src/commit-message/generate.ts`, `git-context.ts`, `types.ts` (shared core) + - `src/server/routes/commit-message.ts` + registration in `server.ts` + - `src/cli/cmd/commit.ts` + registration in `src/index.ts` + - Backend tests + - Can be reviewed/merged independently. No extension changes. + +2. **PR B: Extension changes** (`packages/kilo-vscode/`) — VS Code integration. Contains: + - `generateCommitMessage()` in `http-client.ts` + - `CommitMessageService` + registration + - Command + SCM menu in `package.json` + - Extension tests + - Depends on PR A being deployed. diff --git a/packages/kilo-vscode/docs/commit-message-reimplementation.md b/packages/kilo-vscode/docs/commit-message-reimplementation.md new file mode 100644 index 0000000000..392aee44fe --- /dev/null +++ b/packages/kilo-vscode/docs/commit-message-reimplementation.md @@ -0,0 +1,455 @@ +# Commit Message Generation — Reimplementation Guide + +## Overview + +The commit message generation feature allows users to automatically generate [Conventional Commits](https://www.conventionalcommits.org/) messages from their staged (or unstaged) git changes using an LLM. It is accessible from: + +- **VS Code**: The Source Control panel title bar and the command palette (`Kilo Code: Generate Commit Message`) +- **JetBrains**: A button in the commit dialog + +The feature collects git context (diffs, branch name, recent commits), builds a prompt, sends it to an LLM, and writes the resulting commit message into the IDE's commit input box. + +> **Note:** No screenshots of this feature were found in the repository. A screenshot showing the SCM panel button and generated message would be helpful here. + +--- + +## Architecture + +The current implementation has a clean layered architecture that should be preserved. The key change is that the LLM integration layer will use a different calling mechanism — everything else can be largely reused or adapted. + +### Layered Design + +```mermaid +graph TD + A[IDE Integration Layer] --> B[Orchestrator] + B --> C[Git Context Service] + B --> D[Commit Message Generator] + D --> E[Prompt Builder] + D --> F[LLM Integration Point] + D --> G[Response Cleaner] + A --> H[VS Code Adapter] + A --> I[JetBrains Adapter] + + style F fill:#ff9,stroke:#f90,stroke-width:3px +``` + +### Component Responsibilities + +| Layer | Component | Responsibility | +|-------|-----------|---------------| +| Entry Point | `registerCommitMessageProvider()` | Wires everything up during extension activation | +| IDE Integration | `CommitMessageProvider` | Registers IDE commands, dispatches to the correct adapter | +| Adapter | `VSCodeCommitMessageAdapter` | VS Code SCM panel progress + writes to input box | +| Adapter | `JetBrainsCommitMessageAdapter` | Returns result string to Kotlin host | +| Orchestrator | `CommitMessageOrchestrator` | Sequences: git discovery → diff collection → AI generation → result delivery | +| Business Logic | `CommitMessageGenerator` | Builds prompt, calls LLM, cleans response | +| Git Operations | `GitExtensionService` | Runs git CLI commands, collects diffs and metadata | +| Utilities | `exclusionUtils` | Filters lock files from diffs | + +--- + +## Components to Implement + +### 1. Entry Point — `registerCommitMessageProvider()` + +**Responsibility:** Called during extension activation to wire up all components and register commands. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/index.ts` + +**Key details:** +- Creates instances of `GitExtensionService`, `CommitMessageGenerator`, `CommitMessageOrchestrator` +- Creates the appropriate adapter(s) based on the IDE environment +- Registers VS Code commands and disposables +- Returns disposables for cleanup + +### 2. CommitMessageProvider — Command Router + +**Responsibility:** Registers VS Code commands and dispatches generation requests to the correct adapter. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageProvider.ts` + +**Interface:** +```typescript +interface CommitMessageProvider { + // Register VS Code commands and return disposables + register(): vscode.Disposable[] + + // Handle generation request from either IDE + handleGenerateRequest(context?: { workspacePath?: string; selectedFiles?: string[] }): Promise +} +``` + +**Key details:** +- Registers command `kilo-code.vsc.generateCommitMessage` in the `scm/title` menu +- Registers command `kilo-code.jetbrains.generateCommitMessage` for JetBrains RPC +- Determines which adapter to use based on the calling context + +### 3. CommitMessageOrchestrator — Workflow Coordinator + +**Responsibility:** Sequences the full workflow from git discovery through to delivering the result. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageOrchestrator.ts` + +**Interface:** +```typescript +interface CommitMessageOrchestrator { + generate(options?: { + workspacePath?: string + selectedFiles?: string[] + }): Promise +} + +interface CommitMessageResult { + message: string + regenerated: boolean +} +``` + +**Workflow sequence:** +1. Discover the git repository root +2. Collect git context via `GitExtensionService` +3. Check for re-generation (same diff as last time) +4. Call `CommitMessageGenerator.generateMessage()` with the context +5. Return the result to the adapter for delivery + +### 4. CommitMessageGenerator — Business Logic + +**Responsibility:** Builds the prompt, calls the LLM, and cleans the response. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts` + +**Interface:** +```typescript +interface CommitMessageGenerator { + generateMessage(context: GitContext, options?: { + isRegeneration?: boolean + previousMessage?: string + }): Promise +} +``` + +**Key details:** +- Constructs the prompt using `supportPrompt.create("COMMIT_MESSAGE", ...)` or equivalent +- Loads custom instructions for the "commit" context +- Handles re-generation by prepending "generate a completely different message" +- Calls the LLM (see **Integration Point** below) +- Cleans the response: strips code block markers and surrounding quotes + +**Response cleaning logic:** +```typescript +function cleanResponse(raw: string): string { + let cleaned = raw.trim() + // Strip code block markers + cleaned = cleaned.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "") + // Strip surrounding quotes + cleaned = cleaned.replace(/^["']|["']$/g, "") + return cleaned.trim() +} +``` + +### 5. GitExtensionService — Git Operations + +**Responsibility:** Runs git CLI commands to gather all context needed for prompt construction. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts` + +**Interface:** +```typescript +interface GitContext { + stagedFiles: FileChange[] + diffs: Map // filepath → diff content + branch: string + recentCommits: string[] // last 5 commit summaries +} + +interface FileChange { + status: "added" | "modified" | "deleted" | "renamed" | "untracked" + path: string +} + +interface GitExtensionService { + getGitContext(repoPath: string, selectedFiles?: string[]): Promise +} +``` + +**See section: [Git Context Gathering](#git-context-gathering) for full details.** + +### 6. Exclusion Utilities + +**Responsibility:** Filters lock files and ignored files from the diff set. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/exclusionUtils.ts` + +**Key details:** +- Uses the `ignore` library to match 60+ lock file patterns +- Patterns include `package-lock.json`, `yarn.lock`, `Cargo.lock`, `poetry.lock`, `pnpm-lock.yaml`, etc. +- Also respects `.kilocode-ignore` / `.roo-ignore` via `RooIgnoreController` + +### 7. IDE Adapters + +**VS Code Adapter:** + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts` + +```typescript +interface VSCodeCommitMessageAdapter { + generate(orchestrator: CommitMessageOrchestrator): Promise +} +``` + +- Shows progress via `vscode.window.withProgress(ProgressLocation.SourceControl)` +- Writes result to `repository.inputBox.value` + +**JetBrains Adapter:** + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts` + +```typescript +interface JetBrainsCommitMessageAdapter { + generate(orchestrator: CommitMessageOrchestrator, workspacePath: string, selectedFiles: string[]): Promise<{ message: string }> +} +``` + +- Returns the message string for the Kotlin host to use + +--- + +## LLM Integration Point + +> **⚠️ INTEGRATION POINT — This is the part that will differ from the current implementation.** + +### Current Implementation (for reference only) + +The current code calls `singleCompletionHandler(config, prompt)` which internally uses `buildApiHandler(apiConfig)` to create a provider-specific handler. If the handler has a `completePrompt()` method, it uses single-shot completion; otherwise it streams and collects the full response. This mechanism **will not be used** in the new implementation. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts` — see `callAIForCommitMessage()` + +### Required Contract + +The LLM integration must satisfy this contract: + +```typescript +interface CommitMessageLLMProvider { + /** + * Send a prompt to the LLM and receive a complete text response. + * + * This is a non-streaming, single-shot completion call. + * The full response must be collected before returning. + * + * @param prompt - The complete prompt string including system instructions + * and git context + * @param config - Which model/provider to use. May be a dedicated + * commit message profile or the default profile. + * @returns The raw LLM response text (will be cleaned by the caller) + * @throws If the LLM call fails (network error, auth error, etc.) + */ + complete(prompt: string, config: LLMConfig): Promise +} + +interface LLMConfig { + /** The API config ID — either `commitMessageApiConfigId` or the default */ + configId: string + /** Any additional model parameters if needed */ + [key: string]: unknown +} +``` + +### What the caller provides + +- **Input:** A single prompt string (typically 100–2000 tokens depending on diff size). The prompt includes system instructions, git context, and any custom instructions. +- **Config:** An identifier for which API configuration/model to use. This supports the dedicated `commitMessageApiConfigId` setting which lets users pick a different (often cheaper/faster) model for commit messages. + +### What the caller expects + +- **Output:** A single string containing the commit message. May include code block markers or quotes which will be stripped by the response cleaner. +- **Behavior:** Non-streaming. The call should block until the full response is available. +- **Errors:** Should throw on failure so the orchestrator can catch and display an error to the user. + +### Configuration Resolution + +The config resolution order is: +1. If `commitMessageApiConfigId` is set in global settings → use that API profile +2. Otherwise → use the default/active API profile + +--- + +## Git Context Gathering + +This part is **largely reusable** from the current implementation. It uses `spawnSync` to run git CLI commands. + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts` + +### Git Commands Used + +| Command | Purpose | +|---------|---------| +| `git diff --name-status --cached` | List staged file changes with status | +| `git status --porcelain` | List all changes (fallback when nothing is staged) | +| `git diff [--cached] -- ` | Per-file diff content | +| `git branch --show-current` | Current branch name | +| `git log --oneline -5` | Last 5 commit messages for context | + +### File Processing Rules + +1. **Lock file exclusion:** Files matching any of the 60+ lock file patterns are excluded (see `exclusionUtils`) +2. **Ignore file exclusion:** Files matching `.kilocode-ignore` / `.roo-ignore` patterns are excluded via `shouldIncludeFile()` +3. **Binary files:** Replaced with placeholder text `"Binary file has been modified"` +4. **Untracked files:** Replaced with placeholder text `"New untracked file: "` +5. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged +6. **Selected files (JetBrains):** When the JetBrains adapter provides `selectedFiles`, only those files are included + +### Fallback Behavior + +If no staged changes exist, the service falls back to `git status --porcelain` to capture all modified/untracked files. This ensures the feature works even when users haven't explicitly staged changes. + +--- + +## Prompt Engineering + +### Prompt Template + +The prompt is built using `supportPrompt.create("COMMIT_MESSAGE", { gitContext, customInstructions })`. The template is a ~70-line Conventional Commits guide that includes: + +1. **System instruction:** You are a commit message generator following Conventional Commits format +2. **Format specification:** `type(scope): description` with allowed types (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`) +3. **Rules:** Keep subject under 72 chars, use imperative mood, no period at end, etc. +4. **Git context injection:** Branch name, recent commits, file changes, diffs +5. **Custom instructions:** User-defined instructions from `.kilocode/rules/` for the "commit" context + +### Custom Instructions + +Custom instructions are loaded via `addCustomInstructions()` for the `"commit"` mode context. Users can place files in `.kilocode/rules/` that apply to commit message generation. + +### Re-generation Logic + +When the user requests a new message for the same diff: + +1. The orchestrator detects that the diff hash matches the previous generation +2. It prepends to the prompt: `"GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE. The previous message was: "` +3. This ensures variety when the user isn't satisfied with the first suggestion + +### Prompt Template Override + +Users can override the entire prompt template via the `customSupportPrompts.COMMIT_MESSAGE` setting. This allows complete customization of the commit message format and style. + +--- + +## IDE Integration + +### VS Code + +**Command registration:** +```typescript +// In package.json contributes.commands +{ "command": "kilo-code.vsc.generateCommitMessage", "title": "Generate Commit Message" } + +// In package.json contributes.menus +{ "scm/title": [{ "command": "kilo-code.vsc.generateCommitMessage" }] } +``` + +**Progress reporting:** +```typescript +await vscode.window.withProgress( + { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, + async () => { /* ... generation logic ... */ } +) +``` + +**Result delivery:** +```typescript +repository.inputBox.value = generatedMessage +``` + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts` + +### JetBrains + +**Kotlin side:** A `CommitMessageHandler` adds a button to the commit dialog. When clicked, it sends an RPC command. + +**RPC command:** `kilo-code.jetbrains.generateCommitMessage` with arguments `[workspacePath, selectedFiles]` + +**Result delivery:** The result is returned via RPC to Kotlin which calls `panel.setCommitMessage(result.message)` + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts` + +--- + +## Configuration + +| Setting | Type | Description | +|---------|------|-------------| +| `commitMessageApiConfigId` | `string` | ID of a dedicated API profile for commit messages. Allows using a cheaper/faster model. | +| `customSupportPrompts.COMMIT_MESSAGE` | `string` | Override the entire commit message prompt template | +| Custom instructions in `.kilocode/rules/` | files | Per-project or global instructions applied to the "commit" context | +| `.kilocode-ignore` / `.roo-ignore` | files | File exclusion patterns — excluded files won't appear in diffs | + +### Settings UI + +**Reference:** `/Users/mark/dev/kilo/kilocode-5/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx` + +A dropdown in the Settings panel allows users to select which API configuration to use for commit messages. This is separate from the main chat model selection. + +--- + +## Error Handling + +### Edge Cases to Handle + +| Scenario | Handling | +|----------|----------| +| No git repository found | Show error message: "No git repository found in the current workspace" | +| No changes detected | Show info message: "No changes to generate a commit message for" | +| Empty diff after filtering | Show info message: "All changed files are excluded by lock file or ignore rules" | +| LLM call fails (network/auth) | Show error with details; do not write to input box | +| LLM returns empty response | Retry once; if still empty, show error | +| Very large diff (token limit) | Truncate diffs, prioritize staged files, include file names even if diffs are cut | +| Git command fails | Log the error, attempt to continue with partial context | +| User cancels during progress | Abort the LLM call if possible, clean up gracefully | +| Binary files in diff | Replace with placeholder text instead of including binary content | +| Concurrent generation requests | Debounce or queue — don't send multiple simultaneous LLM requests | + +### Error Display + +- **VS Code:** Use `vscode.window.showErrorMessage()` or `showInformationMessage()` as appropriate +- **JetBrains:** Return error in the RPC response for Kotlin-side display + +--- + +## Testing Strategy + +### Unit Tests + +**Reference for existing tests:** +- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts` +- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/GitExtensionService.spec.ts` +- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/progress-reporting.spec.ts` + +| Component | What to Test | +|-----------|-------------| +| `CommitMessageGenerator` | Prompt construction, response cleaning, re-generation logic, custom instructions injection | +| `GitExtensionService` | Parsing of `git diff --name-status` output, `git status --porcelain` output, branch name extraction, handling of binary files and untracked files | +| `exclusionUtils` | Lock file pattern matching — ensure all 60+ patterns work, edge cases with nested paths | +| `CommitMessageOrchestrator` | Full workflow sequencing, re-generation detection, error propagation | +| `VSCodeCommitMessageAdapter` | Progress reporting, writing to input box, error display | +| `JetBrainsCommitMessageAdapter` | Correct return format, error handling | +| Response cleaner | Stripping code blocks, quotes, whitespace normalization | + +### Integration Tests + +| Test | Description | +|------|-------------| +| Full generation flow | Mock the LLM call, verify end-to-end from git context to result delivery | +| Re-generation | Verify that requesting a new message for the same diff includes the "different message" instruction | +| Config resolution | Verify `commitMessageApiConfigId` is used when set, falls back to default otherwise | +| Large diff handling | Verify truncation behavior with oversized diffs | + +### Mocking Strategy + +- **Git commands:** Mock `spawnSync` to return predefined git output +- **LLM calls:** Mock the LLM integration point to return controlled responses +- **VS Code API:** Mock `vscode.window.withProgress`, `repository.inputBox`, and command registration +- **File system:** Mock ignore file reading for exclusion tests + +### Test File Convention + +Per project convention, test files should use `.spec.ts` extension and live in `__tests__/` directories adjacent to the source code. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 2ac3129b53..27d2454cd2 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -100,6 +100,12 @@ "command": "kilo-code.new.agentManager.nextTab", "title": "Agent Manager: Next Tab", "category": "Kilo Code" + }, + { + "command": "kilo-code.new.generateCommitMessage", + "title": "Generate Commit Message", + "category": "Kilo Code (NEW)", + "icon": "$(sparkle)" } ], "keybindings": [ @@ -161,6 +167,13 @@ "when": "view == kilo-code.new.sidebarView" } ], + "scm/title": [ + { + "command": "kilo-code.new.generateCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } + ], "editor/title": [ { "command": "kilo-code.new.openInTab", diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 1c99d0aa80..b899c15eee 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -6,6 +6,7 @@ import { KiloConnectionService } from "./services/cli-backend" import { registerAutocompleteProvider } from "./services/autocomplete" import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryProxy } from "./services/telemetry" +import { registerCommitMessageService } from "./services/commit-message" export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") @@ -85,6 +86,9 @@ export function activate(context: vscode.ExtensionContext) { // Register autocomplete provider registerAutocompleteProvider(context, connectionService) + // Register commit message generation + registerCommitMessageService(context, connectionService) + // Dispose services when extension deactivates (kills the server) context.subscriptions.push({ dispose: () => { diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index d7ed331411..e21d17facc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -478,6 +478,18 @@ export class HttpClient { return this.request("GET", `/find/file?${params.toString()}`, undefined, { directory }) } + // ============================================ + // Commit Message Methods + // ============================================ + + /** + * Generate a commit message for the current diff in the given directory. + */ + async generateCommitMessage(path: string, selectedFiles?: string[]): Promise { + const result = await this.request<{ message: string }>("POST", "/commit-message", { path, selectedFiles }) + return result.message + } + // ============================================ // MCP Methods // ============================================ diff --git a/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts new file mode 100644 index 0000000000..4de9fba864 --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts +vi.mock("vscode", () => { + const disposable = { dispose: vi.fn() } + + return { + commands: { + registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable), + }, + window: { + showErrorMessage: vi.fn(), + withProgress: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + }, + extensions: { + getExtension: vi.fn(), + }, + ProgressLocation: { + SourceControl: 1, + }, + Uri: { + parse: (s: string) => ({ fsPath: s }), + }, + } +}) + +import * as vscode from "vscode" +import { registerCommitMessageService } from "../index" +import type { KiloConnectionService } from "../../cli-backend/connection-service" + +describe("commit-message service", () => { + let mockContext: vscode.ExtensionContext + let mockConnectionService: KiloConnectionService + let mockHttpClient: { generateCommitMessage: ReturnType } + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + subscriptions: [], + } as any + + mockHttpClient = { + generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"), + } + + mockConnectionService = { + getHttpClient: vi.fn().mockReturnValue(mockHttpClient), + } as any + }) + + describe("registerCommitMessageService", () => { + it("returns an array of disposables", () => { + const disposables = registerCommitMessageService(mockContext, mockConnectionService) + + expect(Array.isArray(disposables)).toBe(true) + expect(disposables.length).toBeGreaterThan(0) + }) + + it("registers the kilo-code.new.generateCommitMessage command", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "kilo-code.new.generateCommitMessage", + expect.any(Function), + ) + }) + + it("pushes the command disposable to context.subscriptions", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(mockContext.subscriptions.length).toBe(1) + }) + }) + + describe("command execution", () => { + let commandCallback: (...args: any[]) => Promise + + beforeEach(() => { + registerCommitMessageService(mockContext, mockConnectionService) + + // Extract the registered command callback + const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]! + commandCallback = registerCall[1] as (...args: any[]) => Promise + }) + + it("shows error when git extension is not found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found") + }) + + it("shows error when no git repository is found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + exports: { + getAPI: () => ({ repositories: [] }), + }, + } as any) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found") + }) + + it("shows error when backend is not connected", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + exports: { + getAPI: () => ({ + repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + vi.mocked(mockConnectionService.getHttpClient as any).mockReturnValue(null) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kilo backend is not connected") + }) + + it("calls generateCommitMessage on the HTTP client with workspace path", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + // Make withProgress execute its callback + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/test/workspace") + }) + + it("sets the generated message on the repository inputBox", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockInputBox.value).toBe("feat: add new feature") + }) + + it("shows progress in SourceControl location", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ + location: vscode.ProgressLocation.SourceControl, + title: "Generating commit message...", + }), + expect.any(Function), + ) + }) + }) +}) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts new file mode 100644 index 0000000000..841bf343b8 --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -0,0 +1,67 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../cli-backend/connection-service" + +interface GitRepository { + inputBox: { value: string } + rootUri: vscode.Uri +} + +interface GitAPI { + repositories: GitRepository[] +} + +interface GitExtensionExports { + getAPI(version: number): GitAPI +} + +export function registerCommitMessageService( + context: vscode.ExtensionContext, + connectionService: KiloConnectionService, +): vscode.Disposable[] { + const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => { + const extension = vscode.extensions.getExtension("vscode.git") + if (!extension) { + vscode.window.showErrorMessage("Git extension not found") + return + } + + const git = extension.exports?.getAPI(1) + const repository = git?.repositories[0] + if (!repository) { + vscode.window.showErrorMessage("No Git repository found") + return + } + + const folder = vscode.workspace.workspaceFolders?.[0] + if (!folder) { + vscode.window.showErrorMessage("No workspace folder found") + return + } + + const client = connectionService.getHttpClient() + if (!client) { + vscode.window.showErrorMessage("Kilo backend is not connected") + return + } + + const path = folder.uri.fsPath + + await vscode.window + .withProgress( + { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, + async () => { + const message = await client.generateCommitMessage(path) + repository.inputBox.value = message + console.log("[Kilo New] Commit message generated successfully") + }, + ) + .then(undefined, (error: unknown) => { + const msg = error instanceof Error ? error.message : String(error) + console.error("[Kilo New] Failed to generate commit message:", msg) + vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`) + }) + }) + + context.subscriptions.push(command) + return [command] +} diff --git a/packages/opencode/src/cli/cmd/commit.ts b/packages/opencode/src/cli/cmd/commit.ts new file mode 100644 index 0000000000..dc8ec45263 --- /dev/null +++ b/packages/opencode/src/cli/cmd/commit.ts @@ -0,0 +1,48 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { generateCommitMessage } from "../../commit-message" +import { UI } from "../ui" + +export const CommitCommand = cmd({ + command: "commit", + describe: "generate a commit message using AI", + builder: (yargs: Argv) => { + return yargs + .option("auto", { + describe: "auto-commit with the generated message", + type: "boolean", + default: false, + }) + .option("staged-only", { + describe: "only use staged changes", + type: "boolean", + default: true, + }) + }, + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const result = await generateCommitMessage({ path: process.cwd() }) + const message = result.message + + if (!process.stdout.isTTY) { + process.stdout.write(message) + return + } + + UI.println(message) + + if (args.auto) { + const proc = Bun.spawnSync(["git", "commit", "-m", message], { + cwd: process.cwd(), + stdout: "inherit", + stderr: "inherit", + }) + if (proc.exitCode !== 0) { + UI.error("git commit failed") + process.exit(1) + } + } + }) + }, +}) diff --git a/packages/opencode/src/commit-message/__tests__/generate.test.ts b/packages/opencode/src/commit-message/__tests__/generate.test.ts new file mode 100644 index 0000000000..cbbde7253b --- /dev/null +++ b/packages/opencode/src/commit-message/__tests__/generate.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" + +// Mock dependencies before importing the module under test + +let mockGitContext = { + branch: "main", + recentCommits: ["abc1234 initial commit"], + files: [ + { status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }, + ], +} + +mock.module("../git-context", () => ({ + getGitContext: async () => mockGitContext, +})) + +let mockStreamText = "feat(src): add hello world logging" + +mock.module("@/provider/provider", () => ({ + Provider: { + defaultModel: async () => ({ providerID: "test", modelID: "test-model" }), + getSmallModel: async () => ({ + providerID: "test", + id: "test-small-model", + }), + getModel: async () => ({ providerID: "test", id: "test-model" }), + }, +})) + +mock.module("@/session/llm", () => ({ + LLM: { + stream: async () => ({ + text: Promise.resolve(mockStreamText), + }), + }, +})) + +mock.module("@/agent/agent", () => ({ + Agent: {}, +})) + +mock.module("@/util/log", () => ({ + Log: { + create: () => ({ + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + }), + }, +})) + +import { generateCommitMessage } from "../generate" + +describe("commit-message.generate", () => { + beforeEach(() => { + mockGitContext = { + branch: "main", + recentCommits: ["abc1234 initial commit"], + files: [ + { status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }, + ], + } + mockStreamText = "feat(src): add hello world logging" + }) + + describe("prompt construction", () => { + test("passes path to getGitContext", async () => { + const result = await generateCommitMessage({ path: "/my/repo" }) + // If getGitContext is called, it returns our mock context and generates a message + expect(result.message).toBeTruthy() + }) + + test("generates message from git context with multiple files", async () => { + mockGitContext = { + branch: "feature/api", + recentCommits: ["abc feat: add api", "def fix: typo"], + files: [ + { status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" }, + { status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" }, + ], + } + mockStreamText = "feat(api): add api module" + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("feat(api): add api module") + }) + }) + + describe("response cleaning", () => { + test("strips code block markers from response", async () => { + mockStreamText = "```\nfeat: add feature\n```" + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("feat: add feature") + }) + + test("strips code block markers with language tag", async () => { + mockStreamText = "```text\nfix(auth): resolve token refresh\n```" + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("fix(auth): resolve token refresh") + }) + + test("strips surrounding double quotes", async () => { + mockStreamText = '"feat: add new feature"' + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("feat: add new feature") + }) + + test("strips surrounding single quotes", async () => { + mockStreamText = "'fix: resolve bug'" + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("fix: resolve bug") + }) + + test("strips whitespace around the message", async () => { + mockStreamText = " \n chore: update deps \n " + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("chore: update deps") + }) + + test("strips code blocks AND quotes together", async () => { + mockStreamText = '```\n"refactor: simplify logic"\n```' + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("refactor: simplify logic") + }) + + test("returns clean message when no markers present", async () => { + mockStreamText = "docs: update readme" + + const result = await generateCommitMessage({ path: "/repo" }) + expect(result.message).toBe("docs: update readme") + }) + }) + + describe("error on no changes", () => { + test("throws when no git changes are found", async () => { + mockGitContext = { + branch: "main", + recentCommits: [], + files: [], + } + + await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow( + "No changes found to generate a commit message for", + ) + }) + }) + + describe("selectedFiles pass-through", () => { + test("passes selectedFiles to getGitContext", async () => { + // This verifies the function doesn't crash when selectedFiles is provided + const result = await generateCommitMessage({ + path: "/repo", + selectedFiles: ["src/a.ts"], + }) + expect(result.message).toBeTruthy() + }) + }) +}) diff --git a/packages/opencode/src/commit-message/__tests__/git-context.test.ts b/packages/opencode/src/commit-message/__tests__/git-context.test.ts new file mode 100644 index 0000000000..a10b05a16c --- /dev/null +++ b/packages/opencode/src/commit-message/__tests__/git-context.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test, beforeEach } from "bun:test" + +// Mock Bun.spawnSync before importing the module under test +const spawnSyncResults: Record = {} + +function setGitOutput(args: string, output: string) { + spawnSyncResults[args] = output +} + +function clearGitOutputs() { + for (const key of Object.keys(spawnSyncResults)) { + delete spawnSyncResults[key] + } +} + +// Replace global Bun.spawnSync — the git() helper in git-context.ts calls +// result.stdout.toString().trim(), so we return a Buffer and let git() trim. +Bun.spawnSync = ((cmd: string[], _opts?: any) => { + const args = cmd.slice(1).join(" ") + const output = spawnSyncResults[args] ?? "" + return { + stdout: Buffer.from(output), + stderr: Buffer.from(""), + exitCode: 0, + } +}) as typeof Bun.spawnSync + +import { getGitContext } from "../git-context" + +describe("commit-message.git-context", () => { + beforeEach(() => { + clearGitOutputs() + // Defaults + setGitOutput("branch --show-current", "main") + setGitOutput("log --oneline -5", "abc1234 initial commit") + setGitOutput("diff --name-status --cached", "") + setGitOutput("status --porcelain", "") + }) + + // NOTE: git() trims stdout, which eats the leading space of the first + // porcelain line. We use staged (--name-status) tests for path-sensitive + // assertions and only use porcelain for behavior tests where this is acceptable. + + describe("lock file filtering", () => { + test("filters out package-lock.json from staged changes", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json") + setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')") + setGitOutput("diff --cached -- package-lock.json", "+lots of lock content") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("src/index.ts") + }) + + test("filters out yarn.lock from staged changes", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock") + setGitOutput("diff --cached -- src/app.ts", "+import x") + setGitOutput("diff --cached -- yarn.lock", "+lock data") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("src/app.ts") + }) + + test("filters out pnpm-lock.yaml from staged changes", async () => { + setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml") + setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock") + setGitOutput("diff --cached -- readme.md", "+docs") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("readme.md") + }) + + test("filters lock files in subdirectories", async () => { + setGitOutput( + "diff --name-status --cached", + "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts", + ) + setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff") + setGitOutput("diff --cached -- packages/api/src/index.ts", "+code") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts") + }) + + test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => { + setGitOutput( + "diff --name-status --cached", + "M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs", + ) + setGitOutput("diff --cached -- bun.lockb", "binary") + setGitOutput("diff --cached -- go.sum", "+hash") + setGitOutput("diff --cached -- Cargo.lock", "+lock") + setGitOutput("diff --cached -- poetry.lock", "+lock") + setGitOutput("diff --cached -- src/main.rs", "+fn main() {}") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("src/main.rs") + }) + }) + + describe("status parsing", () => { + test("parses staged added files", async () => { + setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts") + setGitOutput("diff --cached -- src/new-file.ts", "+new content") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("added") + expect(ctx.files[0]!.path).toBe("src/new-file.ts") + }) + + test("parses staged modified files", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts") + setGitOutput("diff --cached -- src/existing.ts", "+changed line") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("modified") + }) + + test("parses staged deleted files", async () => { + setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts") + setGitOutput("diff --cached -- src/removed.ts", "-deleted content") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("deleted") + }) + + test("parses staged renamed files", async () => { + setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("renamed") + }) + + test("parses untracked files from porcelain", async () => { + setGitOutput("status --porcelain", "?? src/brand-new.ts") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("added") + expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts") + }) + + test("parses porcelain modified files", async () => { + // Use staged to avoid porcelain trim edge case + setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts") + setGitOutput("diff --cached -- src/changed.ts", "+line") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.status).toBe("modified") + }) + + test("prefers staged changes over unstaged", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts") + setGitOutput("diff --cached -- src/staged.ts", "+staged change") + // unstaged also exists but should be ignored when staged is present + setGitOutput("status --porcelain", " M src/unstaged.ts") + setGitOutput("diff -- src/unstaged.ts", "+unstaged change") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.path).toBe("src/staged.ts") + }) + + test("mapStatus returns 'modified' for unknown codes", async () => { + setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts") + setGitOutput("diff --cached -- src/weird.ts", "+stuff") + + const ctx = await getGitContext("/repo") + + expect(ctx.files[0]!.status).toBe("modified") + }) + }) + + describe("diff truncation", () => { + test("truncates diffs exceeding 4000 characters", async () => { + const longDiff = "x".repeat(5000) + setGitOutput("diff --name-status --cached", "M\tsrc/big.ts") + setGitOutput("diff --cached -- src/big.ts", longDiff) + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.diff.length).toBeLessThan(5000) + expect(ctx.files[0]!.diff).toContain("... [truncated]") + // 4000 chars + "\n... [truncated]" + expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length) + }) + + test("does not truncate diffs at exactly 4000 characters", async () => { + const exactDiff = "y".repeat(4000) + setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts") + setGitOutput("diff --cached -- src/exact.ts", exactDiff) + + const ctx = await getGitContext("/repo") + + expect(ctx.files[0]!.diff).toBe(exactDiff) + expect(ctx.files[0]!.diff).not.toContain("... [truncated]") + }) + + test("does not truncate diffs under 4000 characters", async () => { + const shortDiff = "z".repeat(100) + setGitOutput("diff --name-status --cached", "M\tsrc/small.ts") + setGitOutput("diff --cached -- src/small.ts", shortDiff) + + const ctx = await getGitContext("/repo") + + expect(ctx.files[0]!.diff).toBe(shortDiff) + }) + }) + + describe("binary file detection", () => { + test("detects 'Binary files' in diff output", async () => { + setGitOutput("diff --name-status --cached", "M\tassets/logo.png") + setGitOutput( + "diff --cached -- assets/logo.png", + "Binary files a/assets/logo.png and b/assets/logo.png differ", + ) + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified") + }) + + test("detects 'GIT binary patch' in diff output", async () => { + setGitOutput("diff --name-status --cached", "M\tassets/icon.ico") + setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(1) + expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified") + }) + + test("does not flag normal diffs as binary", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/code.ts") + setGitOutput("diff --cached -- src/code.ts", "+const x = 1") + + const ctx = await getGitContext("/repo") + + expect(ctx.files[0]!.diff).toBe("+const x = 1") + }) + }) + + describe("selected files filtering", () => { + test("only includes files in selectedFiles set", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts") + setGitOutput("diff --cached -- src/a.ts", "+a") + setGitOutput("diff --cached -- src/b.ts", "+b") + setGitOutput("diff --cached -- src/c.ts", "+c") + + const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"]) + + expect(ctx.files).toHaveLength(2) + const paths = ctx.files.map((f) => f.path) + expect(paths).toContain("src/a.ts") + expect(paths).toContain("src/c.ts") + expect(paths).not.toContain("src/b.ts") + }) + + test("includes all files when selectedFiles is undefined", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts") + setGitOutput("diff --cached -- src/a.ts", "+a") + setGitOutput("diff --cached -- src/b.ts", "+b") + + const ctx = await getGitContext("/repo") + + expect(ctx.files).toHaveLength(2) + }) + + test("returns empty files when selectedFiles has no matches", async () => { + setGitOutput("diff --name-status --cached", "M\tsrc/a.ts") + setGitOutput("diff --cached -- src/a.ts", "+a") + + const ctx = await getGitContext("/repo", ["src/nonexistent.ts"]) + + expect(ctx.files).toHaveLength(0) + }) + }) + + describe("branch and recent commits", () => { + test("returns current branch name", async () => { + setGitOutput("branch --show-current", "feature/my-branch") + + const ctx = await getGitContext("/repo") + + expect(ctx.branch).toBe("feature/my-branch") + }) + + test("falls back to HEAD when branch is empty", async () => { + setGitOutput("branch --show-current", "") + + const ctx = await getGitContext("/repo") + + expect(ctx.branch).toBe("HEAD") + }) + + test("returns recent commits as array", async () => { + setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third") + + const ctx = await getGitContext("/repo") + + expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"]) + }) + + test("returns empty array when no commits", async () => { + setGitOutput("log --oneline -5", "") + + const ctx = await getGitContext("/repo") + + expect(ctx.recentCommits).toEqual([]) + }) + }) +}) diff --git a/packages/opencode/src/commit-message/generate.ts b/packages/opencode/src/commit-message/generate.ts new file mode 100644 index 0000000000..5bf5486d4e --- /dev/null +++ b/packages/opencode/src/commit-message/generate.ts @@ -0,0 +1,123 @@ +import { Provider } from "@/provider/provider" +import { LLM } from "@/session/llm" +import { Agent } from "@/agent/agent" +import { Log } from "@/util/log" +import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types" +import { getGitContext } from "./git-context" + +const log = Log.create({ service: "commit-message" }) + +const SYSTEM_PROMPT = `You are a commit message generator. Generate a concise commit message following the Conventional Commits format. + +Format: type(scope): description + +Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert + +Rules: +- Keep the subject line under 72 characters +- Use imperative mood ("add feature" not "added feature") +- No period at the end of the subject line +- The scope is optional but encouraged +- Output ONLY the commit message, nothing else` + +function buildUserMessage(ctx: GitContext): string { + const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n") + const diffs = ctx.files + .filter((f) => f.diff) + .map((f) => `--- ${f.path} ---\n${f.diff}`) + .join("\n\n") + + return `Generate a commit message for the following changes: + +Branch: ${ctx.branch} +Recent commits: +${ctx.recentCommits.join("\n")} + +Changed files: +${fileList} + +Diffs: +${diffs}` +} + +function clean(text: string): string { + let result = text.trim() + // Strip code block markers + if (result.startsWith("```")) { + const first = result.indexOf("\n") + if (first !== -1) { + result = result.slice(first + 1) + } + } + if (result.endsWith("```")) { + result = result.slice(0, -3) + } + result = result.trim() + // Strip surrounding quotes + if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) { + result = result.slice(1, -1) + } + return result.trim() +} + +export async function generateCommitMessage(request: CommitMessageRequest): Promise { + const ctx = await getGitContext(request.path, request.selectedFiles) + if (ctx.files.length === 0) { + throw new Error("No changes found to generate a commit message for") + } + + log.info("generating", { + branch: ctx.branch, + files: ctx.files.length, + }) + + const defaultModel = await Provider.defaultModel() + const model = + (await Provider.getSmallModel(defaultModel.providerID)) ?? + (await Provider.getModel(defaultModel.providerID, defaultModel.modelID)) + + const agent: Agent.Info = { + name: "commit-message", + mode: "primary", + hidden: true, + options: {}, + permission: [], + prompt: SYSTEM_PROMPT, + temperature: 0.3, + } + + const stream = await LLM.stream({ + agent, + user: { + id: "commit-message", + sessionID: "commit-message", + role: "user", + model: { + providerID: model.providerID, + modelID: model.id, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + } as any, + tools: {}, + model, + small: true, + messages: [ + { + role: "user" as const, + content: buildUserMessage(ctx), + }, + ], + abort: new AbortController().signal, + sessionID: "commit-message", + system: [], + retries: 3, + }) + + const result = await stream.text + log.info("generated", { message: result }) + + return { message: clean(result) } +} diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts new file mode 100644 index 0000000000..c5dd55fc8e --- /dev/null +++ b/packages/opencode/src/commit-message/git-context.ts @@ -0,0 +1,125 @@ +import type { GitContext, FileChange } from "./types" + +const LOCK_FILES = new Set([ + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Cargo.lock", + "poetry.lock", + "composer.lock", + "Gemfile.lock", + "go.sum", + "bun.lockb", + "bun.lock", + "uv.lock", + "Pipfile.lock", + "flake.lock", + "packages.lock.json", + "project.assets.json", + "paket.lock", + "pubspec.lock", + "Package.resolved", + "Podfile.lock", + "shrinkwrap.yaml", +]) + +const MAX_DIFF_LENGTH = 4000 + +function isLockFile(filepath: string): boolean { + const name = filepath.split("/").pop() ?? filepath + return LOCK_FILES.has(name) +} + +function git(args: string[], cwd: string): string { + const result = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + return result.stdout.toString().trim() +} + +function parseNameStatus(output: string): Array<{ status: string; path: string }> { + if (!output) return [] + return output.split("\n").map((line) => { + const [status, ...rest] = line.split("\t") + return { status: status!, path: rest.join("\t") } + }) +} + +function parsePorcelain(output: string): Array<{ status: string; path: string }> { + if (!output) return [] + return output + .split("\n") + .filter((line) => line.length > 0) + .map((line) => { + const xy = line.slice(0, 2) + const filepath = line.slice(3) + return { status: xy.trim(), path: filepath } + }) +} + +function mapStatus(code: string): FileChange["status"] { + if (code.startsWith("R")) return "renamed" + if (code === "A" || code === "??" || code === "?") return "added" + if (code === "D") return "deleted" + if (code === "M") return "modified" + return "modified" +} + +function isUntracked(code: string): boolean { + return code === "??" || code === "?" +} + +export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise { + const branch = git(["branch", "--show-current"], repoPath) || "HEAD" + const log = git(["log", "--oneline", "-5"], repoPath) + const recentCommits = log ? log.split("\n") : [] + + // Check staged files first + const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath)) + const useStaged = staged.length > 0 + + // Fall back to all changes if nothing staged + const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath)) + + const selected = selectedFiles ? new Set(selectedFiles) : undefined + + const files: FileChange[] = [] + for (const entry of raw) { + if (isLockFile(entry.path)) continue + if (selected && !selected.has(entry.path)) continue + + const status = mapStatus(entry.status) + const untracked = isUntracked(entry.status) + + let diff: string + if (untracked) { + diff = `New untracked file: ${entry.path}` + } else if (status === "deleted") { + diff = useStaged + ? git(["diff", "--cached", "--", entry.path], repoPath) + : git(["diff", "--", entry.path], repoPath) + } else { + const raw = useStaged + ? git(["diff", "--cached", "--", entry.path], repoPath) + : git(["diff", "--", entry.path], repoPath) + + // Detect binary files + if (raw.includes("Binary files") || raw.includes("GIT binary patch")) { + diff = `Binary file ${entry.path} has been modified` + } else { + diff = raw + } + } + + // Truncate large diffs + if (diff.length > MAX_DIFF_LENGTH) { + diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]" + } + + files.push({ status, path: entry.path, diff }) + } + + return { branch, recentCommits, files } +} diff --git a/packages/opencode/src/commit-message/index.ts b/packages/opencode/src/commit-message/index.ts new file mode 100644 index 0000000000..45fbb0f73a --- /dev/null +++ b/packages/opencode/src/commit-message/index.ts @@ -0,0 +1,2 @@ +export { generateCommitMessage } from "./generate" +export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types" diff --git a/packages/opencode/src/commit-message/types.ts b/packages/opencode/src/commit-message/types.ts new file mode 100644 index 0000000000..f806adf50c --- /dev/null +++ b/packages/opencode/src/commit-message/types.ts @@ -0,0 +1,27 @@ +export interface CommitMessageRequest { + /** Workspace/repo path */ + path: string + /** Optional subset of files to include */ + selectedFiles?: string[] +} + +export interface CommitMessageResponse { + /** The generated commit message */ + message: string +} + +export interface GitContext { + /** Current branch name */ + branch: string + /** Last 5 commit summaries */ + recentCommits: string[] + /** File changes with status and diff content */ + files: FileChange[] +} + +export interface FileChange { + status: "added" | "modified" | "deleted" | "renamed" | "untracked" + path: string + /** Diff content, or placeholder for binary/untracked files */ + diff: string +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index cc79306bc2..c9cc681ded 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -22,6 +22,7 @@ import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { AcpCommand } from "./cli/cmd/acp" +import { CommitCommand } from "./cli/cmd/commit" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" @@ -137,6 +138,7 @@ const cli = yargs(hideBin(process.argv)) // .command(GithubCommand) // kilocode_change (Disabled until backend is ready) .command(PrCommand) .command(SessionCommand) + .command(CommitCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/server/routes/commit-message.ts b/packages/opencode/src/server/routes/commit-message.ts new file mode 100644 index 0000000000..3b1962b104 --- /dev/null +++ b/packages/opencode/src/server/routes/commit-message.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { generateCommitMessage } from "../../commit-message" +import { lazy } from "../../util/lazy" +import { errors } from "../error" + +export const CommitMessageRoutes = lazy(() => + new Hono().post( + "/", + describeRoute({ + summary: "Generate commit message", + description: "Generate a commit message using AI based on the current git diff.", + operationId: "commitMessage.generate", + responses: { + 200: { + description: "Generated commit message", + content: { + "application/json": { + schema: resolver(z.object({ message: z.string() })), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + path: z.string().meta({ description: "Workspace/repo path" }), + selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }), + }), + ), + async (c) => { + const body = c.req.valid("json") + const result = await generateCommitMessage(body) + return c.json({ message: result.message }) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8bf922dfca..e883480e40 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" +import { CommitMessageRoutes } from "./routes/commit-message" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -235,6 +236,7 @@ export namespace Server { .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/telemetry", TelemetryRoutes()) // kilocode_change + .route("/commit-message", CommitMessageRoutes()) // kilocode_change // kilocode_change start - Kilo Gateway routes .route( "/kilo", From 0c213086c70e437000deac1082ac3a3ec255a253 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 14:23:38 +0100 Subject: [PATCH 02/14] fix: update Basic auth username from opencode to kilo --- packages/kilo-vscode/src/services/cli-backend/http-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index e21d17facc..67101b60cc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -24,8 +24,8 @@ export class HttpClient { constructor(config: ServerConfig) { this.baseUrl = config.baseUrl - // Auth header format: Basic base64("opencode:password") - // NOTE: The CLI server expects a non-empty username ("opencode"). Using an empty username + // Auth header format: Basic base64("kilo:password") + // NOTE: The CLI server expects a non-empty username ("kilo"). Using an empty username // (":password") results in 401 for both REST and SSE endpoints. this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}` From 561207562b8badaa852e1c9b27e5f2d0a304b086 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 14:26:57 +0100 Subject: [PATCH 03/14] fix: use Kilo logo icon for commit message SCM button --- packages/kilo-vscode/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 27d2454cd2..34762065db 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -105,7 +105,10 @@ "command": "kilo-code.new.generateCommitMessage", "title": "Generate Commit Message", "category": "Kilo Code (NEW)", - "icon": "$(sparkle)" + "icon": { + "light": "assets/icons/kilo-light.svg", + "dark": "assets/icons/kilo-dark.svg" + } } ], "keybindings": [ From 764f7b203bf0b6c03bcfb2ed83644c44db8d3c91 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 16:42:20 +0100 Subject: [PATCH 04/14] chore: remove commit message implementation plans from PR --- .../commit-message-implementation-plan.md | 623 ------------------ .../docs/commit-message-reimplementation.md | 455 ------------- 2 files changed, 1078 deletions(-) delete mode 100644 packages/kilo-vscode/docs/commit-message-implementation-plan.md delete mode 100644 packages/kilo-vscode/docs/commit-message-reimplementation.md diff --git a/packages/kilo-vscode/docs/commit-message-implementation-plan.md b/packages/kilo-vscode/docs/commit-message-implementation-plan.md deleted file mode 100644 index 235115fc5a..0000000000 --- a/packages/kilo-vscode/docs/commit-message-implementation-plan.md +++ /dev/null @@ -1,623 +0,0 @@ -# Commit Message Generation — Implementation Plan - -## 1. Overview - -This plan adds **LLM-powered commit message generation** to the Kilo platform with three surfaces: a **CLI command** (`kilo commit`), an **HTTP route** (`POST /commit-message`), and a **VS Code SCM panel button**. All three delegate to a shared core module that handles git context gathering, prompt building, and LLM interaction. - -### How this differs from the old implementation - -The old extension (kilocode-5) called LLMs directly from the extension process using `buildApiHandler()` and provider-specific handlers. This architecture uses a **shared backend module** instead — the backend handles model selection, prompt building, and LLM communication. The extension is a thin HTTP client, and the CLI command calls the core directly. - -| Aspect | Old extension | This architecture | -|--------|--------------|-------------------| -| LLM calls | Direct from extension → LLM provider | Shared core module in CLI backend | -| Auth | API keys stored in extension settings | OAuth/API key managed by CLI backend's `Auth` module | -| Model selection | User-configurable `commitMessageApiConfigId` | Automatic: `Provider.getSmallModel()` | -| Prompt location | In extension code | In shared core module | -| Surfaces | VS Code + JetBrains adapters | CLI command + HTTP route + VS Code | -| Git context | Gathered in extension | Gathered server-side in shared core | -| Prompt customization | Custom template override setting | Not in v1 | - ---- - -## 2. Backend Investigation - -Investigation of the CLI backend (`packages/opencode/`) revealed existing infrastructure that the commit message feature can reuse directly. - -### What EXISTS in the backend - -| Component | Location | Description | -|-----------|----------|-------------| -| `small_model` config | [`config.ts:1133`](../../packages/opencode/src/config/config.ts:1133) | Optional config field: `small_model: ModelId.describe("Small model to use for tasks like title generation").optional()` | -| `Provider.getSmallModel()` | [`provider.ts:1171`](../../packages/opencode/src/provider/provider.ts:1171) | Resolves small model with priority: user-configured → auto-detected → kilo fallback → undefined | -| Title generation | [`summary.ts:130`](../../packages/opencode/src/session/summary.ts:130) | Uses small_model + "title" agent — reference pattern | -| Agent prompts | `src/agent/prompt/` | Existing agents: ask, compaction, debug, explore, orchestrator, summary, title | -| Server routes | [`server.ts:227`](../../packages/opencode/src/server/server.ts:227) | Hono-based HTTP server with existing route patterns | -| `LLM.stream()` | Various | Streaming LLM infrastructure with auth already handled | -| CLI commands | [`src/cli/cmd/`](../../packages/opencode/src/cli/cmd/) | 18 commands using yargs + [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper | -| `bootstrap()` | [`bootstrap.ts:4`](../../packages/opencode/src/cli/bootstrap.ts:4) | Initializes project context so Provider/LLM APIs are available | - -### What does NOT exist - -- No commit message generation logic -- No generic chat completions endpoint -- No "commit" agent or prompt -- No `kilo commit` CLI command - -### Implications - -Because `Provider.getSmallModel()`, `LLM.stream()`, `bootstrap()`, and the agent prompt infrastructure already exist, the recommended approach is a **shared core module** that both the HTTP route and CLI command delegate to. The extension calls the HTTP route; the CLI command calls the core directly with no HTTP round-trip. - ---- - -## 3. Architecture - -### Three-Layer Design - -``` -+-----------------------------------------------------+ -| Shared Core Module | -| packages/opencode/src/commit-message/ | -| - generate.ts git context + LLM call | -| - git-context.ts diff/branch/log gathering | -| - types.ts CommitMessageRequest/Response | -+----------+------------------+-----------+-----------+ - | | | - +------+------+ +------+------+ +-------------------+ - | CLI Command | | HTTP Route | | VS Code Extension | - | kilo commit | | POST /commit| | SCM panel button | - | cmd/commit.ts| | -message | | calls HTTP route | - +-------------+ +-------------+ +-------------------+ -``` - -**Key design decision:** Git context is gathered **server-side** in the shared core module. Both the CLI command and HTTP route run in the backend process with filesystem access. The VS Code extension does NOT gather git context — it sends the workspace path and the backend does the rest. This avoids duplicating git logic across surfaces. - -### Request Flow — VS Code - -```mermaid -sequenceDiagram - participant User - participant VSCode as VS Code SCM Panel - participant HTTP as HttpClient - participant Route as POST /commit-message - participant Core as Shared Core Module - participant Git as Git CLI - participant SmallModel as Provider.getSmallModel - participant LLM as LLM Provider - - User->>VSCode: Click generate button - VSCode->>HTTP: generateCommitMessage with path - HTTP->>Route: POST /commit-message - Route->>Core: generateCommitMessage with path - Core->>Git: git diff, branch, log - Git-->>Core: diffs + metadata - Core->>SmallModel: Resolve model - SmallModel-->>Core: Model ID - Core->>Core: Build prompt from template + git context - Core->>LLM: LLM.stream with commit prompt - LLM-->>Core: Generated message - Core-->>Route: Commit message string - Route-->>HTTP: JSON response - HTTP-->>VSCode: Commit message string - VSCode-->>User: Message appears in commit input box -``` - -### Request Flow — CLI - -```mermaid -sequenceDiagram - participant User - participant CLI as kilo commit - participant Bootstrap as bootstrap - participant Core as Shared Core Module - participant Git as Git CLI - participant SmallModel as Provider.getSmallModel - participant LLM as LLM Provider - - User->>CLI: kilo commit - CLI->>Bootstrap: Initialize project context - Bootstrap-->>CLI: Context ready - CLI->>Core: generateCommitMessage with cwd - Core->>Git: git diff, branch, log - Git-->>Core: diffs + metadata - Core->>SmallModel: Resolve model - SmallModel-->>Core: Model ID - Core->>Core: Build prompt from template + git context - Core->>LLM: LLM.stream with commit prompt - LLM-->>Core: Generated message - Core-->>CLI: Commit message string - CLI-->>User: Print to stdout -``` - -### Component Diagram - -```mermaid -graph TD - CMD[kilo commit CLI] --> CORE[Shared Core: generate.ts] - ROUTE[POST /commit-message route] --> CORE - EXT[VS Code Extension] --> HC[HttpClient.generateCommitMessage] - HC --> ROUTE - - CORE --> GC[git-context.ts] - CORE --> SM[Provider.getSmallModel] - CORE --> LLMS[LLM.stream] - - CMD --> BOOT[bootstrap] - EXT --> CS[KiloConnectionService] - CS --> HC - - style CORE fill:#ff9,stroke:#f90,stroke-width:2px - style GC fill:#ff9,stroke:#f90,stroke-width:2px - style ROUTE fill:#ff9,stroke:#f90,stroke-width:2px - style CMD fill:#ff9,stroke:#f90,stroke-width:2px - style HC fill:#ff9,stroke:#f90,stroke-width:2px -``` - -Yellow-highlighted components are new code that needs to be written. - -### Design Rationale: Backend vs Gateway - -Two approaches were considered: - -| Aspect | Option A: Backend endpoint — RECOMMENDED | Option B: Gateway endpoint | -|--------|------------------------------------------|---------------------------| -| Endpoint | `POST /commit-message` in opencode server | `POST /kilo/chat` in kilo-gateway | -| Model selection | Backend uses `Provider.getSmallModel()` directly | Extension must read config and pass model | -| Prompt | Backend shared core module | Extension builds prompt locally | -| Auth | Handled by existing `LLM.stream()` | Separate gateway auth flow | -| Consistency | Same pattern as title generation | Different pattern from other features | -| CLI reuse | CLI command shares the same core logic | CLI would need its own implementation | - -Option A is recommended because it enables a shared core used by both command-line and HTTP surfaces, reuses existing infrastructure, and minimizes extension-side complexity. - ---- - -## 4. Implementation Phases - -### Phase 1: Shared Core Module (packages/opencode) - -**Scope:** Core logic for generating commit messages, shared by all surfaces. - -**Files to create:** - -| File | Purpose | -|------|---------| -| `src/commit-message/generate.ts` | Main `generateCommitMessage()` function — orchestrates git context, prompt building, LLM call | -| `src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status | -| `src/commit-message/types.ts` | `CommitMessageRequest` and `CommitMessageResponse` types | - -**`generateCommitMessage()` function:** - -```typescript -// src/commit-message/generate.ts - -import { getGitContext } from "./git-context" -import type { CommitMessageRequest, CommitMessageResponse } from "./types" - -export async function generateCommitMessage( - request: CommitMessageRequest -): Promise { - // 1. Gather git context from the working directory - const context = await getGitContext(request.path, request.selectedFiles) - - // 2. Resolve small model via Provider.getSmallModel() - // 3. Build prompt: Conventional Commits template + git context - // 4. Call LLM.stream() with the commit prompt - // 5. Clean and return the commit message string - - return { message: cleanedMessage } -} -``` - -**`getGitContext()` function:** - -```typescript -// src/commit-message/git-context.ts - -export interface GitContext { - stagedFiles: FileChange[] - diffs: Map - branch: string - recentCommits: string[] -} - -export interface FileChange { - status: "added" | "modified" | "deleted" | "renamed" | "untracked" - path: string -} - -export async function getGitContext( - repoPath: string, - selectedFiles?: string[] -): Promise -``` - -**Types:** - -```typescript -// src/commit-message/types.ts - -export interface CommitMessageRequest { - path: string // workspace/repo path - selectedFiles?: string[] // optional file subset -} - -export interface CommitMessageResponse { - message: string // the generated commit message -} -``` - -### Phase 2: HTTP Route (packages/opencode) - -**Scope:** `POST /commit-message` route that delegates to the shared core. - -**Files to create/modify:** - -| File | Change | -|------|--------| -| `src/server/routes/commit-message.ts` | New route handler — validates request, calls `generateCommitMessage()`, returns JSON | -| [`src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route | - -**HTTP interface:** - -Request: -```typescript -POST /commit-message -{ - path: string // workspace/repo path - selectedFiles?: string[] // optional file subset -} -``` - -Response: -```typescript -{ - message: string // the generated commit message -} -``` - -The route handler is thin — it validates the request body, calls [`generateCommitMessage()`](../../packages/opencode/src/commit-message/generate.ts), and returns the result as JSON. - -### Phase 3: CLI Command (packages/opencode) - -**Scope:** `kilo commit` command that delegates to the shared core. - -**Files to create/modify:** - -| File | Change | -|------|--------| -| `src/cli/cmd/commit.ts` | New CLI command using [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper | -| [`src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` | - -**Command:** `kilo commit [--auto]` - -| Flag | Default | Description | -|------|---------|-------------| -| `--auto` | `false` | Skip confirmation, auto-stage + commit with the generated message | -| (no flags) | — | Generate and print the commit message to stdout | - -**Usage examples:** -```bash -# Generate and print to stdout -kilo commit - -# Pipe to git commit -kilo commit | git commit -F - - -# Auto-stage and commit -kilo commit --auto -``` - -**Implementation:** - -```typescript -// src/cli/cmd/commit.ts - -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" -import { generateCommitMessage } from "../../commit-message/generate" - -export const CommitCommand = cmd({ - command: "commit", - describe: "Generate a commit message using AI", - builder: (yargs) => - yargs.option("auto", { - type: "boolean", - describe: "Auto-stage and commit with the generated message", - default: false, - }), - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const result = await generateCommitMessage({ - path: process.cwd(), - }) - - if (args.auto) { - // Stage all changes + git commit -m - execSync("git add -A", { cwd: process.cwd() }) - execSync(`git commit -m ${shellEscape(result.message)}`, { - cwd: process.cwd(), - stdio: "inherit", - }) - } else { - // Print to stdout for piping - process.stdout.write(result.message + "\n") - } - }) - }, -}) -``` - -**Registration in [`src/index.ts`](../../packages/opencode/src/index.ts:122):** -```typescript -import { CommitCommand } from "./cli/cmd/commit" -// ... -.command(CommitCommand) -``` - -### Phase 4: VS Code Extension (packages/kilo-vscode) - -**Scope:** Extension-side changes to call the backend endpoint and display results. - -#### 4a. HttpClient — `generateCommitMessage()` method - -New method in [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts): - -```typescript -async generateCommitMessage(request: { - path: string - selectedFiles?: string[] -}): Promise -``` - -- POST to `${this.baseUrl}/commit-message` -- Returns the commit message string from the JSON response -- No SSE parsing — simple request/response - -#### 4b. Commit Message Service - -New service at `src/services/commit-message/`: - -| File | Purpose | -|------|---------| -| [`index.ts`](src/services/commit-message/index.ts) | `registerCommitMessageService()` entry point | -| [`CommitMessageService.ts`](src/services/commit-message/CommitMessageService.ts) | Orchestrates HTTP call → write to SCM input box | - -**CommitMessageService responsibilities:** -1. Determine the workspace/repo path from VS Code's git extension -2. Optionally determine selected files from the SCM view -3. Call `connectionService.getHttpClient().generateCommitMessage({ path, selectedFiles })` -4. Clean response (strip code blocks, quotes if present) -5. Write result to `repository.inputBox.value` - -Note: The extension does NOT gather git context — that's handled server-side by the shared core module. This keeps the extension simple. - -#### 4c. VS Code Integration - -**Changes to [`package.json`](package.json):** -```json -{ - "contributes": { - "commands": [ - { - "command": "kilo-code.new.generateCommitMessage", - "title": "Generate Commit Message", - "icon": "$(sparkle)", - "category": "Kilo Code" - } - ], - "menus": { - "scm/title": [ - { - "command": "kilo-code.new.generateCommitMessage", - "group": "navigation", - "when": "scmProvider == git" - } - ] - } - } -} -``` - -**Changes to [`src/extension.ts`](src/extension.ts):** -```typescript -import { registerCommitMessageService } from "./services/commit-message" - -// In activate(): -registerCommitMessageService(context, connectionService) -``` - -**Progress UI:** -```typescript -await vscode.window.withProgress( - { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, - async () => { /* generation logic */ } -) -``` - -### Phase 5: Testing - -**Backend tests (PR A):** -- `packages/opencode/src/commit-message/__tests__/generate.spec.ts` -- `packages/opencode/src/commit-message/__tests__/git-context.spec.ts` -- `packages/opencode/src/cli/cmd/__tests__/commit.spec.ts` - -**Extension tests (PR B):** -- `src/services/commit-message/__tests__/CommitMessageService.spec.ts` - ---- - -## 5. File-by-File Changes - -### New Files — Backend (PR A) - -| File | Purpose | -|------|---------| -| `packages/opencode/src/commit-message/generate.ts` | Main `generateCommitMessage()` function — shared core | -| `packages/opencode/src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status, lock file exclusion | -| `packages/opencode/src/commit-message/types.ts` | `CommitMessageRequest`, `CommitMessageResponse`, `GitContext`, `FileChange` | -| `packages/opencode/src/server/routes/commit-message.ts` | HTTP route handler delegating to shared core | -| `packages/opencode/src/cli/cmd/commit.ts` | `kilo commit` CLI command delegating to shared core | - -### Modified Files — Backend (PR A) - -| File | Change | -|------|--------| -| [`packages/opencode/src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route | -| [`packages/opencode/src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` | - -### New Files — Extension (PR B) - -| File | Purpose | -|------|---------| -| `src/services/commit-message/index.ts` | `registerCommitMessageService()` entry point | -| `src/services/commit-message/CommitMessageService.ts` | Orchestrates HTTP call → write to SCM input box | -| `src/services/commit-message/__tests__/CommitMessageService.spec.ts` | Service tests | - -### Modified Files — Extension (PR B) - -| File | Change | -|------|--------| -| [`package.json`](package.json) | Add command + `scm/title` menu contribution | -| [`src/extension.ts`](src/extension.ts) | Import and call `registerCommitMessageService()` | -| [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts) | Add `generateCommitMessage()` method | - ---- - -## 6. Git Context Gathering (Server-Side) - -The shared core module in [`git-context.ts`](../../packages/opencode/src/commit-message/git-context.ts) runs git CLI commands against the provided workspace path. This runs in the backend process (CLI or HTTP server), which has direct filesystem access. - -### Git Commands - -| Command | Purpose | -|---------|---------| -| `git rev-parse --show-toplevel` | Find repo root | -| `git diff --name-status --cached` | List staged file changes with status | -| `git status --porcelain` | Fallback: list all changes when nothing is staged | -| `git diff --cached -- ` | Per-file diff content (staged) | -| `git diff -- ` | Per-file diff content (unstaged fallback) | -| `git branch --show-current` | Current branch name | -| `git log --oneline -5` | Last 5 commit messages for context | - -### File Processing Rules - -1. **Lock file exclusion:** Files matching lock file patterns are excluded -2. **Binary files:** Replaced with placeholder `"Binary file has been modified"` -3. **Untracked files:** Replaced with placeholder `"New untracked file: "` -4. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged -5. **Selected files:** If `selectedFiles` is provided, only those files are included in the diff -6. **Large diffs:** Truncate individual file diffs at ~4000 chars; include file name even if diff is cut - -### Lock File Patterns - -```typescript -const LOCK_FILE_PATTERNS = [ - "package-lock.json", - "yarn.lock", - "pnpm-lock.yaml", - "Cargo.lock", - "poetry.lock", - "Pipfile.lock", - "Gemfile.lock", - "composer.lock", - "go.sum", - "bun.lockb", - // ... ~50 more patterns -] -``` - ---- - -## 7. Prompt Engineering - -The Conventional Commits prompt is embedded in the shared core module (either as an inline template in [`generate.ts`](../../packages/opencode/src/commit-message/generate.ts) or as a separate `prompt.txt` file alongside it), consistent with the pattern used by title generation in [`summary.ts`](../../packages/opencode/src/session/summary.ts:130). - -### Commit Prompt Template - -``` -You are a commit message generator. Generate a concise commit message following the Conventional Commits specification. - -Format: type(scope): description - -Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert - -Rules: -- Keep the subject line under 72 characters -- Use imperative mood ("add feature" not "added feature") -- Do not end the subject with a period -- The scope is optional but encouraged when changes are focused -- For multiple unrelated changes, use the most significant change as the type -- Output ONLY the commit message, nothing else -``` - -The shared core combines this system prompt with the gathered git context to form the full LLM request. - ---- - -## 8. Error Handling - -### CLI errors - -| Scenario | Handling | -|----------|---------| -| Not in a git repository | Print error to stderr, exit code 1 | -| No changes detected | Print message to stderr, exit code 0 | -| No provider configured | Print setup instructions to stderr, exit code 1 | -| LLM request fails | Print error to stderr, exit code 1 | - -### HTTP route errors - -| Scenario | Handling | -|----------|---------| -| Missing `path` in request | 400 Bad Request | -| Path is not a git repository | 400 Bad Request with message | -| No changes detected | 200 with empty message + info field | -| No `small_model` available | Backend falls back through auto-detection chain | -| LLM request fails | 500 Internal Server Error with message | - -### VS Code extension errors - -| Scenario | Handling | -|----------|---------| -| CLI backend not connected | `vscode.window.showErrorMessage` — check `connectionService` state | -| Not authenticated | `vscode.window.showErrorMessage` with sign-in prompt | -| Backend returns error | `vscode.window.showErrorMessage` with error details | -| Empty response from backend | Show error; do not write to input box | -| User cancels during progress | Abort gracefully via `CancellationToken` | - ---- - -## 9. Simplifications (v1 scope) - -What we are **NOT** implementing in v1: - -| Feature | Reason | -|---------|--------| -| JetBrains adapter | VS Code extension only — no JetBrains in this codebase | -| User model selection | Backend uses `Provider.getSmallModel()` automatically | -| Custom prompt template override | Keep it simple for v1; can add later | -| `.kilocode-ignore` support | Can add later; lock file exclusion covers the main case | -| Re-generation detection | Can add in v2; first version generates fresh each time | -| Custom instructions from rules files | Can add later | -| Concurrent request debouncing | Low priority for v1 | -| `--auto` with selective staging | v1 `--auto` stages everything; selective staging can come later | -| Interactive confirmation in CLI | v1 just prints to stdout; interactive mode can come later | - ---- - -## 10. PR Sequence - -1. **PR A: Backend changes** (`packages/opencode/`) — Shared core module + HTTP route + CLI command. Contains: - - `src/commit-message/generate.ts`, `git-context.ts`, `types.ts` (shared core) - - `src/server/routes/commit-message.ts` + registration in `server.ts` - - `src/cli/cmd/commit.ts` + registration in `src/index.ts` - - Backend tests - - Can be reviewed/merged independently. No extension changes. - -2. **PR B: Extension changes** (`packages/kilo-vscode/`) — VS Code integration. Contains: - - `generateCommitMessage()` in `http-client.ts` - - `CommitMessageService` + registration - - Command + SCM menu in `package.json` - - Extension tests - - Depends on PR A being deployed. diff --git a/packages/kilo-vscode/docs/commit-message-reimplementation.md b/packages/kilo-vscode/docs/commit-message-reimplementation.md deleted file mode 100644 index 392aee44fe..0000000000 --- a/packages/kilo-vscode/docs/commit-message-reimplementation.md +++ /dev/null @@ -1,455 +0,0 @@ -# Commit Message Generation — Reimplementation Guide - -## Overview - -The commit message generation feature allows users to automatically generate [Conventional Commits](https://www.conventionalcommits.org/) messages from their staged (or unstaged) git changes using an LLM. It is accessible from: - -- **VS Code**: The Source Control panel title bar and the command palette (`Kilo Code: Generate Commit Message`) -- **JetBrains**: A button in the commit dialog - -The feature collects git context (diffs, branch name, recent commits), builds a prompt, sends it to an LLM, and writes the resulting commit message into the IDE's commit input box. - -> **Note:** No screenshots of this feature were found in the repository. A screenshot showing the SCM panel button and generated message would be helpful here. - ---- - -## Architecture - -The current implementation has a clean layered architecture that should be preserved. The key change is that the LLM integration layer will use a different calling mechanism — everything else can be largely reused or adapted. - -### Layered Design - -```mermaid -graph TD - A[IDE Integration Layer] --> B[Orchestrator] - B --> C[Git Context Service] - B --> D[Commit Message Generator] - D --> E[Prompt Builder] - D --> F[LLM Integration Point] - D --> G[Response Cleaner] - A --> H[VS Code Adapter] - A --> I[JetBrains Adapter] - - style F fill:#ff9,stroke:#f90,stroke-width:3px -``` - -### Component Responsibilities - -| Layer | Component | Responsibility | -|-------|-----------|---------------| -| Entry Point | `registerCommitMessageProvider()` | Wires everything up during extension activation | -| IDE Integration | `CommitMessageProvider` | Registers IDE commands, dispatches to the correct adapter | -| Adapter | `VSCodeCommitMessageAdapter` | VS Code SCM panel progress + writes to input box | -| Adapter | `JetBrainsCommitMessageAdapter` | Returns result string to Kotlin host | -| Orchestrator | `CommitMessageOrchestrator` | Sequences: git discovery → diff collection → AI generation → result delivery | -| Business Logic | `CommitMessageGenerator` | Builds prompt, calls LLM, cleans response | -| Git Operations | `GitExtensionService` | Runs git CLI commands, collects diffs and metadata | -| Utilities | `exclusionUtils` | Filters lock files from diffs | - ---- - -## Components to Implement - -### 1. Entry Point — `registerCommitMessageProvider()` - -**Responsibility:** Called during extension activation to wire up all components and register commands. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/index.ts` - -**Key details:** -- Creates instances of `GitExtensionService`, `CommitMessageGenerator`, `CommitMessageOrchestrator` -- Creates the appropriate adapter(s) based on the IDE environment -- Registers VS Code commands and disposables -- Returns disposables for cleanup - -### 2. CommitMessageProvider — Command Router - -**Responsibility:** Registers VS Code commands and dispatches generation requests to the correct adapter. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageProvider.ts` - -**Interface:** -```typescript -interface CommitMessageProvider { - // Register VS Code commands and return disposables - register(): vscode.Disposable[] - - // Handle generation request from either IDE - handleGenerateRequest(context?: { workspacePath?: string; selectedFiles?: string[] }): Promise -} -``` - -**Key details:** -- Registers command `kilo-code.vsc.generateCommitMessage` in the `scm/title` menu -- Registers command `kilo-code.jetbrains.generateCommitMessage` for JetBrains RPC -- Determines which adapter to use based on the calling context - -### 3. CommitMessageOrchestrator — Workflow Coordinator - -**Responsibility:** Sequences the full workflow from git discovery through to delivering the result. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageOrchestrator.ts` - -**Interface:** -```typescript -interface CommitMessageOrchestrator { - generate(options?: { - workspacePath?: string - selectedFiles?: string[] - }): Promise -} - -interface CommitMessageResult { - message: string - regenerated: boolean -} -``` - -**Workflow sequence:** -1. Discover the git repository root -2. Collect git context via `GitExtensionService` -3. Check for re-generation (same diff as last time) -4. Call `CommitMessageGenerator.generateMessage()` with the context -5. Return the result to the adapter for delivery - -### 4. CommitMessageGenerator — Business Logic - -**Responsibility:** Builds the prompt, calls the LLM, and cleans the response. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts` - -**Interface:** -```typescript -interface CommitMessageGenerator { - generateMessage(context: GitContext, options?: { - isRegeneration?: boolean - previousMessage?: string - }): Promise -} -``` - -**Key details:** -- Constructs the prompt using `supportPrompt.create("COMMIT_MESSAGE", ...)` or equivalent -- Loads custom instructions for the "commit" context -- Handles re-generation by prepending "generate a completely different message" -- Calls the LLM (see **Integration Point** below) -- Cleans the response: strips code block markers and surrounding quotes - -**Response cleaning logic:** -```typescript -function cleanResponse(raw: string): string { - let cleaned = raw.trim() - // Strip code block markers - cleaned = cleaned.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "") - // Strip surrounding quotes - cleaned = cleaned.replace(/^["']|["']$/g, "") - return cleaned.trim() -} -``` - -### 5. GitExtensionService — Git Operations - -**Responsibility:** Runs git CLI commands to gather all context needed for prompt construction. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts` - -**Interface:** -```typescript -interface GitContext { - stagedFiles: FileChange[] - diffs: Map // filepath → diff content - branch: string - recentCommits: string[] // last 5 commit summaries -} - -interface FileChange { - status: "added" | "modified" | "deleted" | "renamed" | "untracked" - path: string -} - -interface GitExtensionService { - getGitContext(repoPath: string, selectedFiles?: string[]): Promise -} -``` - -**See section: [Git Context Gathering](#git-context-gathering) for full details.** - -### 6. Exclusion Utilities - -**Responsibility:** Filters lock files and ignored files from the diff set. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/exclusionUtils.ts` - -**Key details:** -- Uses the `ignore` library to match 60+ lock file patterns -- Patterns include `package-lock.json`, `yarn.lock`, `Cargo.lock`, `poetry.lock`, `pnpm-lock.yaml`, etc. -- Also respects `.kilocode-ignore` / `.roo-ignore` via `RooIgnoreController` - -### 7. IDE Adapters - -**VS Code Adapter:** - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts` - -```typescript -interface VSCodeCommitMessageAdapter { - generate(orchestrator: CommitMessageOrchestrator): Promise -} -``` - -- Shows progress via `vscode.window.withProgress(ProgressLocation.SourceControl)` -- Writes result to `repository.inputBox.value` - -**JetBrains Adapter:** - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts` - -```typescript -interface JetBrainsCommitMessageAdapter { - generate(orchestrator: CommitMessageOrchestrator, workspacePath: string, selectedFiles: string[]): Promise<{ message: string }> -} -``` - -- Returns the message string for the Kotlin host to use - ---- - -## LLM Integration Point - -> **⚠️ INTEGRATION POINT — This is the part that will differ from the current implementation.** - -### Current Implementation (for reference only) - -The current code calls `singleCompletionHandler(config, prompt)` which internally uses `buildApiHandler(apiConfig)` to create a provider-specific handler. If the handler has a `completePrompt()` method, it uses single-shot completion; otherwise it streams and collects the full response. This mechanism **will not be used** in the new implementation. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts` — see `callAIForCommitMessage()` - -### Required Contract - -The LLM integration must satisfy this contract: - -```typescript -interface CommitMessageLLMProvider { - /** - * Send a prompt to the LLM and receive a complete text response. - * - * This is a non-streaming, single-shot completion call. - * The full response must be collected before returning. - * - * @param prompt - The complete prompt string including system instructions - * and git context - * @param config - Which model/provider to use. May be a dedicated - * commit message profile or the default profile. - * @returns The raw LLM response text (will be cleaned by the caller) - * @throws If the LLM call fails (network error, auth error, etc.) - */ - complete(prompt: string, config: LLMConfig): Promise -} - -interface LLMConfig { - /** The API config ID — either `commitMessageApiConfigId` or the default */ - configId: string - /** Any additional model parameters if needed */ - [key: string]: unknown -} -``` - -### What the caller provides - -- **Input:** A single prompt string (typically 100–2000 tokens depending on diff size). The prompt includes system instructions, git context, and any custom instructions. -- **Config:** An identifier for which API configuration/model to use. This supports the dedicated `commitMessageApiConfigId` setting which lets users pick a different (often cheaper/faster) model for commit messages. - -### What the caller expects - -- **Output:** A single string containing the commit message. May include code block markers or quotes which will be stripped by the response cleaner. -- **Behavior:** Non-streaming. The call should block until the full response is available. -- **Errors:** Should throw on failure so the orchestrator can catch and display an error to the user. - -### Configuration Resolution - -The config resolution order is: -1. If `commitMessageApiConfigId` is set in global settings → use that API profile -2. Otherwise → use the default/active API profile - ---- - -## Git Context Gathering - -This part is **largely reusable** from the current implementation. It uses `spawnSync` to run git CLI commands. - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts` - -### Git Commands Used - -| Command | Purpose | -|---------|---------| -| `git diff --name-status --cached` | List staged file changes with status | -| `git status --porcelain` | List all changes (fallback when nothing is staged) | -| `git diff [--cached] -- ` | Per-file diff content | -| `git branch --show-current` | Current branch name | -| `git log --oneline -5` | Last 5 commit messages for context | - -### File Processing Rules - -1. **Lock file exclusion:** Files matching any of the 60+ lock file patterns are excluded (see `exclusionUtils`) -2. **Ignore file exclusion:** Files matching `.kilocode-ignore` / `.roo-ignore` patterns are excluded via `shouldIncludeFile()` -3. **Binary files:** Replaced with placeholder text `"Binary file has been modified"` -4. **Untracked files:** Replaced with placeholder text `"New untracked file: "` -5. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged -6. **Selected files (JetBrains):** When the JetBrains adapter provides `selectedFiles`, only those files are included - -### Fallback Behavior - -If no staged changes exist, the service falls back to `git status --porcelain` to capture all modified/untracked files. This ensures the feature works even when users haven't explicitly staged changes. - ---- - -## Prompt Engineering - -### Prompt Template - -The prompt is built using `supportPrompt.create("COMMIT_MESSAGE", { gitContext, customInstructions })`. The template is a ~70-line Conventional Commits guide that includes: - -1. **System instruction:** You are a commit message generator following Conventional Commits format -2. **Format specification:** `type(scope): description` with allowed types (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`) -3. **Rules:** Keep subject under 72 chars, use imperative mood, no period at end, etc. -4. **Git context injection:** Branch name, recent commits, file changes, diffs -5. **Custom instructions:** User-defined instructions from `.kilocode/rules/` for the "commit" context - -### Custom Instructions - -Custom instructions are loaded via `addCustomInstructions()` for the `"commit"` mode context. Users can place files in `.kilocode/rules/` that apply to commit message generation. - -### Re-generation Logic - -When the user requests a new message for the same diff: - -1. The orchestrator detects that the diff hash matches the previous generation -2. It prepends to the prompt: `"GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE. The previous message was: "` -3. This ensures variety when the user isn't satisfied with the first suggestion - -### Prompt Template Override - -Users can override the entire prompt template via the `customSupportPrompts.COMMIT_MESSAGE` setting. This allows complete customization of the commit message format and style. - ---- - -## IDE Integration - -### VS Code - -**Command registration:** -```typescript -// In package.json contributes.commands -{ "command": "kilo-code.vsc.generateCommitMessage", "title": "Generate Commit Message" } - -// In package.json contributes.menus -{ "scm/title": [{ "command": "kilo-code.vsc.generateCommitMessage" }] } -``` - -**Progress reporting:** -```typescript -await vscode.window.withProgress( - { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, - async () => { /* ... generation logic ... */ } -) -``` - -**Result delivery:** -```typescript -repository.inputBox.value = generatedMessage -``` - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts` - -### JetBrains - -**Kotlin side:** A `CommitMessageHandler` adds a button to the commit dialog. When clicked, it sends an RPC command. - -**RPC command:** `kilo-code.jetbrains.generateCommitMessage` with arguments `[workspacePath, selectedFiles]` - -**Result delivery:** The result is returned via RPC to Kotlin which calls `panel.setCommitMessage(result.message)` - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts` - ---- - -## Configuration - -| Setting | Type | Description | -|---------|------|-------------| -| `commitMessageApiConfigId` | `string` | ID of a dedicated API profile for commit messages. Allows using a cheaper/faster model. | -| `customSupportPrompts.COMMIT_MESSAGE` | `string` | Override the entire commit message prompt template | -| Custom instructions in `.kilocode/rules/` | files | Per-project or global instructions applied to the "commit" context | -| `.kilocode-ignore` / `.roo-ignore` | files | File exclusion patterns — excluded files won't appear in diffs | - -### Settings UI - -**Reference:** `/Users/mark/dev/kilo/kilocode-5/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx` - -A dropdown in the Settings panel allows users to select which API configuration to use for commit messages. This is separate from the main chat model selection. - ---- - -## Error Handling - -### Edge Cases to Handle - -| Scenario | Handling | -|----------|----------| -| No git repository found | Show error message: "No git repository found in the current workspace" | -| No changes detected | Show info message: "No changes to generate a commit message for" | -| Empty diff after filtering | Show info message: "All changed files are excluded by lock file or ignore rules" | -| LLM call fails (network/auth) | Show error with details; do not write to input box | -| LLM returns empty response | Retry once; if still empty, show error | -| Very large diff (token limit) | Truncate diffs, prioritize staged files, include file names even if diffs are cut | -| Git command fails | Log the error, attempt to continue with partial context | -| User cancels during progress | Abort the LLM call if possible, clean up gracefully | -| Binary files in diff | Replace with placeholder text instead of including binary content | -| Concurrent generation requests | Debounce or queue — don't send multiple simultaneous LLM requests | - -### Error Display - -- **VS Code:** Use `vscode.window.showErrorMessage()` or `showInformationMessage()` as appropriate -- **JetBrains:** Return error in the RPC response for Kotlin-side display - ---- - -## Testing Strategy - -### Unit Tests - -**Reference for existing tests:** -- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts` -- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/GitExtensionService.spec.ts` -- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/progress-reporting.spec.ts` - -| Component | What to Test | -|-----------|-------------| -| `CommitMessageGenerator` | Prompt construction, response cleaning, re-generation logic, custom instructions injection | -| `GitExtensionService` | Parsing of `git diff --name-status` output, `git status --porcelain` output, branch name extraction, handling of binary files and untracked files | -| `exclusionUtils` | Lock file pattern matching — ensure all 60+ patterns work, edge cases with nested paths | -| `CommitMessageOrchestrator` | Full workflow sequencing, re-generation detection, error propagation | -| `VSCodeCommitMessageAdapter` | Progress reporting, writing to input box, error display | -| `JetBrainsCommitMessageAdapter` | Correct return format, error handling | -| Response cleaner | Stripping code blocks, quotes, whitespace normalization | - -### Integration Tests - -| Test | Description | -|------|-------------| -| Full generation flow | Mock the LLM call, verify end-to-end from git context to result delivery | -| Re-generation | Verify that requesting a new message for the same diff includes the "different message" instruction | -| Config resolution | Verify `commitMessageApiConfigId` is used when set, falls back to default otherwise | -| Large diff handling | Verify truncation behavior with oversized diffs | - -### Mocking Strategy - -- **Git commands:** Mock `spawnSync` to return predefined git output -- **LLM calls:** Mock the LLM integration point to return controlled responses -- **VS Code API:** Mock `vscode.window.withProgress`, `repository.inputBox`, and command registration -- **File system:** Mock ignore file reading for exclusion tests - -### Test File Convention - -Per project convention, test files should use `.spec.ts` extension and live in `__tests__/` directories adjacent to the source code. From 2ecb523f99bf74db4f357c344fa36dd6bb856d83 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 17:00:53 +0100 Subject: [PATCH 05/14] fix: enhance commit message prompt with detailed Conventional Commits guidance --- .../opencode/src/commit-message/generate.ts | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/commit-message/generate.ts b/packages/opencode/src/commit-message/generate.ts index 5bf5486d4e..99eb8909f9 100644 --- a/packages/opencode/src/commit-message/generate.ts +++ b/packages/opencode/src/commit-message/generate.ts @@ -7,18 +7,68 @@ import { getGitContext } from "./git-context" const log = Log.create({ service: "commit-message" }) -const SYSTEM_PROMPT = `You are a commit message generator. Generate a concise commit message following the Conventional Commits format. - -Format: type(scope): description - -Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert - -Rules: -- Keep the subject line under 72 characters -- Use imperative mood ("add feature" not "added feature") -- No period at the end of the subject line -- The scope is optional but encouraged -- Output ONLY the commit message, nothing else` +const SYSTEM_PROMPT = `You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification. + +## Conventional Commits Format +Generate commit messages following this exact structure: +\`\`\` +[optional scope]: + +[optional body] + +[optional footer(s)] +\`\`\` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: \`feat(api):\`, \`fix(ui):\` +- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 72 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: \`BREAKING CHANGE: description\` + +## Analysis Instructions +When analyzing staged changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, nothing else.` function buildUserMessage(ctx: GitContext): string { const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n") From 158f87f548829cbd0aed91264df80c44cc3d232d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 17:04:30 +0100 Subject: [PATCH 06/14] feat: add regenerate-different-message support --- .../kilo-vscode/src/services/cli-backend/http-client.ts | 8 ++++++-- .../kilo-vscode/src/services/commit-message/index.ts | 9 ++++++++- packages/opencode/src/commit-message/generate.ts | 7 ++++++- packages/opencode/src/commit-message/types.ts | 2 ++ packages/opencode/src/server/routes/commit-message.ts | 4 ++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index 67101b60cc..6871465b13 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -485,8 +485,12 @@ export class HttpClient { /** * Generate a commit message for the current diff in the given directory. */ - async generateCommitMessage(path: string, selectedFiles?: string[]): Promise { - const result = await this.request<{ message: string }>("POST", "/commit-message", { path, selectedFiles }) + async generateCommitMessage(path: string, selectedFiles?: string[], previousMessage?: string): Promise { + const result = await this.request<{ message: string }>("POST", "/commit-message", { + path, + selectedFiles, + previousMessage, + }) return result.message } diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index 841bf343b8..8fe63e7794 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -1,6 +1,9 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend/connection-service" +let lastGeneratedMessage: string | undefined +let lastWorkspacePath: string | undefined + interface GitRepository { inputBox: { value: string } rootUri: vscode.Uri @@ -46,12 +49,16 @@ export function registerCommitMessageService( const path = folder.uri.fsPath + const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined + await vscode.window .withProgress( { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, async () => { - const message = await client.generateCommitMessage(path) + const message = await client.generateCommitMessage(path, undefined, previousMessage) repository.inputBox.value = message + lastGeneratedMessage = message + lastWorkspacePath = path console.log("[Kilo New] Commit message generated successfully") }, ) diff --git a/packages/opencode/src/commit-message/generate.ts b/packages/opencode/src/commit-message/generate.ts index 99eb8909f9..f5321ace7b 100644 --- a/packages/opencode/src/commit-message/generate.ts +++ b/packages/opencode/src/commit-message/generate.ts @@ -136,6 +136,11 @@ export async function generateCommitMessage(request: CommitMessageRequest): Prom temperature: 0.3, } + let userMessage = buildUserMessage(ctx) + if (request.previousMessage) { + userMessage = `IMPORTANT: Generate a COMPLETELY DIFFERENT commit message from the previous one. The previous message was: "${request.previousMessage}". Use a different type, scope, or description approach.\n\n${userMessage}` + } + const stream = await LLM.stream({ agent, user: { @@ -157,7 +162,7 @@ export async function generateCommitMessage(request: CommitMessageRequest): Prom messages: [ { role: "user" as const, - content: buildUserMessage(ctx), + content: userMessage, }, ], abort: new AbortController().signal, diff --git a/packages/opencode/src/commit-message/types.ts b/packages/opencode/src/commit-message/types.ts index f806adf50c..a356d551b8 100644 --- a/packages/opencode/src/commit-message/types.ts +++ b/packages/opencode/src/commit-message/types.ts @@ -3,6 +3,8 @@ export interface CommitMessageRequest { path: string /** Optional subset of files to include */ selectedFiles?: string[] + /** Previously generated message — when set, the LLM is asked to produce a different one */ + previousMessage?: string } export interface CommitMessageResponse { diff --git a/packages/opencode/src/server/routes/commit-message.ts b/packages/opencode/src/server/routes/commit-message.ts index 3b1962b104..8ebc8d8bd5 100644 --- a/packages/opencode/src/server/routes/commit-message.ts +++ b/packages/opencode/src/server/routes/commit-message.ts @@ -29,6 +29,10 @@ export const CommitMessageRoutes = lazy(() => z.object({ path: z.string().meta({ description: "Workspace/repo path" }), selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }), + previousMessage: z + .string() + .optional() + .meta({ description: "Previously generated message — triggers regeneration with a different result" }), }), ), async (c) => { From 51329c4c6543dd86cc00ba1b0ff20efc0c3035eb Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 17:06:05 +0100 Subject: [PATCH 07/14] fix: expand lock file exclusion list to 80+ patterns across 25+ ecosystems --- .../src/commit-message/git-context.ts | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts index c5dd55fc8e..a0162f7bdc 100644 --- a/packages/opencode/src/commit-message/git-context.ts +++ b/packages/opencode/src/commit-message/git-context.ts @@ -1,26 +1,124 @@ import type { GitContext, FileChange } from "./types" const LOCK_FILES = new Set([ + // --- JavaScript / Node.js --- "package-lock.json", + "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", - "Cargo.lock", - "poetry.lock", - "composer.lock", - "Gemfile.lock", - "go.sum", + "shrinkwrap.yaml", "bun.lockb", "bun.lock", - "uv.lock", + ".pnp.js", + ".pnp.cjs", + "jspm.lock", + + // --- Python --- "Pipfile.lock", - "flake.lock", + "poetry.lock", + "pdm.lock", + ".pdm-lock.toml", + "uv.lock", + "conda-lock.yml", + "pylock.toml", + + // --- Ruby --- + "Gemfile.lock", + + // --- PHP --- + "composer.lock", + + // --- Java / JVM --- + "gradle.lockfile", + "lockfile.json", + "dependency-lock.json", + "dependency-reduced-pom.xml", + "coursier.lock", + + // --- Scala --- + "build.sbt.lock", + + // --- .NET --- "packages.lock.json", - "project.assets.json", "paket.lock", - "pubspec.lock", + "project.assets.json", + + // --- Rust --- + "Cargo.lock", + + // --- Go --- + "go.sum", + "Gopkg.lock", + "glide.lock", + + // --- Zig --- + "build.zig.zon.lock", + + // --- OCaml --- + "dune.lock", + "opam.lock", + + // --- Swift / iOS --- "Package.resolved", "Podfile.lock", - "shrinkwrap.yaml", + "Cartfile.resolved", + + // --- Dart / Flutter --- + "pubspec.lock", + + // --- Elixir / Erlang --- + "mix.lock", + "rebar.lock", + + // --- Haskell --- + "stack.yaml.lock", + "cabal.project.freeze", + + // --- Elm --- + "exact-dependencies.json", + + // --- Crystal --- + "shard.lock", + + // --- Julia --- + "Manifest.toml", + "JuliaManifest.toml", + + // --- R --- + "renv.lock", + "packrat.lock", + + // --- Nim --- + "nimble.lock", + + // --- D --- + "dub.selections.json", + + // --- Lua --- + "rocks.lock", + + // --- Perl --- + "carton.lock", + "cpanfile.snapshot", + + // --- C/C++ --- + "conan.lock", + "vcpkg-lock.json", + + // --- Infrastructure as Code --- + ".terraform.lock.hcl", + "Berksfile.lock", + "Puppetfile.lock", + "MODULE.bazel.lock", + + // --- Nix --- + "flake.lock", + + // --- Deno --- + "deno.lock", + + // --- DevContainers --- + "devcontainer.lock.json", ]) const MAX_DIFF_LENGTH = 4000 From c09c77b8a66e019ce349ba30482ccb2d5a2b3446 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 17:20:14 +0100 Subject: [PATCH 08/14] fix: add GitContext type annotation to mock in generate.test.ts The mockGitContext variable was declared without a type annotation, causing TypeScript to infer a narrow type from the initial assignment (status: "modified"). This made reassignment with status: "added" fail with TS2322. Adding the GitContext type allows all valid status values. --- .../opencode/src/commit-message/__tests__/generate.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/commit-message/__tests__/generate.test.ts b/packages/opencode/src/commit-message/__tests__/generate.test.ts index cbbde7253b..7459e73272 100644 --- a/packages/opencode/src/commit-message/__tests__/generate.test.ts +++ b/packages/opencode/src/commit-message/__tests__/generate.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, mock, beforeEach } from "bun:test" +import type { GitContext } from "../types" // Mock dependencies before importing the module under test -let mockGitContext = { +let mockGitContext: GitContext = { branch: "main", recentCommits: ["abc1234 initial commit"], files: [ From bc24a200bb9ead0f76bcda110d1b5b805568ba03 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 18:27:04 +0100 Subject: [PATCH 09/14] fix: handle getHttpClient() throw in commit message service --- .../kilo-vscode/src/services/commit-message/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index 8fe63e7794..d3e2e657bd 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend/connection-service" +import type { HttpClient } from "../cli-backend/http-client" let lastGeneratedMessage: string | undefined let lastWorkspacePath: string | undefined @@ -41,9 +42,11 @@ export function registerCommitMessageService( return } - const client = connectionService.getHttpClient() - if (!client) { - vscode.window.showErrorMessage("Kilo backend is not connected") + let client: HttpClient | undefined + try { + client = connectionService.getHttpClient() + } catch { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") return } From e90630621d535c0868a78fd0dcd8d5ca2f6ae967 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 18:27:17 +0100 Subject: [PATCH 10/14] fix: remove unused --staged-only CLI option --- packages/opencode/src/cli/cmd/commit.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/commit.ts b/packages/opencode/src/cli/cmd/commit.ts index dc8ec45263..0e61780999 100644 --- a/packages/opencode/src/cli/cmd/commit.ts +++ b/packages/opencode/src/cli/cmd/commit.ts @@ -14,11 +14,7 @@ export const CommitCommand = cmd({ type: "boolean", default: false, }) - .option("staged-only", { - describe: "only use staged changes", - type: "boolean", - default: true, - }) + }, handler: async (args) => { await bootstrap(process.cwd(), async () => { From c071a6c77a2ab14f832ebb4b302cd2c657d12c8c Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 18:27:32 +0100 Subject: [PATCH 11/14] fix: parse git rename entries correctly in git-context --- packages/opencode/src/commit-message/git-context.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts index a0162f7bdc..ff5003c2cd 100644 --- a/packages/opencode/src/commit-message/git-context.ts +++ b/packages/opencode/src/commit-message/git-context.ts @@ -141,7 +141,14 @@ function parseNameStatus(output: string): Array<{ status: string; path: string } if (!output) return [] return output.split("\n").map((line) => { const [status, ...rest] = line.split("\t") - return { status: status!, path: rest.join("\t") } + let path: string + if (status!.startsWith("R")) { + // Rename: rest = ["old.ts", "new.ts"], use the new path + path = rest[1] ?? rest[0] + } else { + path = rest.join("\t") + } + return { status: status!, path } }) } From 70df9460d7c1b99b9ecea2d39bbc8ae98b5c9c6f Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 16 Feb 2026 18:27:51 +0100 Subject: [PATCH 12/14] fix: activate Git extension before accessing exports --- packages/kilo-vscode/src/services/commit-message/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index d3e2e657bd..06c02c77fb 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -29,6 +29,10 @@ export function registerCommitMessageService( return } + if (!extension.isActive) { + await extension.activate() + } + const git = extension.exports?.getAPI(1) const repository = git?.repositories[0] if (!repository) { From 9faf4c393407293eaaa58877a838c8fd24ab3d48 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 10:15:08 +0100 Subject: [PATCH 13/14] refactor: remove commit message CLI command, keep core logic for clients --- packages/opencode/src/cli/cmd/commit.ts | 44 ------------------------- packages/opencode/src/index.ts | 2 -- 2 files changed, 46 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/commit.ts diff --git a/packages/opencode/src/cli/cmd/commit.ts b/packages/opencode/src/cli/cmd/commit.ts deleted file mode 100644 index 0e61780999..0000000000 --- a/packages/opencode/src/cli/cmd/commit.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" -import { generateCommitMessage } from "../../commit-message" -import { UI } from "../ui" - -export const CommitCommand = cmd({ - command: "commit", - describe: "generate a commit message using AI", - builder: (yargs: Argv) => { - return yargs - .option("auto", { - describe: "auto-commit with the generated message", - type: "boolean", - default: false, - }) - - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const result = await generateCommitMessage({ path: process.cwd() }) - const message = result.message - - if (!process.stdout.isTTY) { - process.stdout.write(message) - return - } - - UI.println(message) - - if (args.auto) { - const proc = Bun.spawnSync(["git", "commit", "-m", message], { - cwd: process.cwd(), - stdout: "inherit", - stderr: "inherit", - }) - if (proc.exitCode !== 0) { - UI.error("git commit failed") - process.exit(1) - } - } - }) - }, -}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c9cc681ded..cc79306bc2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -22,7 +22,6 @@ import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { AcpCommand } from "./cli/cmd/acp" -import { CommitCommand } from "./cli/cmd/commit" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" @@ -138,7 +137,6 @@ const cli = yargs(hideBin(process.argv)) // .command(GithubCommand) // kilocode_change (Disabled until backend is ready) .command(PrCommand) .command(SessionCommand) - .command(CommitCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || From bc27d8924bedd0e4d89b8c7946bd518a44bf5dd9 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 12:40:18 +0100 Subject: [PATCH 14/14] fix: address kiloconnect review comments on commit message generation - Use trimEnd() instead of trim() in git() to preserve leading whitespace in porcelain output (fixes status parsing for ' M file' lines) - Remove unused 'untracked' from FileChange status union - Use repository.rootUri.fsPath instead of workspaceFolders[0] - Add explicit null-check for client after try/catch - Fix test mocks: add isActive/activate, use mockImplementation to throw, fix expected error message to match production code --- .../commit-message/__tests__/index.spec.ts | 22 +++++++++++++++---- .../src/services/commit-message/index.ts | 12 +++++----- .../src/commit-message/git-context.ts | 2 +- packages/opencode/src/commit-message/types.ts | 2 +- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts index 4de9fba864..6afa163c86 100644 --- a/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts +++ b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts @@ -101,6 +101,8 @@ describe("commit-message service", () => { it("shows error when no git repository is found", async () => { vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), exports: { getAPI: () => ({ repositories: [] }), }, @@ -113,22 +115,30 @@ describe("commit-message service", () => { it("shows error when backend is not connected", async () => { vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), exports: { getAPI: () => ({ repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }], }), }, } as any) - vi.mocked(mockConnectionService.getHttpClient as any).mockReturnValue(null) + vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => { + throw new Error("Not connected") + }) await commandCallback() - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kilo backend is not connected") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Kilo backend is not connected. Please wait for the connection to establish.", + ) }) - it("calls generateCommitMessage on the HTTP client with workspace path", async () => { + it("calls generateCommitMessage on the HTTP client with repository root path", async () => { const mockInputBox = { value: "" } vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), exports: { getAPI: () => ({ repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], @@ -143,12 +153,14 @@ describe("commit-message service", () => { await commandCallback() - expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/test/workspace") + expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/repo", undefined, undefined) }) it("sets the generated message on the repository inputBox", async () => { const mockInputBox = { value: "" } vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), exports: { getAPI: () => ({ repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], @@ -168,6 +180,8 @@ describe("commit-message service", () => { it("shows progress in SourceControl location", async () => { const mockInputBox = { value: "" } vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), exports: { getAPI: () => ({ repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index 06c02c77fb..825f36ceef 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -40,12 +40,6 @@ export function registerCommitMessageService( return } - const folder = vscode.workspace.workspaceFolders?.[0] - if (!folder) { - vscode.window.showErrorMessage("No workspace folder found") - return - } - let client: HttpClient | undefined try { client = connectionService.getHttpClient() @@ -53,8 +47,12 @@ export function registerCommitMessageService( vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") return } + if (!client) { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") + return + } - const path = folder.uri.fsPath + const path = repository.rootUri.fsPath const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts index ff5003c2cd..3b97f44c58 100644 --- a/packages/opencode/src/commit-message/git-context.ts +++ b/packages/opencode/src/commit-message/git-context.ts @@ -134,7 +134,7 @@ function git(args: string[], cwd: string): string { stdout: "pipe", stderr: "pipe", }) - return result.stdout.toString().trim() + return result.stdout.toString().trimEnd() } function parseNameStatus(output: string): Array<{ status: string; path: string }> { diff --git a/packages/opencode/src/commit-message/types.ts b/packages/opencode/src/commit-message/types.ts index a356d551b8..135fc13a3f 100644 --- a/packages/opencode/src/commit-message/types.ts +++ b/packages/opencode/src/commit-message/types.ts @@ -22,7 +22,7 @@ export interface GitContext { } export interface FileChange { - status: "added" | "modified" | "deleted" | "renamed" | "untracked" + status: "added" | "modified" | "deleted" | "renamed" path: string /** Diff content, or placeholder for binary/untracked files */ diff: string