Merge branch 'main' into mark/disable-issue-triage

This commit is contained in:
Mark IJbema
2026-06-16 11:56:00 +02:00
committed by GitHub
34 changed files with 1058 additions and 101 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-indexing": patch
"kilo-code": patch
---
Support unauthenticated OpenAI-compatible endpoints for codebase indexing without requiring a placeholder API key.
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Add an instant/thinking reasoning toggle for MiniMax M-series models, matching the existing glm/kimi/qwen behavior.
@@ -0,0 +1,33 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.14.51 to v1.15.4 upstream:
- Core Improvements: Clarified how to recover when the npm package is installed without its native binary.
- Core Improvements: Reduced unnecessary prompting around shell, task, and todo flows.
- Core Bugfixes: Ignored invalid exports in custom tool modules instead of failing tool loading.
- Core Bugfixes: Ignored project instruction lookup errors so sessions keep loading when project instruction discovery fails.
- Core Bugfixes: Fixed versioned event projector lookups so event replay uses the right handlers.
- Core Bugfixes: Avoid duplicate consecutive entries in prompt history.
- Core Bugfixes: Show full config validation errors during TUI startup instead of a generic failure.
- Core Bugfixes: Fixed npm installs so the CLI can recover and fetch the right native binary on more setups.
- Core Bugfixes: Fixed multiline `@` mentions in prompts.
- Core Bugfixes: Preserved custom tool metadata from Zod schemas.
- Core Bugfixes: Preserved custom tool argument descriptions in generated schemas.
- Core Bugfixes: Fixed file watching in repos where `.git` is a symlink. (@kagura-agent)
- Core Bugfixes: Fixed sync events not reaching project-scoped subscribers in injected instances.
- Core Bugfixes: Reduced wasted work when reading very large files after output truncation.
- Core Bugfixes: Fixed project-scoped bus events so file watcher and update notifications reach the right instance.
- Core Bugfixes: Fixed custom LSP servers not sending refresh events after they initialize.
- Core Bugfixes: Hid background subagent task instructions unless experimental background mode is enabled.
- TUI Improvements: Added a collapsed thinking view that can be expanded inline.
- TUI Improvements: Added pinned sessions with quick-switch slots in the session picker.
- TUI Improvements: Newly pinned sessions now stay at the end of the pinned list instead of jumping to the top.
- TUI Improvements: Made Markdown H1 headings easier to distinguish.
- TUI Bugfixes: Fixed thinking mode defaults so reasoning starts collapsed consistently.
- TUI Bugfixes: Limited session quick-switching to pinned sessions.
- TUI Bugfixes: Fixed Markdown table rendering in chat output.
- TUI Bugfixes: Fixed `kilo run --agent` resolving project-local agents.
- TUI Bugfixes: Fixed async commands losing the active instance context, which could break agent generation and GitHub-driven runs.
@@ -136,6 +136,12 @@ Imported work stays associated with its branch or worktree and can be continued
- Use session history to reopen local sessions or preview cloud sessions
- Continue a cloud session locally from Agent Manager using the same extension sign-in and provider settings
### Renaming Worktrees
Double-click a worktree name to edit its label inline. You can also right-click the worktree and choose **Rename**. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
Renaming a worktree changes only the label shown in Agent Manager. It does not rename the underlying git branch.
## Starting Sessions From Chat
Kilo can start Agent Manager sessions from chat with the `agent_manager` tool. It is available by default only in the VS Code extension because Agent Manager is an extension feature.
@@ -168,7 +174,7 @@ Sections let you group worktrees into collapsible, color-coded folders in the si
Multi-version worktrees (created via Multi-Version Mode) are moved together — assigning one version to a section moves all versions in the group.
### Renaming
### Renaming Sections
Right-click the section header and select **Rename Section**. An inline text field appears — type the new name and press `Enter` to confirm or `Escape` to cancel.
@@ -82,6 +82,12 @@ Run `/export` in chat, or open a local session's **History** context menu and ch
Kilo builds the export from the complete local session history, not only the messages currently loaded in the chat view.
**Renaming sessions:**
Double-click the current session title at the top of the chat to edit it inline. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
You can also rename local sessions from **History** using the edit button or the session's context menu.
{% /tab %}
{% tab label="CLI" %}
@@ -360,7 +360,7 @@ Operations are queued to prevent concurrent Git operations that might corrupt re
## Git Installation
Checkpoints require Git to be installed on your system.
Checkpoints require Git to be installed on your system. If Git is unavailable or the workspace is not a Git repository, Kilo skips checkpoints automatically; you do not need to disable them manually.
### macOS
@@ -64,7 +64,7 @@ You can also edit the `indexing` section in `kilo.jsonc` directly:
|---|---|---|
| **OpenAI** | API key | Default model: `text-embedding-3-small`. `text-embedding-3-large` for higher accuracy. |
| **Ollama** | Local base URL | No API costs. Runs fully offline. |
| **OpenAI-Compatible** | Base URL + API key | For self-hosted or third-party OpenAI-compatible endpoints. |
| **OpenAI-Compatible** | Base URL + optional API key | For self-hosted or third-party OpenAI-compatible endpoints, including unauthenticated local servers. |
| **Gemini** | Google AI API key | Supports `gemini-embedding-001` and other Gemini embedding models. |
| **Mistral** | API key from [La Plateforme](https://console.mistral.ai/api-keys/) | Use a standard Mistral API key. The Codestral-specific keys from the [Mistral autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) are **not** interchangeable — those only work for completion. |
| **Vercel AI Gateway** | API key | Routes requests through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
@@ -143,7 +143,7 @@ You can also edit the `indexing` section directly. This is the full shape of the
|---|---|---|---|
| **OpenAI** | `openai` | `{ apiKey }` | Default: `text-embedding-3-small`. |
| **Ollama** | `ollama` | `{ baseUrl }` | No API costs. Runs fully offline. |
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey }` | For self-hosted or third-party endpoints. |
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey? }` | For self-hosted or third-party endpoints, including unauthenticated local servers. |
| **Gemini** | `gemini` | `{ apiKey }` | Supports `gemini-embedding-001`. |
| **Mistral** | `mistral` | `{ apiKey }` | Use a [La Plateforme](https://console.mistral.ai/api-keys/) key — the Codestral-specific keys from the [autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) don't work for embeddings. |
| **Vercel AI Gateway** | `vercel-ai-gateway` | `{ apiKey }` | Routes through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
@@ -65,7 +65,7 @@ By default, autocomplete routes through the Kilo provider and uses credits. If y
### How to Get It Free
Add your own Mistral AI (Codestral) API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
Add your own Mistral AI API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
See the [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) for step-by-step instructions.
@@ -57,7 +57,7 @@ export class CodeIndexConfigManager {
private kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
private openAiOptions?: { apiKey: string }
private ollamaOptions?: { baseUrl: string; modelId?: string }
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
private openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
private geminiOptions?: { apiKey: string }
private mistralOptions?: { apiKey: string }
private vercelAiGatewayOptions?: { apiKey: string }
@@ -112,10 +112,9 @@ export class CodeIndexConfigManager {
this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined
const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined)
this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined
this.openAiCompatibleOptions =
input.openAiCompatibleBaseUrl && input.openAiCompatibleApiKey
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey }
: undefined
this.openAiCompatibleOptions = input.openAiCompatibleBaseUrl
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey?.trim() || undefined }
: undefined
this.geminiOptions = input.geminiApiKey ? { apiKey: input.geminiApiKey } : undefined
this.mistralOptions = input.mistralApiKey ? { apiKey: input.mistralApiKey } : undefined
this.vercelAiGatewayOptions = input.vercelAiGatewayApiKey ? { apiKey: input.vercelAiGatewayApiKey } : undefined
@@ -168,8 +167,7 @@ export class CodeIndexConfigManager {
return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore)
if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore)
if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore)
if (provider === "openai-compatible")
return !!(this.openAiCompatibleOptions?.baseUrl && this.openAiCompatibleOptions?.apiKey && hasStore)
if (provider === "openai-compatible") return !!(this.openAiCompatibleOptions?.baseUrl && hasStore)
if (provider === "gemini") return !!(this.geminiOptions?.apiKey && hasStore)
if (provider === "mistral") return !!(this.mistralOptions?.apiKey && hasStore)
if (provider === "vercel-ai-gateway") return !!(this.vercelAiGatewayOptions?.apiKey && hasStore)
@@ -42,7 +42,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
private embeddingsClient: OpenAI
private readonly defaultModelId: string
private readonly baseUrl: string
private readonly apiKey: string
private readonly apiKey?: string
private readonly isFullUrl: boolean
private readonly maxItemTokens: number
private readonly headers: Record<string, string>
@@ -61,13 +61,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
/**
* Creates a new OpenAI Compatible embedder
* @param baseUrl The base URL for the OpenAI-compatible API endpoint
* @param apiKey The API key for authentication
* @param apiKey Optional API key for authentication
* @param modelId Optional model identifier (defaults to "text-embedding-3-small")
* @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS)
*/
constructor(
baseUrl: string,
apiKey: string,
apiKey?: string,
modelId?: string,
maxItemTokens?: number,
options: OpenAICompatibleOptions = {},
@@ -75,22 +75,24 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
if (!baseUrl) {
throw new Error("Base URL is required for OpenAI-compatible embedder")
}
if (!apiKey) {
throw new Error("API key is required for OpenAI-compatible embedder")
}
this.baseUrl = baseUrl
this.apiKey = apiKey
this.apiKey = apiKey?.trim() || undefined
try {
this.embeddingsClient = new OpenAI({
baseURL: baseUrl,
apiKey: apiKey,
defaultHeaders: options.headers,
})
} catch (error) {
throw error instanceof Error ? error : new Error(String(error))
}
const defaults = new Headers(options.headers)
this.embeddingsClient = new OpenAI({
baseURL: baseUrl,
apiKey: this.apiKey ?? "EMPTY",
defaultHeaders: options.headers,
fetch: this.apiKey
? undefined
: async (input, init) => {
const headers = new Headers(init?.headers)
if (!defaults.has("authorization")) headers.delete("authorization")
if (!defaults.has("api-key")) headers.delete("api-key")
return globalThis.fetch(input, { ...init, headers: Object.fromEntries(headers) })
},
})
this.defaultModelId = modelId || getDefaultModelId("openai-compatible")
// Cache the URL type check for performance
@@ -213,10 +215,12 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
headers: {
"Content-Type": "application/json",
...this.headers,
// Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization'
// We'll try 'api-key' first for Azure compatibility
"api-key": this.apiKey,
Authorization: `Bearer ${this.apiKey}`,
...(this.apiKey
? {
"api-key": this.apiKey,
Authorization: `Bearer ${this.apiKey}`,
}
: {}),
},
body: JSON.stringify({
input: batchTexts,
@@ -17,7 +17,7 @@ export interface CodeIndexConfig {
kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
openAiOptions?: { apiKey: string }
ollamaOptions?: { baseUrl: string; modelId?: string }
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
vercelAiGatewayOptions?: { apiKey: string }
@@ -85,8 +85,7 @@ export class CodeIndexServiceFactory {
return new CodeIndexOllamaEmbedder(config.ollamaOptions.baseUrl, config.modelId, config.modelDimension)
}
if (provider === "openai-compatible") {
if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey)
throw new Error("OpenAI-compatible base URL and API key are required.")
if (!config.openAiCompatibleOptions?.baseUrl) throw new Error("OpenAI-compatible base URL is required.")
return new OpenAICompatibleEmbedder(
config.openAiCompatibleOptions.baseUrl,
config.openAiCompatibleOptions.apiKey,
@@ -26,6 +26,34 @@ describe("CodeIndexConfigManager", () => {
expect(cfg.getConfig().ollamaOptions?.baseUrl).toBe("http://localhost:11434")
})
test("configures an OpenAI-compatible endpoint without an API key", () => {
const cfg = new CodeIndexConfigManager(
createInput({
embedderProvider: "openai-compatible",
openAiKey: undefined,
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
}),
)
expect(cfg.isFeatureConfigured).toBe(true)
expect(cfg.getConfig().openAiCompatibleOptions).toEqual({
baseUrl: "http://localhost:1234/v1",
apiKey: undefined,
})
})
test("requires a base URL for an OpenAI-compatible endpoint", () => {
const cfg = new CodeIndexConfigManager(
createInput({
embedderProvider: "openai-compatible",
openAiKey: undefined,
openAiCompatibleApiKey: "sk-test",
}),
)
expect(cfg.isFeatureConfigured).toBe(false)
})
test("defaults vector store to LanceDB when omitted", () => {
const cfg = new CodeIndexConfigManager(createInput({ vectorStoreProvider: undefined }))
@@ -142,6 +170,19 @@ describe("CodeIndexConfigManager", () => {
expect(result.requiresRestart).toBe(true)
})
test("requires restart when OpenAI-compatible auth is added or removed", () => {
const input = createInput({
embedderProvider: "openai-compatible",
openAiKey: undefined,
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
})
const cfg = new CodeIndexConfigManager(input)
expect(cfg.loadConfiguration({ ...input, openAiCompatibleApiKey: "sk-test" }).requiresRestart).toBe(true)
expect(cfg.loadConfiguration(input).requiresRestart).toBe(true)
expect(cfg.loadConfiguration(input).requiresRestart).toBe(false)
})
test("requires restart when Kilo auth changes", () => {
const cfg = new CodeIndexConfigManager(
createInput({
@@ -63,10 +63,10 @@ describe("OpenAICompatibleEmbedder", () => {
)
})
test("should throw error when apiKey is missing", () => {
expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow(
"API key is required for OpenAI-compatible embedder",
)
test("should create embedder without an API key", () => {
embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)
expect(embedder).toBeDefined()
})
test("should throw error when both baseUrl and apiKey are missing", () => {
@@ -649,6 +649,69 @@ describe("OpenAICompatibleEmbedder", () => {
expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6])
})
test("should omit auth headers for a keyless full endpoint", async () => {
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId)
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
mockFetch.mockResolvedValue(
createMockResponse({
data: [{ embedding: base64String }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}) as any,
)
await embedder.createEmbeddings(["test"])
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
const headers = new Headers(init?.headers)
expect(headers.get("authorization")).toBeNull()
expect(headers.get("api-key")).toBeNull()
})
test("should preserve custom headers for a keyless full endpoint", async () => {
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId, undefined, {
headers: { Authorization: "Custom token", "x-fixture": "present" },
})
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
mockFetch.mockResolvedValue(
createMockResponse({
data: [{ embedding: base64String }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}) as any,
)
await embedder.createEmbeddings(["test"])
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
const headers = new Headers(init?.headers)
expect(headers.get("authorization")).toBe("Custom token")
expect(headers.get("x-fixture")).toBe("present")
})
test("should omit generated SDK auth headers when no key is configured", async () => {
let request: ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) | undefined
setOpenAIConstructorHook((config) => {
request = config.fetch
})
new OpenAICompatibleEmbedder(baseUrl, undefined, testModelId)
mockFetch.mockResolvedValue(new Response())
if (!request) throw new Error("Missing OpenAI-compatible fetch adapter")
await request("https://api.example.com/v1/embeddings", {
headers: {
Authorization: "Bearer EMPTY",
"api-key": "EMPTY",
"x-fixture": "present",
},
})
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
const headers = new Headers(init?.headers)
expect(init?.headers).not.toBeInstanceOf(Headers)
expect(headers.get("authorization")).toBeNull()
expect(headers.get("api-key")).toBeNull()
expect(headers.get("x-fixture")).toBe("present")
})
test.each([
[401, "Authentication failed. Please check your API key."],
[500, "Embedding request failed after 3 attempts"],
@@ -1,4 +1,7 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CodeIndexManager } from "../../../src/indexing/manager"
import type { IndexingConfigInput } from "../../../src/indexing/config-manager"
import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry"
@@ -100,6 +103,73 @@ describe("CodeIndexManager", () => {
expect(mgr.getCurrentStatus().message).toContain("not configured")
})
test("initializes an unauthenticated OpenAI-compatible endpoint without auth headers", async () => {
const root = await mkdtemp(join(tmpdir(), "kilo-keyless-indexing-"))
const workspace = join(root, "workspace")
const cache = join(root, "cache")
const requests: Array<{ authorization: string | null; apiKey: string | null }> = []
await Bun.write(join(workspace, ".gitkeep"), "")
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(req) {
requests.push({
authorization: req.headers.get("authorization"),
apiKey: req.headers.get("api-key"),
})
return Response.json({
object: "list",
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }],
model: "fixture-model",
usage: { prompt_tokens: 1, total_tokens: 1 },
})
},
})
try {
const script = `
import { CodeIndexManager } from "./src/indexing/manager.ts"
import { normalizeIndexingStatus } from "./src/status.ts"
const mgr = new CodeIndexManager(${JSON.stringify(workspace)}, ${JSON.stringify(cache)})
try {
await mgr.initialize({
enabled: true,
embedderProvider: "openai-compatible",
vectorStoreProvider: "lancedb",
modelId: "fixture-model",
modelDimension: 3,
openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}/v1`)},
})
if (!mgr.isInitialized) throw new Error("Manager did not initialize")
if (normalizeIndexingStatus(mgr).state === "Disabled") throw new Error("Indexing remained disabled")
} finally {
await mgr.dispose()
}
`
const child = Bun.spawn([process.execPath, "-e", script], {
cwd: join(import.meta.dir, "../../.."),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
const gate = Promise.withResolvers<never>()
const timeout = setTimeout(() => {
child.kill()
gate.reject(new Error("Indexing subprocess timed out"))
}, 10_000)
const [exit, stderr] = await Promise.race([
Promise.all([child.exited, new Response(child.stderr).text()]),
gate.promise,
]).finally(() => clearTimeout(timeout))
if (exit !== 0) throw new Error(stderr)
expect(requests).toEqual([{ authorization: null, apiKey: null }])
} finally {
server.stop(true)
await rm(root, { recursive: true, force: true })
}
})
test("cancels active indexing when configuration is removed", async () => {
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
let stop = 0
@@ -29,6 +29,16 @@ describe("CodeIndexServiceFactory", () => {
setOpenAIConstructorHook(undefined)
})
test("creates an OpenAI-compatible embedder without an API key", () => {
const factory = createFactory({
embedderProvider: "openai-compatible",
openAiKey: undefined,
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
})
expect(factory.createEmbedder().embedderInfo).toEqual({ name: "openai-compatible" })
})
test("uses default LanceDB directory when config is unset", () => {
const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined })
@@ -39,9 +39,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import java.awt.event.ActionEvent
import javax.swing.JLabel
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
@Suppress("UnstableApiUsage")
class SessionSidePanelManagerTest : BasePlatformTestCase() {
@@ -221,7 +223,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
settle { first !in ui }
expire(manager, first)
assertFalse(ui.contains(first))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created)
@@ -234,7 +236,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
settle { first !in ui }
expire(manager, first)
manager.openSession(session("ses_1"))
assertNotSame(first, active(manager))
@@ -255,7 +257,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant")))
flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale"))
}
settle { first !in ui }
expire(manager, first)
manager.openSession(session("ses_1"))
val second = active(manager)
settle()
@@ -588,7 +590,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1")))
}
manager.openSession(session("ses_2"))
settle { first !in ui }
expire(manager, first)
assertFalse(ui.contains(first))
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
@@ -603,7 +605,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
first.controller().model.setState(SessionState.Busy("running"))
}
manager.openSession(session("ses_2"))
settle { first !in ui }
expire(manager, first)
assertFalse(ui.contains(first))
}
@@ -614,7 +616,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
settle { first !in ui }
expire(manager, first)
assertFalse(ui.contains(first))
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
@@ -698,6 +700,16 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
method.invoke(manager)
}
private fun expire(manager: SessionSidePanelManager, ui: JPanel) {
val field = SessionSidePanelManager::class.java.getDeclaredField("timers")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val timers = field.get(manager) as Map<JPanel, Timer>
val timer = requireNotNull(timers[ui]) { "Expected an inactive session disposal timer" }
timer.stop()
timer.actionListeners.single().actionPerformed(ActionEvent(timer, ActionEvent.ACTION_PERFORMED, "expire"))
}
private fun settle() = kotlinx.coroutines.runBlocking {
repeat(5) {
kotlinx.coroutines.delay(100)
@@ -707,16 +719,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
}
private fun settle(done: () -> Boolean) = kotlinx.coroutines.runBlocking {
repeat(50) {
if (done()) return@runBlocking
kotlinx.coroutines.delay(20)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents()
}
}
}
private fun session(id: String) = session(id, "/test")
private fun session(id: String, dir: String, title: String = "Session $id") = SessionDto(
@@ -67,6 +67,7 @@ export enum TelemetryEventName {
// Kilo-specific
COMMIT_MSG_GENERATED = "Commit Message Generated",
AGENT_MANAGER_OPENED = "Agent Manager Opened",
AGENT_MANAGER_BUTTON_CLICKED = "Agent Manager Button Clicked",
AGENT_MANAGER_SESSION_STARTED = "Agent Manager Session Started",
AGENT_MANAGER_SESSION_COMPLETED = "Agent Manager Session Completed",
AGENT_MANAGER_SESSION_STOPPED = "Agent Manager Session Stopped",
@@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test"
import type { TelemetryRequest } from "../../webview-ui/src/types/messages/webview-messages"
import { capture, tracker } from "../../webview-ui/agent-manager/telemetry"
import { TelemetryEventName } from "../../src/services/telemetry/types"
describe("Agent Manager telemetry", () => {
it("uses one stable event with low-cardinality button metadata", () => {
const messages: TelemetryRequest[] = []
capture({ postMessage: (message) => messages.push(message) }, "fullscreen_review", "tab_toolbar", {
action: "open",
fileCount: 3,
})
expect(messages).toEqual([
{
type: "telemetry",
event: TelemetryEventName.AGENT_MANAGER_BUTTON_CLICKED,
properties: {
action: "open",
fileCount: 3,
source: "agent-manager",
button: "fullscreen_review",
surface: "tab_toolbar",
},
},
])
})
it("does not allow callers to override event dimensions", () => {
const messages: TelemetryRequest[] = []
capture({ postMessage: (message) => messages.push(message) }, "apply_to_local", "apply_dialog", {
source: "other",
button: "other",
surface: "other",
})
expect(messages[0]?.properties).toMatchObject({
source: "agent-manager",
button: "apply_to_local",
surface: "apply_dialog",
})
})
it("resolves current properties before running wrapped actions", () => {
const messages: TelemetryRequest[] = []
const order: string[] = []
const metrics = tracker({
postMessage: (message) => {
messages.push(message)
order.push("telemetry")
},
})
const state = { action: "run" }
const click = metrics.click(
"run_script",
"tab_toolbar",
() => order.push("action"),
() => state,
)
state.action = "stop"
click()
expect(messages[0]?.properties?.action).toBe("stop")
expect(order).toEqual(["telemetry", "action"])
})
})
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test"
import { tracksElapsed } from "../../webview-ui/src/components/shared/working-indicator-utils"
describe("tracksElapsed", () => {
it("tracks pending submissions before backend status arrives", () => {
expect(tracksElapsed("idle", true, 1)).toBe(true)
})
it("tracks active backend statuses", () => {
expect(tracksElapsed("busy", false, 1)).toBe(true)
expect(tracksElapsed("retry", false, 1)).toBe(true)
expect(tracksElapsed("offline", false, 1)).toBe(true)
})
it("stops for idle sessions and missing start times", () => {
expect(tracksElapsed("idle", false, 1)).toBe(false)
expect(tracksElapsed("busy", false, undefined)).toBe(false)
expect(tracksElapsed("idle", true, undefined)).toBe(false)
})
})
@@ -134,6 +134,7 @@ import { createSidebarCollapse } from "./sidebar-collapse"
import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import "./agent-manager.css"
import "./agent-manager-review.css"
const REVIEW_TAB_ID = "review"
@@ -203,6 +204,7 @@ const AgentManagerContent: Component = () => {
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const metrics = tracker(vscode)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [busyWorktrees, setBusyWorktrees] = createSignal<Map<string, WorktreeBusyState>>(new Map())
const [staleWorktreeIds, setStaleWorktreeIds] = createSignal<Set<string>>(new Set())
@@ -447,6 +449,7 @@ const AgentManagerContent: Component = () => {
if (!target) return
if (!applyHasSelection()) return
if (applyBusyForTarget()) return
metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length })
applyToLocal(target, applySelectedFiles())
}
@@ -497,6 +500,7 @@ const AgentManagerContent: Component = () => {
if (!sel || sel === LOCAL) return
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: sel })
}
const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory)
const runWorktree = (id: string) => {
const state = runStatuses()[id]?.state ?? "idle"
@@ -1600,6 +1604,7 @@ const AgentManagerContent: Component = () => {
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript)
const handleChangeDefaultBaseBranch = () => {
const [search, setSearch] = createSignal("")
@@ -1735,6 +1740,7 @@ const AgentManagerContent: Component = () => {
expandSidebar()
vscode.postMessage({ type: "agentManager.createWorktree" })
}
const createWorktree = metrics.click("new_worktree", "worktrees", handleCreateWorktree)
// Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode
const showAdvancedWorktreeDialog = () => {
@@ -1829,6 +1835,7 @@ const AgentManagerContent: Component = () => {
const handlePromote = (sessionId: string, e: MouseEvent) => {
e.stopPropagation()
if (!loaded()) return
metrics.track("promote_session", "unassigned_session")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId })
}
@@ -2204,7 +2211,7 @@ const AgentManagerContent: Component = () => {
size="small"
variant="ghost"
label={t("agentManager.worktree.new")}
onClick={handleCreateWorktree}
onClick={createWorktree}
disabled={!loaded()}
/>
<DropdownMenu gutter={4} placement="bottom-end">
@@ -2217,7 +2224,7 @@ const AgentManagerContent: Component = () => {
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={handleCreateWorktree}>
<DropdownMenu.Item onSelect={createWorktree}>
<span class="am-worktree-menu-gap" aria-hidden="true" />
<DropdownMenu.ItemLabel class="am-worktree-menu-label">
<span>{t("sidebar.session.newWorktree.from")}</span>
@@ -2260,7 +2267,7 @@ const AgentManagerContent: Component = () => {
size="small"
variant="ghost"
label={t("agentManager.shortcuts.title")}
onClick={handleShowKeyboardShortcuts}
onClick={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)}
/>
</TooltipKeybind>
<DropdownMenu gutter={4} placement="bottom-end">
@@ -2273,7 +2280,7 @@ const AgentManagerContent: Component = () => {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
<DropdownMenu.Item onSelect={setupScript}>
<DropdownMenu.ItemLabel>{t("agentManager.worktree.setupScript")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
@@ -2427,13 +2434,13 @@ const AgentManagerContent: Component = () => {
prStatuses()[wt.id] !== undefined ? (prStatuses()[wt.id] ?? undefined) : undefined
}
runStatus={runStatuses()[wt.id]}
onOpenPR={() =>
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id })
}
onOpenPR={metrics.click("open_pull_request", "worktree_menu", () =>
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }),
)}
sections={sections()}
currentSectionId={wt.sectionId}
onMoveToSection={(secId) => moveToSection([wt.id], secId)}
onMoveToNewSection={() => newSection()}
onMoveToNewSection={metrics.click("new_section", "worktree_menu", () => newSection())}
onClick={() => {
if (pendingDelete() === wt.id) {
confirmDeleteWorktree(wt.id)
@@ -2448,9 +2455,9 @@ const AgentManagerContent: Component = () => {
onCancelRename={cancelRename}
onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)}
onCopyPath={() => navigator.clipboard.writeText(wt.path)}
onOpen={() =>
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id })
}
onOpen={metrics.click("open_worktree_window", "worktree_menu", () =>
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }),
)}
/>
</div>
)
@@ -2518,7 +2525,7 @@ const AgentManagerContent: Component = () => {
)
})()}
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<button class="am-worktree-create" onClick={createWorktree}>
<Icon name="plus" size="small" />
<span>{t("agentManager.worktree.new")}</span>
</button>
@@ -2607,7 +2614,11 @@ const AgentManagerContent: Component = () => {
<Icon name="branch" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.session.openInWorktree")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => openLocally(s.id)}>
<ContextMenu.Item
onSelect={metrics.click("open_session_locally", "unassigned_session_menu", () =>
openLocally(s.id),
)}
>
<Icon name="folder" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.session.openLocally")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
@@ -2711,8 +2722,8 @@ const AgentManagerContent: Component = () => {
newTerminalLabel: t("agentManager.terminal.new"),
newSessionMenuLabel: t("agentManager.session.newSession"),
moreOptionsLabel: t("agentManager.tab.newOptions"),
onNewSession: handleAddSession,
onNewTerminal: () => termHandlers.requestNew(),
onNewSession: metrics.click("new_session", "tab_bar", handleAddSession),
onNewTerminal: metrics.click("embedded_terminal", "new_tab_menu", () => termHandlers.requestNew()),
})}
</div>
</Show>
@@ -2738,7 +2749,7 @@ const AgentManagerContent: Component = () => {
<Show when={isWorktree()}>
<>
<Tooltip value={t("agentManager.open.tooltip")} placement="bottom">
<Button size="small" variant="ghost" icon="folder" onClick={openWorktreeDirectory}>
<Button size="small" variant="ghost" icon="folder" onClick={openWindow}>
{t("agentManager.open.button")}
</Button>
</Tooltip>
@@ -2774,7 +2785,14 @@ const AgentManagerContent: Component = () => {
variant="ghost"
icon={active() ? "stop" : "play"}
disabled={rs()?.state === "stopping"}
onClick={() => runWorktree(rid())}
onClick={metrics.click(
"run_script",
"tab_toolbar",
() => runWorktree(rid()),
() => ({
action: active() ? "stop" : configured() ? "run" : "configure",
}),
)}
>
{active() ? "Stop" : "Run"}
</Button>
@@ -2794,7 +2812,9 @@ const AgentManagerContent: Component = () => {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={configureRunScript}>
<DropdownMenu.Item
onSelect={metrics.click("configure_run_script", "run_menu", configureRunScript)}
>
<Icon name="settings-gear" size="small" />
<DropdownMenu.ItemLabel>{t("agentManager.run.configure")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -2813,6 +2833,9 @@ const AgentManagerContent: Component = () => {
<button
class={`am-diff-toggle-btn ${diffOpen() && !reviewActive() ? "am-tab-diff-btn-active" : ""} ${hasChanges() ? "am-diff-toggle-has-changes" : ""}`}
onClick={() => {
metrics.track("side_review", "tab_toolbar", {
action: diffOpen() && !reviewActive() ? "close" : "open",
})
if (reviewActive()) {
closeReviewTab()
setSidePanel("diff")
@@ -2845,7 +2868,7 @@ const AgentManagerContent: Component = () => {
variant="ghost"
label={t("command.review.toggle")}
class={reviewActive() ? "am-tab-diff-btn-active" : ""}
onClick={toggleReviewTab}
onClick={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
/>
</Tooltip>
</Show>
@@ -2864,6 +2887,7 @@ const AgentManagerContent: Component = () => {
variant="ghost"
label={t("agentManager.tab.openTerminal")}
onClick={() => {
metrics.track("vscode_terminal", "tab_toolbar")
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" })
@@ -3020,6 +3044,7 @@ const AgentManagerContent: Component = () => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("open_session_locally", "readonly_banner")
openLocally(sid)
}}
>
@@ -3031,7 +3056,9 @@ const AgentManagerContent: Component = () => {
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (sid) vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
if (!sid) return
metrics.track("promote_session", "readonly_banner")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
@@ -3073,8 +3100,13 @@ const AgentManagerContent: Component = () => {
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onClose={() => setSidePanel(null)}
onExpand={selection() !== null ? openReviewTab : undefined}
onSendClick={() => metrics.track("send_review_comments", "side_review")}
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
onExpand={
selection() !== null
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
: undefined
}
onRequestDiff={requestDiffFile}
onOpenFile={(file, line) => {
const id = currentDiffSessionId()
@@ -3082,7 +3114,7 @@ const AgentManagerContent: Component = () => {
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
}}
onRevertFile={revertCtl.revert}
onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
/>
@@ -3104,6 +3136,7 @@ const AgentManagerContent: Component = () => {
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onSendAll={closeReviewTab}
onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
@@ -3114,10 +3147,10 @@ const AgentManagerContent: Component = () => {
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
}}
onRevertFile={revertCtl.revert}
onRevertFile={metrics.use("revert_file", "fullscreen_review", revertCtl.revert)}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
onClose={closeReviewTab}
onClose={metrics.click("fullscreen_review", "fullscreen_review", closeReviewTab, { action: "close" })}
/>
</div>
</Show>
@@ -75,6 +75,7 @@ interface DiffPanelProps {
onCommentsChange: (comments: ReviewComment[]) => void
composer?: ReviewComposer
onSendAll?: () => void
onSendClick?: () => void
onClose: () => void
onExpand?: () => void
onRequestDiff?: (file: string) => void
@@ -439,6 +440,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
props.onSendAll?.()
}
const sendAllClick = () => {
props.onSendClick?.()
sendAllToChat()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter") return
if (!(e.metaKey || e.ctrlKey)) return
@@ -731,7 +737,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
{comments().length} comment{comments().length !== 1 ? "s" : ""}
</span>
<TooltipKeybind title={t("agentManager.review.sendAllToChat")} keybind={sendAllKeybind()} placement="top">
<Button variant="primary" size="small" onClick={sendAllToChat}>
<Button variant="primary" size="small" onClick={sendAllClick}>
{t("agentManager.review.sendAllToChat")}
</Button>
</TooltipKeybind>
@@ -34,6 +34,7 @@ import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToTex
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
import { tracker } from "./telemetry"
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
@@ -71,6 +72,10 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const session = useSession()
const provider = useProvider()
const { config } = useConfig()
const metrics = tracker(vscode)
const track = (button: string, properties?: Record<string, string | number | boolean | undefined>) =>
metrics.track(button, "configure_worktree_dialog", properties)
const click = metrics.click
const [tab, setTab] = createSignal<DialogTab>("new")
@@ -209,6 +214,8 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
if (compareMode() && totalAllocations(modelAllocations()) === 0) return false
return true
}
const total = () => (compareMode() ? totalAllocations(modelAllocations()) : versions())
const mode = () => (compareMode() ? "compare_models" : versions() > 1 ? "multiple_versions" : "single")
const handleSubmit = () => {
if (!canSubmit()) return
@@ -224,7 +231,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const isCompare = compareMode()
const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined
const count = isCompare ? totalAllocations(modelAllocations()) : versions()
const count = total()
const sel = isCompare ? null : model()
vscode.postMessage({
@@ -320,6 +327,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const handleBranchSelect = (name: string) => {
if (isPending()) return
track("import_branch")
setImportPending(true)
setBranchOpen(false)
setBranchSearch("")
@@ -333,7 +341,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "new" }}
onClick={() => setTab("new")}
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("new"), { tab: "new" })}
type="button"
>
{t("agentManager.dialog.tab.new")}
@@ -341,7 +349,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "import" }}
onClick={() => setTab("import")}
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("import"), { tab: "import" })}
type="button"
>
{t("agentManager.dialog.tab.import")}
@@ -460,7 +468,16 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
</div>
{/* Advanced options toggle */}
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
<button
class="am-advanced-toggle"
onClick={click(
"advanced_options",
"configure_worktree_dialog",
() => setShowAdvanced(!showAdvanced()),
() => ({ action: showAdvanced() ? "close" : "open" }),
)}
type="button"
>
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
<span>{t("agentManager.dialog.advancedOptions")}</span>
</button>
@@ -584,7 +601,9 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<button
class="am-nv-pill"
classList={{ "am-nv-pill-active": versions() === count }}
onClick={() => setVersions(count)}
onClick={click("version_count", "configure_worktree_dialog", () => setVersions(count), {
count,
})}
type="button"
>
{count}
@@ -595,7 +614,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
placement="top"
contentClass="am-tooltip-wrap"
>
<button class="am-nv-pill am-nv-pill-compare" onClick={() => setCompareMode(true)} type="button">
<button
class="am-nv-pill am-nv-pill-compare"
onClick={click("compare_models", "configure_worktree_dialog", () => setCompareMode(true), {
action: "open",
})}
type="button"
>
<Icon name="layers" size="small" />
<span class="am-nv-pill-compare-label">{t("agentManager.dialog.compareModels")}</span>
</button>
@@ -622,6 +647,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<button
class="am-nv-pill-back"
onClick={() => {
track("compare_models", { action: "close" })
setCompareMode(false)
setModelAllocations(new Map())
}}
@@ -670,7 +696,21 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
</div>
{/* Submit button — fixed footer, always visible */}
<div class="am-nv-dialog-footer">
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
<Button
variant="primary"
size="large"
class="am-nv-submit"
onClick={click("create_worktree", "configure_worktree_dialog", handleSubmit, () => ({
mode: mode(),
versionCount: total(),
advanced: showAdvanced(),
customBranch: showAdvanced() && !!branchName().trim(),
customBase: showAdvanced() && !!baseBranch(),
hasPrompt: !!prompt().trim(),
hasAttachments: imageAttach.images().length > 0,
}))}
disabled={!canSubmit()}
>
<Show
when={!starting()}
fallback={
@@ -714,7 +754,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<Button
variant="secondary"
size="small"
onClick={handlePRSubmit}
onClick={click("import_pull_request", "configure_worktree_dialog", handlePRSubmit)}
disabled={!prUrl().trim() || isPending()}
>
<Show when={prPending()} fallback={t("agentManager.import.open")}>
@@ -0,0 +1,46 @@
import { TelemetryEventName } from "../../src/services/telemetry/types"
import type { TelemetryRequest } from "../src/types/messages/webview-messages"
interface Target {
postMessage(message: TelemetryRequest): void
}
type Value = string | number | boolean | undefined
type Properties = Record<string, Value>
type Input = Properties | (() => Properties)
export function capture(target: Target, button: string, surface: string, properties: Properties = {}) {
target.postMessage({
type: "telemetry",
event: TelemetryEventName.AGENT_MANAGER_BUTTON_CLICKED,
properties: {
...properties,
source: "agent-manager",
button,
surface,
},
})
}
function clicked(target: Target, button: string, surface: string, action: () => void, properties: Input = {}) {
return () => {
capture(target, button, surface, typeof properties === "function" ? properties() : properties)
action()
}
}
function used<T>(target: Target, button: string, surface: string, action: (value: T) => void) {
return (value: T) => {
capture(target, button, surface)
action(value)
}
}
export function tracker(target: Target) {
return {
track: (button: string, surface: string, properties?: Properties) => capture(target, button, surface, properties),
click: (button: string, surface: string, action: () => void, properties?: Input) =>
clicked(target, button, surface, action, properties),
use: <T>(button: string, surface: string, action: (value: T) => void) => used(target, button, surface, action),
}
}
@@ -72,6 +72,7 @@ interface FullScreenDiffViewProps {
onCommentsChange: (comments: ReviewComment[]) => void
composer?: ReviewComposer
onSendAll?: () => void
onSendClick?: () => void
diffStyle: DiffStyle
onDiffStyleChange: (style: DiffStyle) => void
markdownRender?: boolean
@@ -443,6 +444,11 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
props.onSendAll?.()
}
const sendAllClick = () => {
props.onSendClick?.()
sendAllToChat()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter") return
if (!(e.metaKey || e.ctrlKey)) return
@@ -573,7 +579,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
keybind={sendAllKeybind()}
placement="bottom"
>
<Button variant="primary" size="small" onClick={sendAllToChat}>
<Button variant="primary" size="small" onClick={sendAllClick}>
{t("agentManager.review.sendAllToChatWithCount", { count: comments().length })}
</Button>
</TooltipKeybind>
@@ -70,7 +70,7 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string;
if (provider === "openai-compatible") {
return [
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
{ key: "apiKey", label: "API Key", placeholder: "sk-..." },
{ key: "apiKey", label: "API Key (optional)", placeholder: "sk-..." },
]
}
if (provider === "gemini") return [{ key: "apiKey", label: "API Key", placeholder: "AI..." }]
@@ -10,6 +10,7 @@ import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { tracksElapsed } from "./working-indicator-utils"
export const WorkingIndicator: Component = () => {
const session = useSession()
@@ -23,7 +24,7 @@ export const WorkingIndicator: Component = () => {
const since = session.busySince()
const status = session.status()
if (status === "idle" || !since) {
if (!tracksElapsed(status, session.submitting(), since)) {
setElapsed(0)
return
}
@@ -0,0 +1,5 @@
import type { SessionStatus } from "../../types/messages"
export function tracksElapsed(status: SessionStatus, submitting: boolean, since: number | undefined): since is number {
return since !== undefined && (status !== "idle" || submitting)
}
@@ -59,7 +59,7 @@ const PROVIDER_FIELDS: Record<EmbeddingProvider, ProviderFieldDef[]> = {
ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }],
"openai-compatible": [
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
{ key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true },
{ key: "apiKey", label: "API Key (optional)", placeholder: "sk-...", sensitive: true },
],
gemini: [{ key: "apiKey", label: "API Key", placeholder: "AI...", sensitive: true }],
mistral: [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }],
+10 -2
View File
@@ -649,7 +649,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
id.includes("deepseek-reasoner") ||
id.includes("deepseek-r1") ||
id.includes("deepseek-v3") ||
id.includes("minimax") ||
// id.includes("minimax") || // kilocode_change
// id.includes("glm") || // kilocode_change
// id.includes("kimi") || // kilocode_change
// TODO: Remove this after models.dev data is fixed to use "kimi-k2.5" instead of "k2p5"
@@ -679,7 +679,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@kilocode/kilo-gateway": // kilocode_change
case "@openrouter/ai-sdk-provider":
// kilocode_change start
if (id.includes("glm") || id.includes("kimi") || id.includes("qwen")) {
if (id.includes("glm") || id.includes("kimi") || id.includes("qwen") || id.includes("minimax")) {
return {
instant: { reasoning: { enabled: false } },
thinking: { reasoning: { enabled: true } },
@@ -855,6 +855,14 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
// kilocode_change start - MiniMax M-series toggles thinking on/off rather than exposing effort levels
if (id.includes("minimax")) {
return {
instant: { thinking: { type: "disabled" } },
thinking: { thinking: { type: "adaptive" } },
}
}
// kilocode_change end
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
@@ -2410,20 +2410,42 @@ describe("ProviderTransform.variants", () => {
expect(result).toEqual({})
})
test("minimax returns empty object", () => {
// kilocode_change start: minimax
test("minimax direct anthropic provider returns instant/thinking toggle", () => {
const model = createMockModel({
id: "minimax/minimax-model",
id: "minimax/MiniMax-M3",
providerID: "minimax",
api: {
id: "minimax-model",
url: "https://api.minimax.com",
npm: "@ai-sdk/openai-compatible",
id: "MiniMax-M3",
url: "https://api.minimax.io/anthropic/v1",
npm: "@ai-sdk/anthropic",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
expect(result).toEqual({
instant: { thinking: { type: "disabled" } },
thinking: { thinking: { type: "adaptive" } },
})
})
test("minimax via kilo gateway returns instant/thinking toggle", () => {
const model = createMockModel({
id: "kilo/minimax/minimax-m3",
providerID: "kilo",
api: {
id: "minimax/minimax-m3",
url: "https://gateway.kilo.ai",
npm: "@kilocode/kilo-gateway",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({
instant: { reasoning: { enabled: false } },
thinking: { reasoning: { enabled: true } },
})
})
// kilocode_change end
test("glm returns empty object", () => {
const model = createMockModel({
id: "glm/glm-4",
+11
View File
@@ -34,6 +34,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
| `merge.ts` | Main orchestration script for upstream merges |
| `list-versions.ts` | List available upstream versions |
| `analyze.ts` | Analyze changes without merging |
| `opencode-changesets.ts` | Generate Kilo changesets from upstream opencode release notes |
| `fix-kilocode-markers.ts` | Rebuild `kilocode_change` markers for one file against the last merged upstream |
| `reset-to-upstream.ts` | Reset one file to the transformed last merged upstream version |
| `find-reset-candidates.ts` | Bulk-find files that have drifted insignificantly from upstream and (optionally) reset them |
@@ -60,6 +61,16 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
| `codemods/transform-imports.ts` | Transform import statements using ts-morph |
| `codemods/transform-strings.ts` | Transform string literals |
## Release Notes Changesets
After merging upstream opencode releases, use `opencode-changesets.ts` to turn the upstream GitHub release notes into Kilo changesets:
```bash
bun script/upstream/opencode-changesets.ts --from 1.17.0 --to 1.17.7
```
The script fetches releases from `anomalyco/opencode`, selects published releases in the semver range `(from, to]`, and writes one `.changeset/opencode-vX-Y-Z-to-vX-Y-Z.md` file for the whole range. It requires the target release to exist, merges notes from every release into shared `##` sections and `###` categories, then folds those headings into each bullet (for example, `Core Bugfixes: ...`) so Changesets can embed the notes cleanly in package changelogs. It generates a patch changeset for the fixed release group, `@kilocode/cli` and `kilo-code`. Generated notes omit contributor thank-you blocks and the upstream `Desktop` and `SDK` sections by default because Kilo does not ship the opencode desktop app and SDK release notes are not user-facing for Kilo.
## Merge Process
The merge automation follows this process, applying **all transformations BEFORE the merge** to minimize conflicts:
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test"
import { changeset, select, type Release } from "./opencode-changesets"
const releases: Release[] = [
{ tag_name: "v1.2.3", body: "Patch release" },
{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" },
{ tag_name: "1.2.1", body: "Old tag without prefix" },
{ tag_name: "v1.2.0", body: "Base release" },
{ tag_name: "v1.2.4", body: "Draft release", draft: true },
{ tag_name: "v1.2.5", body: "Prerelease", prerelease: true },
]
describe("opencode changesets", () => {
test("selects releases in semver range with normalized tags", () => {
expect(select(releases, "1.2.0", "v1.2.3")).toEqual([
{ tag_name: "v1.2.1", body: "Old tag without prefix" },
{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" },
{ tag_name: "v1.2.3", body: "Patch release" },
])
})
test("excludes prereleases", () => {
expect(() => select(releases, "1.2.3", "1.2.5")).toThrow("Target opencode release does not exist")
})
test("requires the target release to exist", () => {
expect(() => select(releases, "1.2.0", "99.9.9")).toThrow("Target opencode release does not exist")
})
test("requires the starting release to exist", () => {
expect(() => select(releases, "1.1.9", "1.2.3")).toThrow("Starting opencode release does not exist")
})
test("formats changeset markdown", () => {
expect(
changeset([{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" }], "1.2.1", "1.2.2"),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.2.1 to v1.2.2 upstream:
- Core: Fix issue
`)
})
test("filters ignored sections and contributor thanks", () => {
expect(
changeset([
{
tag_name: "v1.2.2",
body: `## Core
- Keep this
## Desktop
- Drop this
## SDK
- Drop sdk
**Thank you to 1 community contributor:**
- @user:
- Helped
`,
},
], "1.2.1", "1.2.2"),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.2.1 to v1.2.2 upstream:
- Core: Keep this
`)
})
test("bundles release notes into shared sections", () => {
expect(
changeset([
{
tag_name: "v1.2.1",
body: `## Core
### Bugfixes
- Fix first
## TUI
### Improvements
- Improve first
`,
},
{
tag_name: "v1.2.2",
body: `## Core
### Bugfixes
- Fix second
### Improvements
- Improve core
## TUI
### Improvements
- Improve second
`,
},
], "1.2.0", "1.2.2"),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.2.0 to v1.2.2 upstream:
- Core Bugfixes: Fix first
- Core Bugfixes: Fix second
- Core Improvements: Improve core
- TUI Improvements: Improve first
- TUI Improvements: Improve second
`)
})
test("preserves multiline markdown blocks", () => {
expect(
changeset([
{
tag_name: "v1.2.2",
body: `## Core
### Improvements
- Parent item
- Nested item
Continuation paragraph
- Second item
`,
},
], "1.2.1", "1.2.2"),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.2.1 to v1.2.2 upstream:
- Core Improvements: Parent item
- Nested item
Continuation paragraph
- Core Improvements: Second item
`)
})
})
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bun
import path from "path"
import semver from "semver"
import { parseArgs } from "util"
type Bump = "major" | "minor" | "patch"
export type Release = {
tag_name: string
name?: string | null
body?: string | null
draft?: boolean
prerelease?: boolean
}
type Opts = {
from: string
to: string
root: string
}
type Group = Map<string, Map<string, string[]>>
const repo = "anomalyco/opencode"
const pkgs = ["@kilocode/cli", "kilo-code"]
const bump: Bump = "patch"
const drop = ["Desktop", "SDK"]
const usage = `
Usage: bun script/upstream/opencode-changesets.ts --from <version> --to <version>
Creates one changeset for upstream opencode releases in the semver range (from, to].
Options:
--from <version> Starting opencode version, exclusive
--to <version> Ending opencode version, inclusive
-h, --help Show this help message
Example:
bun script/upstream/opencode-changesets.ts --from v1.16.0 --to v1.17.7
`
function clean(input: string) {
const raw = input.trim().replace(/^v/, "")
const version = semver.valid(raw)
if (!version) throw new Error(`Invalid semver version: ${input}`)
return version
}
function tag(input: string) {
return `v${clean(input)}`
}
function slug(from: string, to: string) {
const base = tag(from).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
const head = tag(to).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
return `opencode-${base}-to-${head}.md`
}
function header(input: string[], bump: Bump) {
return input.map((item) => `"${item}": ${bump}`).join("\n")
}
function body(release: Release) {
const text = release.body?.replace(/\r\n?/g, "\n").trim()
if (text) return text
const name = release.name?.trim()
if (name && name !== release.tag_name) return name
return `Integrate upstream opencode ${release.tag_name}.`
}
function filter(input: string, sections: string[]) {
const dropped = new Set(sections.map((item) => item.trim().toLowerCase()).filter(Boolean))
const lines = input.replace(/\r\n?/g, "\n").split("\n")
const out: string[] = []
let skip = false
let thanks = false
for (const line of lines) {
const match = line.match(/^##\s+(.+?)\s*$/)
if (match) {
skip = dropped.has(match[1].trim().toLowerCase())
thanks = false
}
if (line.match(/^\*\*Thank you to \d+ community contributors?:\*\*\s*$/)) {
thanks = true
continue
}
if (thanks) {
if (!line.startsWith("-") && !line.startsWith(" -") && line.trim() !== "") thanks = false
if (thanks) continue
}
if (!skip) out.push(line)
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim()
}
function add(groups: Group, section: string, category: string, lines: string[]) {
const text = lines.join("\n").trim()
if (!text) return
if (!groups.has(section)) groups.set(section, new Map())
const group = groups.get(section)!
if (!group.has(category)) group.set(category, [])
group.get(category)!.push(text)
}
function collect(releases: Release[]) {
const groups: Group = new Map()
for (const release of releases) {
const text = filter(body(release), drop)
let section = "Core"
let category = ""
const block: string[] = []
const flush = () => {
add(groups, section, category, block.splice(0))
}
for (const line of text.split("\n")) {
const heading = line.match(/^##\s+(.+?)\s*$/)
if (heading) {
flush()
section = heading[1].trim()
category = ""
if (!groups.has(section)) groups.set(section, new Map())
continue
}
const sub = line.match(/^###\s+(.+?)\s*$/)
if (sub) {
flush()
category = sub[1].trim()
if (!groups.has(section)) groups.set(section, new Map())
if (!groups.get(section)!.has(category)) groups.get(section)!.set(category, [])
continue
}
if (!line.trim() && block.length === 0) continue
if (line.match(/^[-*]\s+/) && block.length > 0) flush()
block.push(line)
}
flush()
}
return groups
}
function render(groups: Group) {
const lines: string[] = []
for (const [section, cats] of groups) {
for (const [category, items] of cats) {
if (items.length === 0) continue
const prefix = [section, category].filter(Boolean).join(" ")
for (const item of items) {
const text = item.replace(/^\s*[-*]\s+/, "").trimEnd()
const body = text.split("\n")
const [first = "", ...rest] = body
lines.push(`- ${prefix}: ${first.trim()}`)
lines.push(...rest.map((line) => (line.trim() ? ` ${line}` : "")))
}
}
}
return lines.join("\n")
}
function isRelease(input: unknown): input is Release {
return Boolean(input && typeof input === "object" && "tag_name" in input && typeof input.tag_name === "string")
}
export function select(releases: Release[], from: string, to: string) {
const base = clean(from)
const head = clean(to)
if (semver.gt(base, head) || base === head) throw new Error(`Expected from version to be lower than to version`)
const seen = new Set<string>()
const published = releases
.filter((release) => !release.draft)
.filter((release) => !release.prerelease)
.map((release) => ({ release, version: semver.valid(release.tag_name.replace(/^v/, "")) }))
.filter((item): item is { release: Release; version: string } => Boolean(item.version))
if (!published.some((item) => item.version === base)) {
throw new Error(`Starting opencode release does not exist or is not published: ${tag(base)}`)
}
if (!published.some((item) => item.version === head)) {
throw new Error(`Target opencode release does not exist or is not published: ${tag(head)}`)
}
return published
.filter((item) => {
if (seen.has(item.version)) return false
seen.add(item.version)
return semver.gt(item.version, base) && semver.lte(item.version, head)
})
.sort((a, b) => semver.compare(a.version, b.version))
.map((item) => ({ ...item.release, tag_name: tag(item.version) }))
}
export function changeset(releases: Release[], from: string, to: string) {
const text = render(collect(releases)) || "No upstream release notes were published."
return `---\n${header(pkgs, bump)}\n---\n\nChanges from opencode ${tag(from)} to ${tag(to)} upstream:\n\n${text}\n`
}
async function fetch_all() {
const list: Release[] = []
const auth = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
for (let page = 1; ; page++) {
const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, {
headers: {
Accept: "application/vnd.github+json",
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
})
if (!res.ok) throw new Error(`GitHub releases request failed for ${repo}: ${res.status} ${await res.text()}`)
const json: unknown = await res.json()
if (!Array.isArray(json) || !json.every(isRelease)) throw new Error(`GitHub returned invalid release data`)
const batch = json
list.push(...batch)
if (batch.length < 100) return list
}
}
function parse_opts() {
const parsed = parseArgs({
args: Bun.argv.slice(2),
options: {
from: { type: "string" },
to: { type: "string" },
help: { type: "boolean", short: "h", default: false },
},
})
if (parsed.values.help) {
process.stdout.write(usage)
process.exit(0)
}
const from = parsed.values.from
const to = parsed.values.to
if (!from || !to) throw new Error("Expected from and to opencode versions")
return { from, to, root: path.resolve(import.meta.dir, "../..") } satisfies Opts
}
export async function run(opts: Opts) {
const releases = select(await fetch_all(), opts.from, opts.to)
if (releases.length === 0) throw new Error(`No opencode releases found in range (${opts.from}, ${opts.to}]`)
const dir = path.join(opts.root, ".changeset")
const file = path.join(dir, slug(opts.from, opts.to))
await Bun.write(file, changeset(releases, opts.from, opts.to))
process.stdout.write(`Wrote ${path.relative(opts.root, file)}\n`)
}
if (import.meta.main) {
await (async () => run(parse_opts()))().catch((err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})
}