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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@
"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": {
"light": "assets/icons/kilo-light.svg",
"dark": "assets/icons/kilo-dark.svg"
}
}
],
"keybindings": [
Expand Down Expand Up @@ -161,6 +170,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",
Expand Down
4 changes: 4 additions & 0 deletions packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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: () => {
Expand Down
20 changes: 18 additions & 2 deletions packages/kilo-vscode/src/services/cli-backend/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`

Expand Down Expand Up @@ -478,6 +478,22 @@ export class HttpClient {
return this.request<string[]>("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[], previousMessage?: string): Promise<string> {
const result = await this.request<{ message: string }>("POST", "/commit-message", {
path,
selectedFiles,
previousMessage,
})
return result.message
}

// ============================================
// MCP Methods
// ============================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
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<typeof vi.fn> }

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<void>

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<void>
})

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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Mocked vscode.git extension is missing isActive/activate, which can cause the command callback to throw

registerCommitMessageService() now checks if (!extension.isActive) await extension.activate(). In this test, the mocked extension object only provides exports, so extension.activate will be undefined and the callback will throw before reaching the "No Git repository found" branch.

Add isActive: true (or activate: vi.fn().mockResolvedValue(undefined) + isActive: false) to the mocked extension return values so the test exercises the intended path.

isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
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({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({
repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }],
}),
},
} as any)
vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => {
throw new Error("Not connected")
})

await commandCallback()

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 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" } }],
}),
},
} 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("/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" } }],
}),
},
} 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({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
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),
)
})
})
})
79 changes: 79 additions & 0 deletions packages/kilo-vscode/src/services/commit-message/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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

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<GitExtensionExports>("vscode.git")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Ensure the built-in Git extension is activated before accessing exports

vscode.extensions.getExtension('vscode.git') can return an extension object with exports undefined until it’s activated. Consider await extension.activate() before calling extension.exports.getAPI(1) to avoid incorrectly reporting "No Git repository found".

if (!extension) {
vscode.window.showErrorMessage("Git extension not found")
return
}

if (!extension.isActive) {
await extension.activate()
}

const git = extension.exports?.getAPI(1)
const repository = git?.repositories[0]
if (!repository) {
vscode.window.showErrorMessage("No Git repository found")
return
}

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
}
if (!client) {
vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.")
return
}

const path = repository.rootUri.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, undefined, previousMessage)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Possible runtime error if getHttpClient() returns undefined/null instead of throwing

packages/kilo-vscode/src/services/commit-message/index.ts:65 calls client.generateCommitMessage(...), but client is declared as HttpClient | undefined and the code only handles the throwing case. If connectionService.getHttpClient() returns undefined/null when disconnected, this will throw and show a generic failure message. Consider explicitly checking client after the call and returning early with a targeted error message.

repository.inputBox.value = message
lastGeneratedMessage = message
lastWorkspacePath = path
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]
}
Loading
Loading