Merge remote-tracking branch 'origin/main' into rune-polo

This commit is contained in:
kirillk
2026-08-03 14:17:36 -04:00
91 changed files with 2130 additions and 254 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Start voice input faster on macOS with native AVFoundation capture.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update model search results instantly and keep the active match visible while typing.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---
Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
+157
View File
@@ -0,0 +1,157 @@
---
name: chart
description: Use when the user asks to visualize data with charts, graphs, or plots using the `chart` tool (bar, line, scatter, pie, time series, etc.).
---
# Data Visualization
The `chart` tool is ALWAYS available in this environment. When the user asks to visualize data (charts, graphs, plots), you MUST call the `chart` tool. Never output the config as text, never say the tool is unavailable, never suggest external renderers. Always use the tool call — it is the only correct response for data visualization requests. Do NOT repeat or echo the config JSON in your text response.
Use the `chart` tool only when the user explicitly asks for a chart, graph, or plot. Only use these supported Chart.js v4 types: `bar`, `bubble`, `pie`, `doughnut`, `line`, `mixed`, `polarArea`, `radar`, `scatter`. For area charts, use `line` with `fill: true` on the dataset — do NOT use `area` as a type.
Use mermaid fenced code blocks (` ```mermaid `) when:
- The user asks for a diagram, flowchart, sequence diagram, ER diagram, or architecture diagram
- Visualizing relationships, processes, or structure — not data values
Mermaid is NOT a tool and is NOT Chart.js — never call the `chart` tool for mermaid diagrams. Just write the mermaid syntax directly in your text response inside a fenced code block. No tool call needed.
Do not use either for: code, text, or data that is already clear in prose or table form.
The `chart` tool input accepts:
- `title` (string) — short label shown in the tool header
- `description` (string, optional) — subtitle shown below the title
- `spec` (string) — a Chart.js config object as a JSON string
The `spec` field must be a Chart.js config JSON string with `type`, `data`, and optionally `options`. Examples:
Bar chart:
```json
{
"type": "bar",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "label": "Value", "data": [10, 20, 15] }]
}
}
```
Area chart (line with fill):
```json
{
"type": "line",
"data": {
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": true }]
}
}
```
Line chart:
```json
{
"type": "line",
"data": {
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": false }]
}
}
```
Scatter plot:
```json
{
"type": "scatter",
"data": {
"datasets": [{
"label": "Points",
"data": [{ "x": 1, "y": 5 }, { "x": 2, "y": 8 }, { "x": 3, "y": 3 }]
}]
}
}
```
Time series:
```json
{
"type": "line",
"data": {
"labels": ["2024-01", "2024-02", "2024-03", "2024-04"],
"datasets": [{ "label": "Value", "data": [120, 145, 132, 178], "fill": true }]
}
}
```
Pie chart:
```json
{
"type": "pie",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "data": [30, 50, 20] }]
}
}
```
Doughnut chart:
```json
{
"type": "doughnut",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "data": [30, 50, 20] }]
}
}
```
Radar chart:
```json
{
"type": "radar",
"data": {
"labels": ["Speed", "Power", "Agility", "Stamina"],
"datasets": [{ "label": "Player", "data": [80, 60, 90, 70] }]
}
}
```
Bubble chart:
```json
{
"type": "bubble",
"data": {
"datasets": [{
"label": "Group A",
"data": [{ "x": 10, "y": 20, "r": 8 }, { "x": 15, "y": 10, "r": 5 }]
}]
}
}
```
Polar area chart:
```json
{
"type": "polarArea",
"data": {
"labels": ["A", "B", "C", "D"],
"datasets": [{ "data": [11, 16, 7, 14] }]
}
}
```
Mixed chart (bar + line):
```json
{
"type": "bar",
"data": {
"labels": ["Jan", "Feb", "Mar"],
"datasets": [
{ "type": "bar", "label": "Revenue", "data": [100, 120, 90] },
{ "type": "line", "label": "Trend", "data": [95, 115, 100] }
]
}
}
```
You may customize colors by setting `backgroundColor` and `borderColor` arrays on datasets. The renderer handles sizing — do not set width or height.
Only include `scales` in `options` for cartesian chart types: `bar`, `line`, `scatter`, `bubble`. Do NOT include `scales` for `pie`, `doughnut`, `polarArea`, `radar`, or `mixed` — it will cause them to fail.
+5
View File
@@ -362,6 +362,7 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/rootless": "1.5.2",
"chart.js": "4.5.1",
"diff": "catalog:",
"lucide-solid": "0.576.0",
"motion": "12.34.5",
@@ -1557,6 +1558,8 @@
"@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
@@ -2865,6 +2868,8 @@
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-sid/FCql4CmiAb1MNGMH33fwNs6esv9UNC819Yb03bQ=",
"aarch64-linux": "sha256-1H1873nLqmcKrumAjnjPPts5+EyJLBm1Z5VKsOYANeg=",
"aarch64-darwin": "sha256-i7D5RSPIVxXNY1gUnsYdFhVU2FxYEcjqYcQx6zZrJjg=",
"x86_64-darwin": "sha256-5lSTqolFVcaKqN6ndKGJLcCUP8JQ8i60np+3eAulnZg="
"x86_64-linux": "sha256-gQFDvDbTsTNge6ji5hToanPCbXHUqbx4ccAN/isBl3c=",
"aarch64-linux": "sha256-iLfVhjLJoCyudlcdw1tPW0MYt4fLdgxooC9lGb0wTy0=",
"aarch64-darwin": "sha256-ohr/Nu0GbHAECsqpB93AaNBbKHWJTjbqQgEwFoim5kw=",
"x86_64-darwin": "sha256-vWaL8d4k01J4R9ROd44BJYTsPLGt7yiismBNThlwlOY="
}
}
+1
View File
@@ -11,6 +11,7 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
variant: ModelV2.VariantID.pipe(Schema.optional), // kilocode_change - support variant-only command overrides
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
template: Schema.String.pipe(Schema.optional), // kilocode_change - allow partial command overrides
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
+26 -15
View File
@@ -29,22 +29,33 @@ export const Plugin = PluginV2.define({
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
const items = documents.flatMap((document) => Object.entries(document.commands ?? {}))
// Register every template first, preserving the normal source priority for
// metadata in the second pass. // kilocode_change
for (const [name, command] of items) {
if (command.template === undefined) {
continue
}
const template = command.template
editor.update(name, (item) => {
item.template = template
})
}
for (const [name, command] of items) {
if (command.template === undefined && !editor.get(name)) continue // kilocode_change
editor.update(name, (item) => {
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined) item.variant = ModelV2.VariantID.make(command.variant) // kilocode_change
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
})
}),
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommandV1 from "./command"
import { Schema } from "effect"
export const Info = Schema.Struct({
template: Schema.String,
template: Schema.optional(Schema.String), // kilocode_change - allow global workflow model/variant overrides
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
+2
View File
@@ -207,6 +207,7 @@ export const SubtaskPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
@@ -500,6 +501,7 @@ export const SubtaskPartInput = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
+35
View File
@@ -69,6 +69,7 @@ Review files`,
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
variant: ModelV2.VariantID.make("high"),
subtask: true,
}),
new CommandV2.Info({ name: "empty", template: "" }),
@@ -78,4 +79,38 @@ Review files`,
),
),
)
it.effect("applies a global partial override after project command definitions", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ commands: { review: { model: "anthropic/claude", variant: "high" } } }),
}),
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Review files" } } }),
}),
]),
}),
),
)
expect(yield* command.get("review")).toMatchObject({
template: "Review files",
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
})
}),
)
})
+7 -4
View File
@@ -11,6 +11,8 @@ import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const PTY_TEST_TIMEOUT = "15 seconds" // kilocode_change - PTY startup can exceed the default test timeout on macOS CI
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
@@ -59,7 +61,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
@@ -73,6 +75,7 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
onData: (chunk) => Queue.offerUnsafe(output, chunk),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
if (attachment.replay) Queue.offerUnsafe(output, attachment.replay)
attachment.activate()
return { attachment, output, ended }
})
@@ -84,7 +87,7 @@ const waitForOutput = (output: Queue.Queue<string>, text: string) =>
return received
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
@@ -144,7 +147,7 @@ describe("pty", () => {
Effect.gen(function* () {
const pty = yield* Pty.Service
const marker = "café-über-北京-🚀"
const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`])
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
const attached = yield* attachCollecting(info.id)
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
}),
@@ -268,7 +271,7 @@ describe("pty", () => {
attachment.write("ignored")
yield* pty.remove(info.id)
attachment.activate()
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
expect(yield* Deferred.await(ended).pipe(Effect.timeout(PTY_TEST_TIMEOUT))).toEqual({ exitCode: 7 })
attachment.detach()
}),
)
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
size 52887
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
size 52895
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9
size 33866
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
size 39173
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c
size 658647
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
size 758306
+3 -1
View File
@@ -6,6 +6,7 @@
"exports": {
"./font": "./src/components/font.tsx",
"./button": "./src/components/button.tsx",
"./chart": "./src/components/chart.tsx",
"./code": "./src/components/code.tsx",
"./diff": "./src/components/diff.tsx",
"./diff-ssr": "./src/components/diff-ssr.tsx",
@@ -128,6 +129,7 @@
"motion": "12.34.5",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"strip-ansi": "7.1.2"
"strip-ansi": "7.1.2",
"chart.js": "4.5.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
[data-component="chart-container"] {
padding: 12px;
}
[data-slot="chart-render"] {
width: 100%;
max-height: 300px;
}
[data-slot="chart-error"] {
padding: 12px;
color: var(--text-weak);
font-size: var(--kilo-font-size-12, 12px);
}
+146
View File
@@ -0,0 +1,146 @@
/** @jsxImportSource solid-js */
import { createEffect, createSignal, onCleanup } from "solid-js"
import { Chart, registerables } from "chart.js"
import { BasicTool } from "./basic-tool"
import type { ToolProps } from "./message-part"
import { busy } from "./tool-utils"
Chart.register(...registerables)
function getThemeColors() {
const style = getComputedStyle(document.documentElement)
const get = (v: string, fallback: string) => style.getPropertyValue(v).trim() || fallback
return {
text: get("--text-base", "#FAFAFA"),
textWeak: get("--text-weak", "#A3A3A3"),
border: get("--border-weak-base", "#FFFFFF1A"),
surface: get("--surface-raised-base", "#202020"),
series: [
get("--vscode-charts-blue", "#3B82F6"),
get("--vscode-charts-green", "#22C55E"),
get("--vscode-charts-purple", "#A855F7"),
get("--vscode-charts-orange", "#F97316"),
get("--vscode-charts-red", "#EF4444"),
get("--vscode-charts-yellow", "#EAB308"),
],
}
}
type ChartConfig = {
type: string
data: {
labels?: string[]
datasets: {
label?: string
data: number[] | { x: number | string; y: number; r?: number }[]
backgroundColor?: string | string[]
borderColor?: string | string[]
[key: string]: unknown
}[]
}
options?: Record<string, unknown>
}
export function ChartTool(props: ToolProps) {
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>()
const [error, setError] = createSignal<string>()
let rendered = false
createEffect(() => {
const el = canvas()
const raw = props.output
if (!el || !raw || busy(props.status) || rendered) return
let config: ChartConfig
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || !parsed.type || !parsed.data) {
setError("Invalid chart config — must include type and data")
return
}
config = parsed
} catch (e) {
// output is not valid JSON — model likely passed an unsupported type
console.warn("[Kilo Chart]: could not parse output as JSON", e)
return
}
let chart: Chart | undefined
onCleanup(() => {
chart?.destroy()
rendered = false
})
if (!el.isConnected) return
const colors = getThemeColors()
const isPolar = config.type === "pie" || config.type === "doughnut" || config.type === "polarArea"
const datasets = config.data.datasets.map((dataset, i) => {
if (dataset.backgroundColor) return dataset
if (isPolar) {
const data = dataset.data as unknown[]
return {
...dataset,
backgroundColor: data.map((_, j) => colors.series[j % colors.series.length]),
borderColor: data.map((_, j) => colors.series[j % colors.series.length]),
}
}
return {
backgroundColor: colors.series[i % colors.series.length],
borderColor: colors.series[i % colors.series.length],
...dataset,
}
})
try {
chart = new Chart(el, {
type: config.type as any,
data: { ...config.data, datasets } as any,
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
labels: { color: colors.textWeak },
},
},
scales: {
x: {
ticks: { color: colors.textWeak },
grid: { color: colors.border },
border: { color: colors.border },
},
y: {
ticks: { color: colors.textWeak },
grid: { color: colors.border },
border: { color: colors.border },
},
},
...config.options,
},
})
rendered = true
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to render chart")
}
})
return (
<BasicTool
{...props}
icon="bullet-list"
trigger={{
title: props.input?.title ?? "Chart",
subtitle: props.input?.description ?? undefined,
args: [],
}}
defaultOpen={props.defaultOpen ?? true}
>
<div data-component="chart-container">
{error() ? <div data-slot="chart-error">{error()}</div> : <canvas ref={setCanvas} data-slot="chart-render" />}
</div>
</BasicTool>
)
}
@@ -3097,3 +3097,9 @@ ToolRegistry.register({
)
},
})
import { ChartTool } from "./chart"
ToolRegistry.register({
name: "chart",
render: ChartTool,
})
+1
View File
@@ -10,6 +10,7 @@
@import "../components/auto-approve-bar.css";
@import "../components/button.css";
@import "../components/card.css";
@import "../components/chart.css";
@import "../components/chat-input.css";
@import "../components/checkbox.css";
@import "../components/code.css";
@@ -734,7 +734,11 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.setWorktreeOrder") {
this.state?.setWorktreeOrder(m.order)
const state = this.getStateManager()
if (state) {
state.setWorktreeOrder(m.order)
this.pushState()
}
return null
}
if (m.type === "agentManager.setSessionsCollapsed") {
@@ -626,6 +626,7 @@ interface SetTabOrderIn {
interface SetWorktreeOrderIn {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}
@@ -34,11 +34,32 @@ type Args = {
input: string[]
}
const macScript = `
ObjC.import("AVFoundation")
ObjC.import("Foundation")
function run(args) {
const settings = $.NSMutableDictionary.alloc.init
settings.setObjectForKey($.NSNumber.numberWithUnsignedInt(1819304813), $.AVFormatIDKey)
settings.setObjectForKey($.NSNumber.numberWithDouble(16000), $.AVSampleRateKey)
settings.setObjectForKey($.NSNumber.numberWithInt(1), $.AVNumberOfChannelsKey)
settings.setObjectForKey($.NSNumber.numberWithInt(16), $.AVLinearPCMBitDepthKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsFloatKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsBigEndianKey)
const error = Ref()
const url = $.NSURL.fileURLWithPath(args[0])
const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error)
if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording")
console.log("ready")
$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
recorder.stop
}`
let active: Recording | undefined
let starting: string | undefined
let ffmpeg: Promise<string> | undefined
export async function prewarmSpeechCapture(): Promise<void> {
if (useMacCapture(process.platform, process.env)) return
await resolveFFmpeg()
}
@@ -47,8 +68,15 @@ export async function startSpeechCapture(input: Input): Promise<boolean> {
starting = input.requestId
try {
const bin = await resolveFFmpeg()
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
if (useMacCapture(process.platform, process.env)) {
const result = await startMac(file, input).catch((err: unknown) => {
console.warn("[Kilo New] Native macOS speech capture failed, falling back to FFmpeg", err)
return undefined
})
if (result) return !result.stopped
}
const bin = await resolveFFmpeg()
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
return !state.stopped
} finally {
@@ -112,7 +140,7 @@ async function waitForStart(state: Recording): Promise<void> {
onError(new Error(summary(state, "Could not start microphone recording")))
}
const onData = (data: Buffer) => {
if (/Output #0|Press \[q\]|size=\s*\d+/i.test(data.toString())) done()
if (/^ready$|Output #0|Press \[q\]|size=\s*\d+/im.test(data.toString())) done()
}
const timer = setTimeout(() => {
onError(new Error(summary(state, "Timed out starting microphone recording")))
@@ -129,6 +157,21 @@ async function waitForStart(state: Recording): Promise<void> {
})
}
async function startMac(file: string, input: Input): Promise<Recording> {
const proc = spawn("/usr/bin/osascript", macCaptureArgs(file), { stdio: ["pipe", "ignore", "pipe"] })
const state = createState(input, file, proc)
await waitForStart(state)
return state
}
export function macCaptureArgs(file: string): string[] {
return ["-l", "JavaScript", "-e", macScript, file]
}
export function useMacCapture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
return platform === "darwin" && !env.KILO_FFMPEG_PATH && !env.FFMPEG_PATH
}
async function startWithArgs(bin: string, file: string, input: Input, args: Args[]): Promise<Recording> {
const [first, ...rest] = args
if (!first) throw new Error(`Unsupported platform for speech input: ${process.platform}`)
@@ -138,6 +181,18 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
: spawn(bin, ["-y", ...first.input, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
stdio: ["pipe", "ignore", "pipe"],
})
const state = createState(input, file, proc)
try {
await waitForStart(state)
return state
} catch (err) {
if (state.stopped) return state
if (rest.length === 0) throw err
return startWithArgs(bin, file, input, rest)
}
}
function createState(input: Input, file: string, proc: ChildProcess): Recording {
const state: Recording = { ...input, file, proc, stderr: [], stopped: false }
active = state
@@ -152,15 +207,7 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
state.stderr.push(err.message)
if (active === state && !state.stopped) active = undefined
})
try {
await waitForStart(state)
return state
} catch (err) {
if (state.stopped) return state
if (rest.length === 0) throw err
return startWithArgs(bin, file, input, rest)
}
return state
}
function pipeProcess(pipe: string[], bin: string, file: string): ChildProcess {
@@ -193,6 +193,18 @@ test("large catalogs keep the rendered tree bounded and navigate to distant mode
// The window mounts before we measure it, yet stays far smaller than the catalog.
await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0)
await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50)
await expect(page.getByRole("treeitem", { name: "Model 300" })).toBeVisible()
// Searching from deep in the catalog scrolls the first active match into view.
await tree.getByRole("treeitem").last().hover()
await tree.evaluate((el) => el.scrollTo({ top: el.scrollHeight }))
await combobox.pressSequentially("Model 5")
const first = page.getByRole("treeitem", { name: "Model 500" })
await expect(first).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await first.getAttribute("id"))
const hovered = page.getByRole("treeitem", { name: "Model 501" })
await hovered.hover()
await expect(combobox).toHaveAttribute("aria-activedescendant", await hovered.getAttribute("id"))
// Reaching a distant model scrolls it into the mounted window and activates it.
await combobox.fill("Model 599")
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { focusQuestionOption, hasQuestionOption } from "../../webview-ui/agent-manager/focus"
describe("Agent Manager focus", () => {
it("focuses the first enabled question option", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const disabled = window.document.createElement("button")
const option = window.document.createElement("button")
disabled.setAttribute("data-slot", "question-option")
disabled.disabled = true
option.setAttribute("data-slot", "question-option")
dock.setAttribute("data-component", "question-dock")
dock.append(disabled, option)
root.append(dock)
window.document.body.append(root)
expect(focusQuestionOption(root)).toBe(true)
expect(root.ownerDocument.activeElement).toBe(option)
})
it("ignores collapsed question bodies", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const body = window.document.createElement("div")
const option = window.document.createElement("button")
dock.setAttribute("data-component", "question-dock")
body.setAttribute("inert", "")
option.setAttribute("data-slot", "question-option")
body.append(option)
dock.append(body)
root.append(dock)
window.document.body.append(root)
expect(focusQuestionOption(root)).toBe(false)
expect(root.ownerDocument.activeElement).not.toBe(option)
})
it("only reports enabled options outside inert bodies", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const option = window.document.createElement("button")
dock.setAttribute("data-component", "question-dock")
option.setAttribute("data-slot", "question-option")
dock.append(option)
root.append(dock)
expect(hasQuestionOption(root)).toBe(true)
dock.setAttribute("inert", "")
expect(hasQuestionOption(root)).toBe(false)
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test"
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
const state = (projectId: string, order: string[]) => ({
type: "agentManager.state" as const,
projectId,
worktrees: order.map((id) => ({
id,
branch: `${projectId}-${id}`,
path: `/repo/${projectId}/${id}`,
parentBranch: "main",
createdAt: "2026-01-01",
})),
sessions: [],
sections: [],
worktreeOrder: order,
})
describe("project stores", () => {
it("keeps worktree order isolated between projects", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a", ["same", "other"]))
second.applyState(state("b", ["same", "other"]))
first.setWorktreeOrder(["other", "same"])
expect(first.worktreeOrder()).toEqual(["other", "same"])
expect(second.worktreeOrder()).toEqual(["same", "other"])
})
it("preserves live run statuses when state omits them", () => {
const store = createProjectStore("a")
store.applyState(state("a", ["same", "other"]))
store.setRunStatuses({
same: { worktreeId: "same", state: "running" },
})
store.applyState(state("a", ["other", "same"]))
expect(store.runStatuses()).toEqual({
same: { worktreeId: "same", state: "running" },
})
})
})
@@ -7,6 +7,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
} from "../../webview-ui/agent-manager/section-helpers"
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
@@ -112,6 +113,23 @@ describe("isGrouped", () => {
})
})
describe("sortWorktrees", () => {
it("applies persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["c", "a", "b"]).map((item) => item.id)).toEqual(["c", "a", "b"])
})
it("keeps multi-version siblings adjacent at the first group position", () => {
const all = [wt("a", { groupId: "g" }), wt("b"), wt("c", { groupId: "g" })]
expect(sortWorktrees(all, ["b", "c", "a"]).map((item) => item.id)).toEqual(["b", "c", "a"])
})
it("appends worktrees missing from persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["b"]).map((item) => item.id)).toEqual(["b", "a", "c"])
})
})
describe("isGroupStart", () => {
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
@@ -1,5 +1,28 @@
import { describe, expect, it } from "bun:test"
import { cleanOutput, parseDshowAudioDevices } from "../../src/speech-to-text/capture"
import { cleanOutput, macCaptureArgs, parseDshowAudioDevices, useMacCapture } from "../../src/speech-to-text/capture"
describe("macCaptureArgs", () => {
it("records 16 kHz mono PCM with the built-in AVFoundation bridge", () => {
const args = macCaptureArgs("/tmp/speech.wav")
expect(args.slice(0, 3)).toEqual(["-l", "JavaScript", "-e"])
expect(args.at(-1)).toBe("/tmp/speech.wav")
expect(args[3]).toContain("AVAudioRecorder")
expect(args[3]).toContain("numberWithDouble(16000)")
expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey")
expect(args[3]).toContain('console.log("ready")')
})
})
describe("useMacCapture", () => {
it("preserves explicit FFmpeg overrides", () => {
expect(useMacCapture("darwin", {})).toBe(true)
expect(useMacCapture("darwin", { KILO_FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
expect(useMacCapture("darwin", { FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
expect(useMacCapture("linux", {})).toBe(false)
expect(useMacCapture("win32", {})).toBe(false)
})
})
describe("parseDshowAudioDevices", () => {
it("extracts Windows dshow audio device names", () => {
@@ -159,6 +159,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
type TopLevelItem,
} from "./section-helpers"
import {} from "./section-dnd"
@@ -174,6 +175,7 @@ import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, hasQuestionOption } from "./focus"
import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
@@ -379,6 +381,27 @@ const AgentManagerContent: Component = () => {
const sel = selection()
return sel === null ? null : nsKey(sel)
})
const requestChatFocus = createChatFocus({
term: () => terms.activeId(),
history,
review: reviewActive,
})
createEffect(
on(
() => {
const id = session.currentSessionID()
return `${id ?? ""}:${session
.scopedQuestions(id)
.map((question) => question.id)
.join(",")}`
},
() => {
requestChatFocus()
},
{ defer: true },
),
)
type FocusOwner = "prompt" | { terminal: string }
const focusMemory = new Map<string, FocusOwner>()
@@ -425,7 +448,7 @@ const AgentManagerContent: Component = () => {
}
if (!terminal) focusMemory.delete(key)
}
window.dispatchEvent(new Event("focusPrompt"))
requestChatFocus()
}
createEffect(
on(
@@ -845,39 +868,7 @@ const AgentManagerContent: Component = () => {
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
const sortedWorktrees = createMemo(() => {
const ordered = applyTabOrder(worktrees(), sidebarWorktreeOrder())
if (ordered.length === 0) return []
// Collect grouped worktrees by groupId
const grouped = new Map<string, WorktreeState[]>()
for (const wt of ordered) {
if (!wt.groupId) continue
const list = grouped.get(wt.groupId) ?? []
list.push(wt)
grouped.set(wt.groupId, list)
}
// Build output: interleave groups at the position of their earliest member
const result: WorktreeState[] = []
const placed = new Set<string>()
for (const wt of ordered) {
if (placed.has(wt.id)) continue
if (wt.groupId) {
if (placed.has(wt.groupId)) continue
placed.add(wt.groupId)
const group = grouped.get(wt.groupId) ?? []
for (const g of group) {
result.push(g)
placed.add(g.id)
}
} else {
result.push(wt)
placed.add(wt.id)
}
}
return result
})
const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder()))
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
@@ -913,12 +904,14 @@ const AgentManagerContent: Component = () => {
setSelection(null)
setReviewActive(false)
session.selectSession(id)
requestChatFocus(true)
}
const focusSidebarItem = (item: { type: string; id: string }) => {
if (item.type === "local") selectLocal()
else if (item.type === "wt") selectWorktree(item.id)
else selectUnassigned(item.id)
requestChatFocus(true)
const el = document.querySelector(`[data-sidebar-id="${item.id}"]`)
if (el instanceof HTMLElement) scrollIntoView(el)
}
@@ -951,6 +944,7 @@ const AgentManagerContent: Component = () => {
const next = direction === "left" ? idx - 1 : idx + 1
if (next < 0 || next >= ids.length) return
focusTab(ids[next]!)
requestChatFocus(true)
}
const selectionDeps = {
@@ -971,10 +965,15 @@ const AgentManagerContent: Component = () => {
remembered === REVIEW_TAB_ID && reviewOpenByContext()[sel] === true,
}
const selectLocal = () => selectLocalAction(selectionDeps, localSessions())
const selectLocal = () => {
selectLocalAction(selectionDeps, localSessions())
requestChatFocus()
}
const selectWorktree = (worktreeId: string) =>
const selectWorktree = (worktreeId: string) => {
selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId))
requestChatFocus()
}
const addSessionToCurrentWorktree = (sid: string) => {
const sel = selection()
@@ -994,6 +993,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(worktreeId)
setHistory(false)
session.selectSession(sid)
requestChatFocus()
return true
}
@@ -1113,6 +1113,7 @@ const AgentManagerContent: Component = () => {
setSelection,
setActivePendingId,
})
requestChatFocus()
}
// Recover sidebar collapsed state and mark hydrated so transitions enable
sidebar.hydrate(state.sidebarCollapsed)
@@ -1177,7 +1178,7 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt"))
else if (msg.action === "focusInput") requestChatFocus(true)
else if (msg.action === "focusSearch")
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
else if (msg.action === "newTerminal") termHandlers.requestNew()
@@ -1382,6 +1383,7 @@ const AgentManagerContent: Component = () => {
const ms = managedSessions().find((s) => s.id === ev.sessionId)
if (ms?.worktreeId) setSelection(ms.worktreeId)
evictLocal(ev.sessionId)
requestChatFocus(true)
}
} else {
// Track this worktree as setting up and auto-select it in the sidebar
@@ -1410,6 +1412,7 @@ const AgentManagerContent: Component = () => {
evictLocal(ev.sessionId)
drafts.apply(ev.worktreeId, ev.sessionId)
session.selectSession(ev.sessionId)
requestChatFocus(true)
}
if (msg.type === "agentManager.sessionForked") {
@@ -1429,6 +1432,7 @@ const AgentManagerContent: Component = () => {
evictLocal(ev.sessionId)
}
session.selectSession(ev.sessionId)
requestChatFocus(true)
}
if (msg.type === "agentManager.keybindings") {
@@ -1969,6 +1973,7 @@ const AgentManagerContent: Component = () => {
setSelection(LOCAL)
setReviewActive(false)
session.selectSession(sid)
requestChatFocus()
vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid })
}
@@ -2077,7 +2082,7 @@ const AgentManagerContent: Component = () => {
cancelAmbientSetup()
setSidePanel(null)
},
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
refocus: requestChatFocus,
postMessage: (msg) => vscode.postMessage(msg as never),
track: (button, surface, properties) => metrics.track(button, surface, properties),
// Panel-local pick, immune to cross-window setting echoes (see side.ts).
@@ -2173,7 +2178,7 @@ const AgentManagerContent: Component = () => {
return activeTabs().find((s) => s.id === id)
})
const focusTab = (id: string) =>
const focusTab = (id: string) => {
focusCurrentTab({
id,
terms,
@@ -2189,6 +2194,7 @@ const AgentManagerContent: Component = () => {
selectSession: session.selectSession,
activateTerminal: termHandlers.activate,
})
}
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
// Close the currently active tab via keyboard shortcut.
@@ -2328,6 +2334,7 @@ const AgentManagerContent: Component = () => {
<ProjectList
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
@@ -2498,6 +2505,7 @@ const AgentManagerContent: Component = () => {
saveTabMemory()
session.selectSession(id)
setSelection(LOCAL)
requestChatFocus(true)
return
}
const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined
@@ -2505,6 +2513,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
requestChatFocus()
return
}
openLocally(id)
@@ -2559,6 +2568,7 @@ const AgentManagerContent: Component = () => {
if (localSessionIDs().includes(id)) {
session.selectSession(id)
if (selection() === null) setSelection(LOCAL)
requestChatFocus()
return
}
// Navigate to owning worktree instead of forcing into local mode
@@ -2568,6 +2578,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
requestChatFocus()
return
}
}
@@ -2579,6 +2590,7 @@ const AgentManagerContent: Component = () => {
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
deferFocusToQuestion={hasQuestionOption}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
focusOnDraftChange={focusOnDraftChange}
onFocusChange={rememberPromptFocus}
@@ -19,11 +19,13 @@ import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL } from "./navigate"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { ProjectBranchDialog } from "./ProjectBranchDialog"
import type { ProjectStore } from "./project/store"
import type { ModeRouter } from "./mode-router"
interface Props {
projects: AgentProjectSnapshot[]
states: Record<string, AgentManagerStateMessage>
store?: (projectId: string) => ProjectStore
stats: Record<string, Record<string, WorktreeGitStats>>
local: Record<string, LocalGitStats>
prs: Record<string, Record<string, PRStatus | null>>
@@ -208,6 +210,7 @@ export const ProjectList: Component<Props> = (props) => {
<ProjectSidebarBody
project={project}
state={props.states[project.id]}
store={props.store?.(project.id)}
stats={props.stats[project.id]}
local={props.local[project.id]}
prs={props.prs[project.id]}
@@ -1,26 +1,40 @@
import { For, Show, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { DragDropProvider, DragDropSensors } from "@thisbeyond/solid-dnd"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
createSortable,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import type {
AgentManagerStateMessage,
AgentProjectSnapshot,
LocalGitStats,
PRStatus,
ProjectSessionInfo,
WorktreeState,
WorktreeGitStats,
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import { formatRelativeDate } from "../src/utils/date"
import SectionHeader from "./SectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { ProjectActions } from "./ProjectActions"
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
interface Props {
project: AgentProjectSnapshot
state?: AgentManagerStateMessage
store?: ProjectStore
busy?: (id: string) => boolean
stats?: Record<string, WorktreeGitStats>
local?: LocalGitStats
@@ -40,8 +54,21 @@ interface Props {
/** Permanent real sidebar body for one expanded project. */
export const ProjectSidebarBody: Component<Props> = (props) => {
const vscode = useVSCode()
const store = props.store ?? createProjectStore(props.project.id)
if (!props.store) {
createEffect(() => {
const state = props.state
if (state) store.applyState(state)
})
}
const [pending, setPending] = createSignal<string>()
const [renaming, setRenaming] = createSignal<string>()
const [renamingSection, setRenamingSection] = createSignal<string>()
const [pendingSection, setPendingSection] = createSignal<
{ ids: Set<string>; state?: AgentManagerStateMessage } | undefined
>()
const [dragging, setDragging] = createSignal<string>()
const [dragOrigin, setDragOrigin] = createSignal<string[]>()
const [name, setName] = createSignal("")
let pendingTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(pendingTimer))
@@ -61,14 +88,105 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const sessions = (worktreeId: string | null) =>
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
const active = () => props.selectedProject === props.project.id
const runs = createMemo(() => Object.fromEntries((state()?.runStatuses ?? []).map((run) => [run.worktreeId, run])))
const sections = () => state()?.sections ?? []
const runs = () => store.runStatuses()
const sections = () => store.sections()
const worktrees = () => store.worktrees()
const order = () => store.worktreeOrder()
const localSessions = () => sessions(null)
const ungrouped = () => state()?.worktrees.filter((wt) => !wt.sectionId) ?? []
const members = (sectionId: string) => state()?.worktrees.filter((wt) => wt.sectionId === sectionId) ?? []
const sorted = createMemo(() => sortWorktrees(worktrees(), order()))
const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId)
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order()))
const post = (message: Record<string, unknown>) =>
vscode.postMessage({ ...message, projectId: props.project.id } as never)
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
if (typeof value !== "string") return
const prefix = `${props.project.id}:${kind}:`
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined
}
const createSection = (worktreeIds?: string[]) => {
setPendingSection({ ids: new Set(sections().map((section) => section.id)), state: state() })
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
color: randomColor(),
worktreeIds,
})
}
createEffect(() => {
const previous = pendingSection()
if (!previous) return
const current = state()
if (current === previous.state) return
const created = (current?.sections ?? []).find((section) => !previous.ids.has(section.id))
setPendingSection(undefined)
if (!created) return
setRenamingSection(created.id)
})
const worktreeIds = createMemo(() => new Set(worktrees().map((wt) => wt.id)))
const sectionIds = createMemo(() => new Set(sections().map((section) => scope("section", section.id))))
const home = createMemo(
() =>
new Map(
worktrees().map(
(wt) => [scope("worktree", wt.id), wt.sectionId ? scope("section", wt.sectionId) : undefined] as const,
),
),
)
const detector = sectionAwareDetector(sectionIds, home)
const dragIds = createMemo(() => sorted().map((wt) => scope("worktree", wt.id)))
const onDragStart = (event: DragEvent) => {
const id = parse("worktree", event.draggable?.id)
if (!id || !worktreeIds().has(id)) return
setDragging(id)
setDragOrigin(order())
document.body.classList.add("am-wt-dragging-active")
}
const onDragOver = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const to = parse("worktree", event.droppable?.id)
if (!from || !to || !worktreeIds().has(from) || !worktreeIds().has(to)) return
store.setWorktreeOrder((previous) => {
const current = applyTabOrder(
sorted().map((wt) => ({ id: wt.id })),
previous,
).map((item) => item.id)
return reorderTabs(current, from, to) ?? previous
})
}
const onDragEnd = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const section = parse("section", event.droppable?.id)
const to = parse("worktree", event.droppable?.id)
setDragging(undefined)
const origin = dragOrigin()
setDragOrigin(undefined)
document.body.classList.remove("am-wt-dragging-active")
if (!from || !worktreeIds().has(from)) {
if (origin) store.setWorktreeOrder(origin)
return
}
if (section && sections().some((item) => item.id === section)) {
post({ type: "agentManager.moveToSection", worktreeIds: [from], sectionId: section })
return
}
if (!to || !worktreeIds().has(to)) {
if (origin) store.setWorktreeOrder(origin)
return
}
post({ type: "agentManager.setWorktreeOrder", order: order() })
}
onCleanup(() => document.body.classList.remove("am-wt-dragging-active"))
// Escape unmounts the focused rename input, which fires a synchronous blur
// that would re-commit the cancelled value; this flag swallows that blur.
let cancelled = false
@@ -86,62 +204,64 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
setRenaming(undefined)
}
const renderWorktree = (worktree: NonNullable<Props["state"]>["worktrees"][number]) => (
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || worktree.branch}
subtitle={worktree.label && worktree.label !== worktree.branch ? worktree.branch : undefined}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={false}
groupStart={false}
groupEnd={false}
groupSize={0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.worktree.newSection"),
worktreeIds: [worktree.id],
})
}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
)
const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => {
const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch)
const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined)
const sortable = createSortable(scope("worktree", worktree.id))
void sortable
return (
<div use:sortable class={`am-wt-sortable ${sortable.isActiveDraggable ? "am-wt-dragging" : ""}`}>
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || label()}
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
groupEnd={isGroupEnd(worktree, idx(), list)}
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() => createSection([worktree.id])}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
</div>
)
}
return (
<div class="am-project-body" data-project-body={props.project.id}>
@@ -202,52 +322,73 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
})
}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
/>
</div>
<div class="am-worktree-list">
{/*
SectionHeader registers a drop target via solid-dnd, which throws
without a DragDropProvider ancestor and kills the whole render.
Multi-project has no drag-and-drop yet, so this provider is a
no-op context until DnD lands here.
*/}
<DragDropProvider onDragStart={() => {}} onDragEnd={() => {}}>
<DragDropProvider
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
collisionDetector={detector}
>
<DragDropSensors />
<For each={sections()}>
{(section, index) => (
<SectionHeader
section={section}
count={members(section.id).length}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
<ConstrainDragXAxis />
<SortableProvider ids={dragIds()}>
<For each={top()}>
{(item, index) => {
if (item.kind === "worktree") {
const list = ungrouped()
return renderWorktree(item.wt, () => list.indexOf(item.wt), list)
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === sections().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={members(section.id)}>{renderWorktree}</For>
</div>
</Show>
</SectionHeader>
)}
</For>
<For each={ungrouped()}>{renderWorktree}</For>
const section = item.section
const list = members(section.id)
return (
<SectionHeader
section={section}
dropId={scope("section", section.id)}
count={list.length}
autoRename={renamingSection() === section.id}
onRenameEnd={() => {
if (renamingSection() === section.id) setRenamingSection(undefined)
}}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === top().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={list}>{(wt, wtIndex) => renderWorktree(wt, wtIndex, list)}</For>
</div>
</Show>
</SectionHeader>
)
}}
</For>
</SortableProvider>
<DragOverlay>
{(() => {
const wt = sorted().find((item) => item.id === dragging())
if (!wt) return null
return (
<div class="am-wt-overlay">
<Icon name="branch" size="small" />
<span>{wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch)}</span>
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
</div>
</div>
@@ -12,6 +12,8 @@ interface Props {
children?: JSX.Element
/** When true, auto-enter rename mode (e.g. after creation). */
autoRename?: boolean
/** Scoped drop id used when multiple project DnD providers are mounted. */
dropId?: string
onToggle: () => void
onRename: (name: string) => void
onDelete: () => void
@@ -59,7 +61,7 @@ const SectionHeader: Component<Props> = (props) => {
props.onToggle()
}
const droppable = createDroppable(props.section.id)
const droppable = createDroppable(props.dropId ?? props.section.id)
return (
<div
@@ -0,0 +1,48 @@
const OPTION = '[data-component="question-dock"] button[data-slot="question-option"]'
export function createChatFocus(deps: {
term: () => string | undefined
history: () => boolean
review: () => boolean
}) {
const focus = (force: boolean) => {
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
if (!force && document.activeElement?.matches('[role="tab"]')) return
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
if (focusQuestionOption()) return
const defer = hasQuestionOption()
window.dispatchEvent(
new CustomEvent("focusPrompt", {
detail: { restore: !defer, deferFocusToQuestion: defer },
}),
)
}
return (force = false) => {
queueMicrotask(() => focus(force))
requestAnimationFrame(() => {
focus(force)
requestAnimationFrame(() => {
focus(force)
requestAnimationFrame(() => focus(force))
})
})
}
}
/** Return whether the visible question dock has an enabled option to focus. */
export function hasQuestionOption(root: ParentNode = document): boolean {
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
if (!option.disabled && !option.closest("[inert]")) return true
}
return false
}
/** Focus the first enabled option in the visible question dock, if one exists. */
export function focusQuestionOption(root: ParentNode = document): boolean {
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
if (option.disabled || option.closest("[inert]")) continue
option.focus({ preventScroll: true })
return true
}
return false
}
@@ -78,9 +78,11 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined)
setRunScriptConfigured(state.runScriptConfigured === true)
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
const runs: Record<string, RunStatus> = {}
for (const item of state.runStatuses ?? []) runs[item.worktreeId] = item
setRunStatuses(runs)
if (state.runStatuses) {
const runs: Record<string, RunStatus> = {}
for (const item of state.runStatuses) runs[item.worktreeId] = item
setRunStatuses(runs)
}
// Reconcile busy flags with the worktree list (deleted worktrees drop out).
const ids = new Set(state.worktrees.map((wt) => wt.id))
setBusy((prev) => {
@@ -3,11 +3,44 @@
* Pure functions — no solid-dnd dependency so they remain testable.
*/
import type { WorktreeState, SectionState } from "../src/types/messages"
import { applyTabOrder } from "./tab-order"
export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState }
export type SidebarItem = { type: "local" | "wt" | "session"; id: string }
/** Apply persisted order while keeping multi-version worktrees adjacent. */
export function sortWorktrees(all: WorktreeState[], order: string[]): WorktreeState[] {
const ordered = applyTabOrder(all, order)
if (ordered.length === 0) return []
const groups = new Map<string, WorktreeState[]>()
for (const wt of ordered) {
if (!wt.groupId) continue
const group = groups.get(wt.groupId) ?? []
group.push(wt)
groups.set(wt.groupId, group)
}
const result: WorktreeState[] = []
const placed = new Set<string>()
for (const wt of ordered) {
if (placed.has(wt.id)) continue
if (!wt.groupId) {
result.push(wt)
placed.add(wt.id)
continue
}
if (placed.has(wt.groupId)) continue
placed.add(wt.groupId)
for (const item of groups.get(wt.groupId) ?? []) {
result.push(item)
placed.add(item.id)
}
}
return result
}
/** Build a canonical sidebar order containing section IDs and every worktree ID. */
export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], order: string[]): string[] {
const valid = new Set([...secs.map((sec) => sec.id), ...all.map((wt) => wt.id)])
@@ -39,6 +39,7 @@ interface ChatViewProps {
/** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */
continueInWorktree?: boolean
promptBoxId?: string
deferFocusToQuestion?: () => boolean
pendingSessionID?: string
focusOnDraftChange?: () => boolean
onFocusChange?: (focused: boolean) => void
@@ -386,6 +387,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
suggesting={suggesting}
questioning={questioning}
boxId={props.promptBoxId}
deferFocusToQuestion={props.deferFocusToQuestion}
pendingSessionID={pendingSessionID()}
focusOnDraftChange={props.focusOnDraftChange}
onFocusChange={props.onFocusChange}
@@ -109,6 +109,8 @@ interface PromptInputProps {
suggesting?: () => boolean
/** When true, session is busy only because a question is pending — treat as idle for input */
questioning?: () => boolean
/** When true, defer prompt focus while switching to a pending question */
deferFocusToQuestion?: () => boolean
boxId?: string
pendingSessionID?: string
/** Agent Manager can suppress automatic prompt focus when this session last
@@ -386,7 +388,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
textareaRef.scrollTop = scroll
if (highlightRef) highlightRef.scrollTop = scroll
}
if (props.focusOnDraftChange?.() ?? true) window.dispatchEvent(new Event("focusPrompt"))
if (!props.deferFocusToQuestion?.() && (props.focusOnDraftChange?.() ?? true)) {
window.dispatchEvent(new Event("focusPrompt"))
}
}),
)
@@ -410,7 +414,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Focus textarea when any part of the app requests it
const onFocusPrompt = (event: Event) => {
const defer = () =>
event instanceof CustomEvent && event.detail?.deferFocusToQuestion && props.deferFocusToQuestion?.()
const focus = () => {
if (defer()) return
const ref = textareaRef
if (!ref) return
ref.focus({ preventScroll: true })
@@ -418,6 +425,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
focus()
if (!(event instanceof CustomEvent) || !event.detail?.restore) return
const restore = () => {
if (defer()) return
window.focus()
focus()
}
@@ -4,10 +4,16 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { useConfig } from "../../../context/config"
import { useLanguage } from "../../../context/language"
import { useProvider } from "../../../context/provider"
import { ModelSelectorBase } from "../../shared/ModelSelector"
import { ThinkingSelectorBase } from "../../shared/ThinkingSelector"
import { parseModelString } from "../../../../../src/shared/provider-model"
import type { CommandConfig } from "../../../types/messages"
const WorkflowsTab: Component = () => {
const language = useLanguage()
const { config } = useConfig()
const { config, globalConfig, globalDraft, updateGlobalConfig } = useConfig()
const provider = useProvider()
const cmds = createMemo(() => Object.entries(config().command ?? {}))
const [expanded, setExpanded] = createSignal<Record<string, boolean>>({})
@@ -16,6 +22,38 @@ const WorkflowsTab: Component = () => {
setExpanded((prev) => ({ ...prev, [name]: !prev[name] }))
}
const update = (name: string, patch: Partial<CommandConfig>) => {
updateGlobalConfig({ command: { [name]: patch } })
}
const scoped = (cmd: CommandConfig, name: string) => ({
...cmd,
...globalConfig().command?.[name],
...globalDraft().command?.[name],
})
const model = (cmd: CommandConfig, name: string) => {
const value = scoped(cmd, name).model
return value === null ? null : parseModelString(value ?? undefined)
}
const variant = (cmd: CommandConfig, name: string) => {
const value = scoped(cmd, name).variant
return value === null ? undefined : (value ?? undefined)
}
const variants = (cmd: CommandConfig, name: string) =>
Object.keys(provider.findModel(model(cmd, name))?.variants ?? {})
const selectModel = (name: string, providerID: string, modelID: string) => {
const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {})
const current = variant(config().command?.[name] ?? {}, name)
update(name, {
model: providerID && modelID ? `${providerID}/${modelID}` : null,
...(current && !list.includes(current) ? { variant: null } : {}),
})
}
return (
<div>
{/* Description */}
@@ -118,6 +156,38 @@ const WorkflowsTab: Component = () => {
{cmd.description}
</div>
</Show>
<div
style={{
display: "flex",
"flex-wrap": "wrap",
gap: "8px",
"margin-bottom": "8px",
}}
onClick={(e) => e.stopPropagation()}
>
<ModelSelectorBase
value={model(cmd, name)}
onSelect={(providerID, modelID) => selectModel(name, providerID, modelID)}
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={`${name} ${language.t("settings.agentBehaviour.workflows.model")}`}
description={language.t("settings.agentBehaviour.workflows.modelDescription")}
/>
<Show when={variants(cmd, name).length > 0 || !!variant(cmd, name)}>
<ThinkingSelectorBase
variants={variants(cmd, name)}
value={variant(cmd, name)}
onSelect={(variant) => update(name, { variant })}
onClear={() => update(name, { variant: null })}
allowClear
clearLabel={language.t("settings.providers.notSet")}
placement="bottom-start"
globalTrigger={false}
label={`${name} ${language.t("settings.agentBehaviour.workflows.variant")}`}
/>
</Show>
</div>
<Show when={cmd.template}>
<div>
<span style={{ "font-weight": "500" }}>
@@ -159,7 +159,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const expanded = vscode.getModelSelectorExpanded
const setExpanded = vscode.setModelSelectorExpanded
const [search, setSearch] = createSignal("")
const [debouncedSearch, setDebouncedSearch] = createSignal("")
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
const [browsing, setBrowsing] = createSignal(false)
const [navigating, setNavigating] = createSignal(false)
@@ -181,6 +180,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
let listRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let previewTimer: ReturnType<typeof setTimeout> | undefined
let scrollFrame: number | undefined
let pointerX: number | undefined
let pointerY: number | undefined
let previousSearch: string | undefined
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
const [pointer, setPointer] = createSignal(true)
@@ -223,16 +226,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const hasProviders = () => visibleModels().length > 0
const canOpen = () => hasProviders() || ((props.allowClear ?? false) && !!props.value)
// Debounce search input to avoid re-filtering on every keystroke
createEffect(() => {
const q = search()
const t = setTimeout(() => setDebouncedSearch(q), 250)
onCleanup(() => clearTimeout(t))
})
// Flat filtered list for keyboard navigation
const filtered = createMemo(() => {
const q = debouncedSearch().trim()
const q = search().trim()
if (!q) {
return visibleModels()
}
@@ -251,7 +247,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const favoriteModels = createMemo(() => {
if (props.favorites === false) return []
if (!session || debouncedSearch()) return []
if (!session || search()) return []
const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m]))
const list = session
.favoriteModels()
@@ -400,7 +396,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
const key = modelKey(m.providerID, m.id)
const favorite = favoriteKey(m)
if (!debouncedSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
return canonicalKey(m)
}
const chosen = (row: ModelRow) => {
@@ -453,7 +449,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// which would cause star/unstar to reset selection mid-interaction.
// Falls back to defaultKey when the active model is filtered out.
createEffect(() => {
const query = search()
const list = filtered()
const searchChanged = query !== previousSearch
previousSearch = query
untrack(() => {
const active = activeModel()
const canon = active ? canonicalKey(active) : null
@@ -468,10 +467,16 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
? CLEAR_KEY
: defaultKey()
setSelectedKey(next)
setBrowsing(!!debouncedSearch() && nodeMap().has(next))
setBrowsing(!!search() && nodeMap().has(next))
setNavigating(false)
setPreActiveKey(next)
setPreviewKey(next)
if (!open() || !searchChanged) return
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
scrollRow(next, "nearest")
})
})
})
@@ -500,8 +505,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
setBrowsing(false)
setNavigating(false)
setSearch("")
setDebouncedSearch("")
clearTimeout(previewTimer)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
scrollFrame = undefined
})
// Register before the popover mounts so programmatic slash-command opens
@@ -519,6 +525,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
window.removeEventListener("openModelPicker", onTrigger)
window.removeEventListener("keydown", onEscape, true)
clearTimeout(previewTimer)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
})
function pick(model: EnrichedModel) {
@@ -552,6 +559,20 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
previewTimer = setTimeout(() => setPreviewKey(key), 200)
}
function pointerMove(e: MouseEvent) {
const target = e.target
if (!(target instanceof Element)) return
const item = target.closest<HTMLElement>('[role="treeitem"][data-key]')
const key = item?.dataset.key
if (!key) return
const moved = pointerX !== undefined && pointerY !== undefined && (e.clientX !== pointerX || e.clientY !== pointerY)
pointerX = e.clientX
pointerY = e.clientY
if (!moved) return
setPointer(true)
setSelectedKey(key)
}
function scrollRow(key: string | null | undefined, block: ScrollLogicalPosition = "nearest") {
if (!key) return
const idx = nodeIndex().get(key)
@@ -808,6 +829,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
placeholder={language.t("dialog.model.search.placeholder")}
value={search()}
onInput={(e) => {
setPointer(false)
setBrowsing(false)
setNavigating(false)
setSearch(e.currentTarget.value)
@@ -846,7 +868,14 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Tooltip>
</div>
<div id={listID} class="model-selector-list" role="tree" aria-label={label()} ref={listRef}>
<div
id={listID}
class="model-selector-list"
role="tree"
aria-label={label()}
ref={listRef}
onMouseMove={pointerMove}
>
<Show when={groups().length === 0}>
<div class="model-selector-empty" role="status" aria-live="polite">
{language.t("dialog.model.empty")}
@@ -871,16 +900,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return (
<div
id={optionID(key)}
data-key={key}
class={`model-selector-group-label${props.allowClear || group.key !== groups()[0]?.key ? " model-selector-group-label--divided" : ""}${isSelected(key) ? " selected" : ""}${isSelected(key) && !pointer() ? " keyboard-focused" : ""}`}
role="treeitem"
aria-level={1}
aria-expanded={shown()}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(key)
}}
>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
@@ -893,7 +919,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!debouncedSearch()}>
<Show when={!shown() && !!search()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</div>
@@ -906,15 +932,12 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return (
<div
id={optionID(CLEAR_KEY)}
data-key={CLEAR_KEY}
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="treeitem"
aria-level={1}
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(CLEAR_KEY)
}}
>
<span class="model-selector-item-name" style={{ "font-style": "italic", opacity: 0.7 }}>
{props.clearLabel ?? language.t("dialog.model.notSet")}
@@ -939,6 +962,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
>
<div
id={optionID(row.key)}
data-key={row.key}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
role="treeitem"
aria-level={2}
@@ -955,10 +979,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
}}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
@@ -43,6 +43,8 @@ export interface ThinkingSelectorBaseProps {
globalTrigger?: boolean
/** Show the Shift+Tab cycle hint in the trigger tooltip. */
cycleHint?: boolean
/** Accessible name for the selector trigger. */
label?: string
}
export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props) => {
@@ -163,7 +165,7 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
open={open()}
onOpenChange={onOpen}
triggerAs={Button}
triggerProps={{ variant: "ghost", size: "small" }}
triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label }}
trigger={
<>
<span class="thinking-selector-trigger-label">{display(props.value)}</span>
@@ -40,6 +40,7 @@ export interface SaveError {
interface ConfigContextValue {
config: Accessor<Config>
globalConfig: Accessor<Config>
globalDraft: Accessor<Partial<Config>>
projectConfig: Accessor<Config>
collections: Accessor<ConfigCollections>
settings: Accessor<Record<string, unknown>>
@@ -397,6 +398,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const value: ConfigContextValue = {
config,
globalConfig,
globalDraft,
projectConfig,
collections,
settings,
+3
View File
@@ -1001,6 +1001,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "لم يتم تهيئة أوامر مخصصة. أضف أوامر إلى opencode.json لرؤيتها هنا.",
"settings.agentBehaviour.workflows.detail.description": "الوصف",
"settings.agentBehaviour.workflows.detail.template": "القالب",
"settings.agentBehaviour.workflows.model": "النموذج",
"settings.agentBehaviour.workflows.variant": "المتغير",
"settings.agentBehaviour.workflows.modelDescription": "تجاوز النموذج العام",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"تشغيل أوامر shell الخاصة بالوكيل داخل sandbox على مستوى نظام التشغيل يقيّد الكتابة على مجلدات حالة المشروع و Kilo",
+3
View File
@@ -1037,6 +1037,9 @@ export const dict = {
"Nenhum comando personalizado configurado. Adicione comandos ao opencode.json para vê-los aqui.",
"settings.agentBehaviour.workflows.detail.description": "Descrição",
"settings.agentBehaviour.workflows.detail.template": "Modelo",
"settings.agentBehaviour.workflows.model": "modelo",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Substituição global do modelo",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Executar os comandos shell do agente dentro de um sandbox a nível de sistema operacional que restringe escritas aos diretórios de estado do projeto e do Kilo",
+3
View File
@@ -1027,6 +1027,9 @@ export const dict = {
"Nema konfiguriranih prilagođenih komandi. Dodajte komande u opencode.json da ih vidite ovdje.",
"settings.agentBehaviour.workflows.detail.description": "Opis",
"settings.agentBehaviour.workflows.detail.template": "Predložak",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "varijanta",
"settings.agentBehaviour.workflows.modelDescription": "Globalno premošćivanje modela",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Pokrenite shell komande agenta unutar sandboxa na nivou operativnog sistema koji ograničava pisanje na direktorije stanja projekta i Kilo",
+3
View File
@@ -1025,6 +1025,9 @@ export const dict = {
"Ingen brugerdefinerede kommandoer konfigureret. Tilføj kommandoer til opencode.json for at se dem her.",
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
"settings.agentBehaviour.workflows.detail.template": "Skabelon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global modeloverskrivelse",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Kør shell-kommandoer for agenten i en sandbox på operativsystemniveau, der begrænser skrivning til projekt- og Kilo-tilstandsmapperne",
@@ -1050,6 +1050,9 @@ export const dict = {
"Keine benutzerdefinierten Befehle konfiguriert. Fügen Sie Befehle zu opencode.json hinzu, um sie hier zu sehen.",
"settings.agentBehaviour.workflows.detail.description": "Beschreibung",
"settings.agentBehaviour.workflows.detail.template": "Vorlage",
"settings.agentBehaviour.workflows.model": "Modell",
"settings.agentBehaviour.workflows.variant": "Variante",
"settings.agentBehaviour.workflows.modelDescription": "Globale Modellüberschreibung",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Shell-Befehle des Agenten in einer Sandbox auf Betriebssystemebene ausführen, die Schreibvorgänge auf die Projekt- und Kilo-Statusverzeichnisse beschränkt",
@@ -963,6 +963,9 @@ export const dict = {
"No custom commands configured. Add commands to your opencode.json to see them here.",
"settings.agentBehaviour.workflows.detail.description": "Description",
"settings.agentBehaviour.workflows.detail.template": "Template",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global model override",
"settings.agentBehaviour.createMode": "Create New Mode",
"settings.agentBehaviour.createMode.name": "Name",
+3
View File
@@ -1040,6 +1040,9 @@ export const dict = {
"No hay comandos personalizados configurados. Añada comandos a opencode.json para verlos aquí.",
"settings.agentBehaviour.workflows.detail.description": "Descripción",
"settings.agentBehaviour.workflows.detail.template": "Plantilla",
"settings.agentBehaviour.workflows.model": "modelo",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Anulación global del modelo",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Ejecutar los comandos de shell del agente dentro de un sandbox a nivel de sistema operativo que restringe las escrituras a los directorios de estado del proyecto y de Kilo",
+3
View File
@@ -971,6 +971,9 @@ export const dict = {
"هیچ دستور سفارشی پیکربندی نشده است. دستورات را به opencode.json خود اضافه کنید تا اینجا نمایش داده شوند.",
"settings.agentBehaviour.workflows.detail.description": "توضیحات",
"settings.agentBehaviour.workflows.detail.template": "قالب",
"settings.agentBehaviour.workflows.model": "مدل",
"settings.agentBehaviour.workflows.variant": "گونه",
"settings.agentBehaviour.workflows.modelDescription": "بازنویسی مدل سراسری",
"settings.agentBehaviour.createMode": "ایجاد حالت جدید",
"settings.agentBehaviour.createMode.name": "نام",
+3
View File
@@ -1053,6 +1053,9 @@ export const dict = {
"Aucune commande personnalisée configurée. Ajoutez des commandes à opencode.json pour les voir ici.",
"settings.agentBehaviour.workflows.detail.description": "Description",
"settings.agentBehaviour.workflows.detail.template": "Modèle",
"settings.agentBehaviour.workflows.model": "modèle",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Remplacement global du modèle",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Exécuter les commandes shell de l'agent dans un sandbox au niveau du système d'exploitation qui restreint les écritures aux répertoires d'état du projet et de Kilo",
+3
View File
@@ -850,6 +850,9 @@ export const dict = {
"Nessun comando personalizzato configurato. Aggiungi comandi a opencode.json per vederli qui.",
"settings.agentBehaviour.workflows.detail.description": "Descrizione",
"settings.agentBehaviour.workflows.detail.template": "Template",
"settings.agentBehaviour.workflows.model": "modello",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Override globale del modello",
"settings.agentBehaviour.createMode": "Crea nuova modalità",
"settings.agentBehaviour.createMode.name": "Nome",
"settings.agentBehaviour.createMode.name.placeholder": "es. reviewer",
+3
View File
@@ -1019,6 +1019,9 @@ export const dict = {
"カスタムコマンドが設定されていません。opencode.json にコマンドを追加するとここに表示されます。",
"settings.agentBehaviour.workflows.detail.description": "説明",
"settings.agentBehaviour.workflows.detail.template": "テンプレート",
"settings.agentBehaviour.workflows.model": "モデル",
"settings.agentBehaviour.workflows.variant": "バリアント",
"settings.agentBehaviour.workflows.modelDescription": "グローバルモデルの上書き",
"settings.sandboxing.enabled.title": "サンドボックス",
"settings.sandboxing.enabled.description":
"エージェントのシェルコマンドを、プロジェクトおよびKiloの状態ディレクトリへの書き込みを制限するOSレベルのサンドボックス内で実行",
+3
View File
@@ -1013,6 +1013,9 @@ export const dict = {
"구성된 사용자 정의 명령이 없습니다. opencode.json에 명령을 추가하면 여기에 표시됩니다.",
"settings.agentBehaviour.workflows.detail.description": "설명",
"settings.agentBehaviour.workflows.detail.template": "템플릿",
"settings.agentBehaviour.workflows.model": "모델",
"settings.agentBehaviour.workflows.variant": "변형",
"settings.agentBehaviour.workflows.modelDescription": "전역 모델 재정의",
"settings.sandboxing.enabled.title": "샌드박스",
"settings.sandboxing.enabled.description":
"에이전트 셸 명령을 프로젝트 및 Kilo 상태 디렉터리에 대한 쓰기를 제한하는 OS 수준의 샌드박스 내에서 실행",
+3
View File
@@ -998,6 +998,9 @@ export const dict = {
"Geen aangepaste commando's geconfigureerd. Voeg commando's toe aan opencode.json om ze hier te zien.",
"settings.agentBehaviour.workflows.detail.description": "Beschrijving",
"settings.agentBehaviour.workflows.detail.template": "Sjabloon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Globale modeloverride",
"settings.autoApprove.description":
"Definieer hoe tools mogen worden uitgevoerd. De meeste tools staan standaard op Toestaan. doom_loop en external_directory staan standaard op Vragen.",
+3
View File
@@ -1025,6 +1025,9 @@ export const dict = {
"Ingen egendefinerte kommandoer konfigurert. Legg til kommandoer i opencode.json for å se dem her.",
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
"settings.agentBehaviour.workflows.detail.template": "Mal",
"settings.agentBehaviour.workflows.model": "modell",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global modelloverstyring",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Kjør shell-kommandoer for agenten i en sandbox på operativsystemnivå som begrenser skriving til prosjekt- og Kilo-tilstandsmapper",
+3
View File
@@ -1026,6 +1026,9 @@ export const dict = {
"Brak skonfigurowanych niestandardowych komend. Dodaj komendy do opencode.json, aby je tu zobaczyć.",
"settings.agentBehaviour.workflows.detail.description": "Opis",
"settings.agentBehaviour.workflows.detail.template": "Szablon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "wariant",
"settings.agentBehaviour.workflows.modelDescription": "Globalne nadpisanie modelu",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Uruchamiaj polecenia shell agenta w sandboxie na poziomie systemu operacyjnego, który ogranicza zapisy do katalogów stanu projektu i Kilo",
+3
View File
@@ -1023,6 +1023,9 @@ export const dict = {
"Пользовательские команды не настроены. Добавьте команды в opencode.json, чтобы увидеть их здесь.",
"settings.agentBehaviour.workflows.detail.description": "Описание",
"settings.agentBehaviour.workflows.detail.template": "Шаблон",
"settings.agentBehaviour.workflows.model": "модель",
"settings.agentBehaviour.workflows.variant": "вариант",
"settings.agentBehaviour.workflows.modelDescription": "Глобальное переопределение модели",
"settings.sandboxing.enabled.title": "Песочница",
"settings.sandboxing.enabled.description":
"Выполнять команды оболочки агента в песочнице на уровне ОС, которая ограничивает запись в каталоги состояния проекта и Kilo",
+3
View File
@@ -1010,6 +1010,9 @@ export const dict = {
"ไม่มีคำสั่งแบบกำหนดเองที่กำหนดค่าไว้ เพิ่มคำสั่งใน opencode.json เพื่อดูที่นี่",
"settings.agentBehaviour.workflows.detail.description": "คำอธิบาย",
"settings.agentBehaviour.workflows.detail.template": "เทมเพลต",
"settings.agentBehaviour.workflows.model": "โมเดล",
"settings.agentBehaviour.workflows.variant": "รูปแบบ",
"settings.agentBehaviour.workflows.modelDescription": "การแทนที่โมเดลส่วนกลาง",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"เรียกใช้คำสั่ง shell ของ agent ใน sandbox ระดับระบบปฏิบัติการที่จำกัดการเขียนไปยังโฟลเดอร์สถานะของโปรเจ็กต์และ Kilo",
+3
View File
@@ -986,6 +986,9 @@ export const dict = {
"Yapılandırılmış özel komut yok. Burada görmek için opencode.json dosyasına komutlar ekleyin.",
"settings.agentBehaviour.workflows.detail.description": "Açıklama",
"settings.agentBehaviour.workflows.detail.template": "Şablon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "varyant",
"settings.agentBehaviour.workflows.modelDescription": "Global model geçersiz kılması",
"settings.autoApprove.description":
"Araçların nasıl çalıştırılacağını tanımlayın. Çoğu araç varsayılan olarak İzin Ver'dir. doom_loop ve external_directory varsayılan olarak Sor'dur.",
+3
View File
@@ -986,6 +986,9 @@ export const dict = {
"Власних команд не налаштовано. Додайте команди до opencode.json, щоб вони з'явилися тут.",
"settings.agentBehaviour.workflows.detail.description": "Опис",
"settings.agentBehaviour.workflows.detail.template": "Шаблон",
"settings.agentBehaviour.workflows.model": "модель",
"settings.agentBehaviour.workflows.variant": "варіант",
"settings.agentBehaviour.workflows.modelDescription": "Глобальне перевизначення моделі",
"settings.autoApprove.description":
"Визначте, як виконуються інструменти. Більшість інструментів за замовчуванням — Дозволити. doom_loop та external_directory за замовчуванням — Запитувати.",
+3
View File
@@ -977,6 +977,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "未配置自定义命令。将命令添加到 opencode.json 即可在此处看到。",
"settings.agentBehaviour.workflows.detail.description": "描述",
"settings.agentBehaviour.workflows.detail.template": "模板",
"settings.agentBehaviour.workflows.model": "模型",
"settings.agentBehaviour.workflows.variant": "变体",
"settings.agentBehaviour.workflows.modelDescription": "全局模型覆盖",
"settings.sandboxing.enabled.title": "沙盒",
"settings.sandboxing.enabled.description":
"在操作系统级沙盒中运行代理 shell 命令,将写入限制在项目和 Kilo 状态目录内",
+3
View File
@@ -939,6 +939,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "未設定自訂命令。將命令新增至 opencode.json 即可在此處看到。",
"settings.agentBehaviour.workflows.detail.description": "描述",
"settings.agentBehaviour.workflows.detail.template": "範本",
"settings.agentBehaviour.workflows.model": "模型",
"settings.agentBehaviour.workflows.variant": "變體",
"settings.agentBehaviour.workflows.modelDescription": "全域模型覆寫",
"settings.sandboxing.enabled.title": "沙盒",
"settings.sandboxing.enabled.description":
"在作業系統層級沙盒中執行代理 shell 指令,將寫入限制在專案和 Kilo 狀態目錄內",
@@ -345,6 +345,7 @@ const ConfigWrapper: ParentComponent<{
const value = {
config: createMemo(() => cfg()),
globalConfig: createMemo(() => (scoped ? global() : cfg())),
globalDraft: () => ({}),
projectConfig: createMemo(() => (scoped ? project() : cfg())),
collections: () => ({}),
settings,
@@ -1228,13 +1228,14 @@ const projectB: AgentProjectSnapshot = {
missing: false,
}
const wt = (id: string, branch: string, label?: string): WorktreeState => ({
const wt = (id: string, branch: string, label?: string, opts: Partial<WorktreeState> = {}): WorktreeState => ({
id,
branch,
path: `/repos/x/.kilo/worktrees/${id}`,
parentBranch: "main",
createdAt: "2026-07-20T10:00:00Z",
label,
...opts,
})
const projectState = (
@@ -1243,12 +1244,18 @@ const projectState = (
sessions: { id: string; worktreeId: string | null }[],
sections: NonNullable<AgentManagerStateMessage["sections"]> = [],
baseBranch = "main",
worktreeOrder?: string[],
): AgentManagerStateMessage => ({
type: "agentManager.state",
projectId,
worktrees,
sessions: sessions.map((s) => ({ id: s.id, worktreeId: s.worktreeId, createdAt: "2026-07-20T10:00:00Z" })),
sections,
worktreeOrder: worktreeOrder ?? [
...worktrees.filter((item) => !item.sectionId).map((item) => item.id),
...sections.map((item) => item.id),
...worktrees.filter((item) => item.sectionId).map((item) => item.id),
],
staleWorktreeIds: [],
isGitRepo: true,
defaultBaseBranch: baseBranch,
@@ -1298,18 +1305,30 @@ export const MultiProjectSidebar: Story = {
states={{
[projectA.id]: projectState(
projectA.id,
[wt("wt-a1", "feature/project-list", "Project list UI"), wt("wt-a2", "fix/session-routing")],
[
wt("wt-a1", "feature/project-list", "Project list UI", { sectionId: "sec-a1" }),
wt("wt-a2", "fix/session-routing"),
wt("wt-a3", "feat/project-list-v2", undefined, { groupId: "grp-a1" }),
wt("wt-a4", "feat/project-list-v3", undefined, { groupId: "grp-a1" }),
],
[
{ id: "ses-a1", worktreeId: null },
{ id: "ses-a2", worktreeId: "wt-a1" },
],
[{ id: "sec-a1", name: "Agent Manager", color: "Blue", order: 0, collapsed: false }],
"main",
["wt-a2", "sec-a1", "wt-a1", "wt-a3", "wt-a4"],
),
[projectB.id]: projectState(
projectB.id,
[wt("wt-b1", "feat/gateway-routing", "Gateway routing")],
[
wt("wt-b1", "feat/gateway-routing", "Gateway routing", { sectionId: "sec-b1" }),
wt("wt-b2", "fix/api"),
],
[{ id: "ses-b1", worktreeId: null }],
[{ id: "sec-b1", name: "In progress", color: null, order: 0, collapsed: false }],
"master",
["wt-b2", "sec-b1", "wt-b1"],
),
}}
stats={{
@@ -1227,6 +1227,231 @@ export const SearchPreviews: Story = {
</div>
</div>
</section>
<section class="tool-call-lab-panel tool-call-lab-panel-wide">
<div class="tool-call-lab-panel-header">
<span class="tool-call-lab-panel-title">Chart renderers</span>
<span class="tool-call-lab-panel-note">
Chart.js chart tool rendered with production ChartTool component.
</span>
</div>
<div class="tool-call-lab-stack">
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Bar chart</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-bar-preview",
"chart",
{ title: "Bar chart" },
"Render chart",
JSON.stringify({
type: "bar",
data: {
labels: ["A", "B", "C", "D", "E"],
datasets: [{ label: "Value", data: [28, 55, 43, 91, 81] }],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Line plot</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-line-preview",
"chart",
{ title: "Line plot" },
"Render chart",
JSON.stringify({
type: "line",
data: {
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
datasets: [{ label: "Value", data: [12, 28, 19, 45, 38, 62, 55, 74], fill: false }],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Scatter plot</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-scatter-preview",
"chart",
{ title: "Scatter plot" },
"Render chart",
JSON.stringify({
type: "scatter",
data: {
datasets: [
{
label: "Points",
data: [
{ x: 2, y: 14 },
{ x: 5, y: 38 },
{ x: 8, y: 22 },
{ x: 11, y: 61 },
{ x: 14, y: 44 },
{ x: 17, y: 73 },
{ x: 20, y: 55 },
{ x: 23, y: 88 },
{ x: 26, y: 67 },
{ x: 29, y: 95 },
],
},
],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Temporal (time series)</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-temporal-preview",
"chart",
{ title: "Time series" },
"Render chart",
JSON.stringify({
type: "line",
data: {
labels: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
datasets: [
{
label: "Value",
data: [120, 145, 132, 178, 163, 201, 194, 223, 215, 248, 237, 271],
fill: true,
},
],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Pie chart</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-pie-preview",
"chart",
{ title: "Pie chart" },
"Render chart",
JSON.stringify({
type: "pie",
data: {
labels: ["A", "B", "C", "D"],
datasets: [{ data: [30, 50, 15, 5] }],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Doughnut chart</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-doughnut-preview",
"chart",
{ title: "Doughnut chart" },
"Render chart",
JSON.stringify({
type: "doughnut",
data: {
labels: ["A", "B", "C", "D"],
datasets: [{ data: [40, 25, 20, 15] }],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Radar chart</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-radar-preview",
"chart",
{ title: "Radar chart" },
"Render chart",
JSON.stringify({
type: "radar",
data: {
labels: ["Speed", "Strength", "Agility", "Intelligence", "Endurance"],
datasets: [
{ label: "Player A", data: [80, 60, 75, 90, 70] },
{ label: "Player B", data: [55, 85, 60, 65, 80] },
],
},
}),
),
]}
/>
</div>
<div class="tool-call-lab-example">
<span class="tool-call-lab-example-label">Bubble chart</span>
<AssistantMessage
message={base}
parts={[
done(
"chart-bubble-preview",
"chart",
{ title: "Bubble chart" },
"Render chart",
JSON.stringify({
type: "bubble",
data: {
datasets: [
{
label: "Dataset",
data: [
{ x: 5, y: 20, r: 10 },
{ x: 15, y: 35, r: 20 },
{ x: 25, y: 15, r: 8 },
{ x: 35, y: 50, r: 15 },
{ x: 45, y: 30, r: 25 },
],
},
],
},
}),
),
]}
/>
</div>
</div>
</section>
</div>
</SessionContext.Provider>
</StoryProviders>
@@ -25,10 +25,12 @@ export interface ConfigCollectionEntry {
export type ConfigCollections = Record<string, ConfigCollectionEntry[]>
export interface CommandConfig {
template: string
template?: string
description?: string
agent?: string
model?: string
model?: string | null
variant?: string | null
subtask?: boolean
}
export interface SkillsConfig {
@@ -876,6 +876,7 @@ export interface SetTabOrderRequest {
// Persist sidebar worktree order
export interface SetWorktreeOrderRequest {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}
+34 -13
View File
@@ -8,6 +8,7 @@ import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import { legacyReviewCommand, reviewCommand } from "@/kilocode/review/command" // kilocode_change
import { apply as applyOverride, type Override } from "@/kilocode/command/override" // kilocode_change
import { EventV2 } from "@opencode-ai/core/event"
import PROMPT_INITIALIZE from "./template/initialize.txt"
@@ -32,6 +33,7 @@ export const Info = Schema.Struct({
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String), // kilocode_change
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
trusted: Schema.optional(Schema.Boolean), // kilocode_change - skill-sourced templates only run `!`cmd`` shell when trusted
// Some command templates are lazy promises from MCP prompt resolution.
@@ -114,20 +116,12 @@ export const layer = Layer.effect(
commands["local-review-uncommitted"] = legacyReviewCommand("local-review-uncommitted")!
// kilocode_change end
// kilocode_change start - defer partial overrides until all command sources are registered
const overrides: Array<{ name: string; command: Override }> = []
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
if (!applyOverride(commands, name, command, hints)) overrides.push({ name, command }) // kilocode_change
}
// kilocode_change end
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
commands[name] = {
@@ -163,6 +157,31 @@ export const layer = Layer.effect(
commands[item.name] = fromSkill(item) // kilocode_change
}
// kilocode_change start - apply deferred overrides to their registered source
for (const item of overrides) {
const skillTarget = skillName(item.name)
if (skillTarget) {
const found = yield* skill.get(skillTarget)
if (found) {
if (commands[skillTarget]?.source !== "skill") {
commands[item.name] = fromSkill(found)
applyOverride(commands, item.name, item.command, hints) // kilocode_change
} else {
applyOverride(commands, skillTarget, item.command, hints) // kilocode_change
}
}
continue
}
const mcpTarget = mcpName(item.name)
if (mcpTarget) {
if (commands[mcpTarget]?.source !== "mcp") continue
applyOverride(commands, mcpTarget, item.command, hints) // kilocode_change
continue
}
applyOverride(commands, item.name, item.command, hints) // kilocode_change
}
// kilocode_change end
return {
commands,
}
@@ -180,6 +199,8 @@ export const layer = Layer.effect(
// kilocode_change start
const target = skillName(name)
if (target) {
const exact = s.commands[target]
if (exact?.source === "skill") return exact
const item = yield* skill.get(target)
if (item) return fromSkill(item)
return undefined
@@ -201,7 +222,7 @@ export const layer = Layer.effect(
const result = Object.values(s.commands)
const names = new Set(result.map((item) => item.name))
for (const item of yield* skill.all()) {
if (s.commands[item.name]?.source === "skill") continue
if (s.commands[item.name]?.source === "skill" || s.commands[`${item.name}:skill`]?.source === "skill") continue
if (names.has(item.name)) result.push(fromSkill(item))
}
return result
@@ -0,0 +1,53 @@
// kilocode_change - new file
type Existing = {
name: string
description?: string
agent?: string
model?: string
variant?: string
source?: "command" | "mcp" | "skill"
trusted?: boolean
template: string | Promise<string>
subtask?: boolean
hints: readonly string[]
}
export type Override = {
template?: string
description?: string
agent?: string
model?: string
variant?: string
subtask?: boolean
}
type Hints = (template: string) => string[]
export function apply(commands: Record<string, Existing>, name: string, command: Override, hints: Hints) {
const existing = commands[name]
if (command.template === undefined) {
if (!existing) return false
if (command.description !== undefined) existing.description = command.description
if (command.agent !== undefined) existing.agent = command.agent
if (command.model !== undefined) existing.model = command.model
if (command.variant !== undefined) existing.variant = command.variant
if (command.subtask !== undefined) existing.subtask = command.subtask
return true
}
const template = command.template
commands[name] = {
name,
agent: command.agent,
model: command.model,
variant: command.variant,
description: command.description,
source: "command",
get template() {
return template
},
subtask: command.subtask,
hints: hints(template),
}
return true
}
@@ -0,0 +1,34 @@
// kilocode_change - new file
import type { Agent } from "@/agent/agent"
import type { Command } from "@/command"
export function resolve(input: {
command: Pick<Command.Info, "model" | "agent" | "variant">
agent: Pick<Agent.Info, "model" | "variant">
model: { providerID: string; modelID: string }
selected: { variants?: Record<string, unknown> }
input?: string
}) {
if (input.command.variant && input.selected.variants?.[input.command.variant]) return input.command.variant
if (
input.agent.model &&
input.agent.model.providerID === input.model.providerID &&
input.agent.model.modelID === input.model.modelID &&
input.agent.variant &&
input.selected.variants?.[input.agent.variant]
) {
return input.agent.variant
}
if (
!input.command.model &&
(!input.command.agent || !input.agent.model) &&
input.input &&
input.selected.variants?.[input.input]
) {
return input.input
}
return undefined
}
@@ -0,0 +1,77 @@
// kilocode_change - new file
import { Effect, Schema } from "effect"
import * as Tool from "../../tool/tool"
const Parameters = Schema.Struct({
title: Schema.String.annotate({
description: "Short label for the chart shown in the tool header",
}),
description: Schema.optional(Schema.String).annotate({
description: "Optional subtitle shown below the title",
}),
spec: Schema.String.annotate({
description: "A Chart.js v4 configuration serialized as a JSON string. Pass the JSON object as a plain string value — do not nest objects, do not escape quotes manually. Example: '{\"type\":\"bar\",\"data\":{\"labels\":[\"A\",\"B\"],\"datasets\":[{\"data\":[1,2]}]}}'",
}),
})
type Meta = {
title: string
description?: string
error?: string
}
export const ChartTool = Tool.define(
"chart",
Effect.gen(function* () {
return {
description:
"Render a data visualization chart using a Chart.js v4 config. Use this when the user explicitly asks for a chart, graph, or plot. Supported types: bar, bubble, pie, doughnut, line (use fill:true for area charts), mixed, polarArea, radar, scatter. Do NOT use this for diagrams, flowcharts, sequence diagrams, or any mermaid content — write those as mermaid fenced code blocks in your text response instead. After calling this tool, do NOT output the JSON spec or raw data in your text response — the chart is the response.",
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.metadata({
title: params.title,
metadata: { title: params.title, description: params.description } as Meta,
})
let spec: unknown
try {
spec = JSON.parse(params.spec)
} catch {
return {
title: params.title,
output: `Invalid chart spec: could not parse JSON. If you are trying to render a diagram or mermaid chart, do NOT use the chart tool — write a mermaid fenced code block in your text response instead.`,
metadata: { title: params.title, description: params.description, error: "invalid-json" } as Meta,
}
}
if (!spec || typeof spec !== "object" || !("type" in spec) || !("data" in spec)) {
return {
title: params.title,
output: `Invalid chart spec: must be a Chart.js v4 config with "type" and "data" fields. If you are trying to render a mermaid diagram, do NOT use the chart tool — write a mermaid fenced code block in your text response instead.`,
metadata: { title: params.title, description: params.description, error: "invalid-spec" } as Meta,
}
}
// "area" is not a Chart.js type — remap to line with fill
if ((spec as Record<string, unknown>).type === "area") {
const s = spec as Record<string, unknown>
s.type = "line"
const data = s.data as Record<string, unknown> | undefined
if (data && Array.isArray(data.datasets)) {
data.datasets = (data.datasets as Record<string, unknown>[]).map((ds) => ({
fill: true,
...ds,
}))
}
}
return {
title: params.title,
output: JSON.stringify(spec),
metadata: { title: params.title, description: params.description } as Meta,
}
}).pipe(Effect.orDie),
}
}),
)
@@ -3,6 +3,7 @@ import { RecallTool } from "../../tool/recall"
import { AgentManagerModelsTool } from "./agent-manager-models"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import { ChartTool } from "./chart"
import { GenerateImageTool } from "./generate-image"
import { InteractiveTerminalTool } from "./interactive-terminal"
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
@@ -74,6 +75,7 @@ export namespace KiloToolRegistry {
const save = yield* MemorySaveTool
const manager = yield* AgentManagerTool.pipe(Effect.provideService(AgentManager.Service, host ?? unavailable))
const process = yield* BackgroundProcessTool
const chart = yield* ChartTool
const image = yield* GenerateImageTool
const terminal = yield* InteractiveTerminalTool
// The notify_user tool depends on KiloSessions.Service, which the tool-registry layer provides
@@ -83,13 +85,13 @@ export namespace KiloToolRegistry {
const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions))
const send = yield* SendFileTool
if (!notebook)
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send }
return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send }
const tools = yield* Effect.all({
notebookRead: NotebookReadTool,
notebookEdit: NotebookEditTool,
notebookExecute: NotebookExecuteTool,
}).pipe(Effect.provideService(Notebook.Service, notebook))
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send, ...tools }
return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send, ...tools }
})
}
@@ -104,6 +106,7 @@ export namespace KiloToolRegistry {
save: Tool.Info
manager: Tool.Info
process: Tool.Info
chart: Tool.Info
image: Tool.Info
terminal?: Tool.Info
notify: Tool.Info
@@ -124,6 +127,7 @@ export namespace KiloToolRegistry {
save: Tool.init(tools.save),
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
chart: Tool.init(tools.chart),
image: Tool.init(tools.image),
notify: Tool.init(tools.notify),
send: Tool.init(tools.send),
@@ -198,6 +202,7 @@ export namespace KiloToolRegistry {
save: Tool.Def
manager: Tool.Def
process: Tool.Def
chart: Tool.Def
image: Tool.Def
terminal?: Tool.Def
notify: Tool.Def
@@ -215,9 +220,9 @@ export namespace KiloToolRegistry {
tools.memory,
tools.save,
tools.recall,
...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []),
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []),
// Agent Manager tools are useful only when the extension can create and display their sessions.
...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []),
...(Flag.KILO_CLIENT === "vscode" &&
cfg.experimental?.native_notebook_tools === true &&
@@ -100,6 +100,7 @@ export namespace KiloTask {
type Model = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
type Saved = Model & { variant?: string }
type Choice = { model: Model; variant?: string; sticky?: boolean; direct?: boolean }
type Workflow = { model: Model; variant?: string }
function key(model: Model) {
return `${model.providerID}/${model.modelID}`
@@ -141,12 +142,14 @@ export namespace KiloTask {
config: Pick<Config.Info, "subagent_model" | "subagent_variant" | "subagent_variant_overrides">
parent: Model
variant?: string
workflow?: Workflow
provider: Provider.Interface
}) {
const state = yield* saved(input.name)
const cfg = parse(input.config.subagent_model)
const override = (model: Model) => input.config.subagent_variant_overrides?.[key(model)] ?? undefined
const choices: Array<Choice | undefined> = [
input.workflow ? { ...input.workflow, direct: true } : undefined,
state
? {
model: { providerID: state.providerID, modelID: state.modelID },
@@ -197,4 +200,20 @@ export namespace KiloTask {
const variant = full?.variants?.[value] ? value : input.variant
return { model: input.parent, variant }
})
export function workflow(value: unknown): Workflow | undefined {
if (!value || typeof value !== "object") return undefined
const item = (value as { workflow?: unknown }).workflow
if (!item || typeof item !== "object") return undefined
const model = (item as { model?: unknown }).model
if (!model || typeof model !== "object") return undefined
const providerID = (model as { providerID?: unknown }).providerID
const modelID = (model as { modelID?: unknown }).modelID
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
const variant = (item as { variant?: unknown }).variant
return {
model: { providerID: ProviderV2.ID.make(providerID), modelID: ModelV2.ID.make(modelID) },
variant: typeof variant === "string" ? variant : undefined,
}
}
}
+36 -4
View File
@@ -11,6 +11,7 @@ import { KiloSession } from "@/kilocode/session" // kilocode_change
import { SessionTranscript } from "@/kilocode/session/transcript" // kilocode_change
import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
import * as KiloWorkflowVariant from "@/kilocode/session/workflow-variant" // kilocode_change
import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change
import { KiloReference } from "@/kilocode/reference/contains" // kilocode_change
import { KiloReadObject } from "@/kilocode/tool/read-object" // kilocode_change
@@ -350,6 +351,7 @@ export const layer = Layer.effect(
const promptOps = yield* ops()
const { task: taskTool } = yield* registry.named()
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const taskVariant = task.variant ?? lastUser.model.variant // kilocode_change
const assistantMessage: SessionV1.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
role: "assistant",
@@ -357,7 +359,7 @@ export const layer = Layer.effect(
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.model.variant,
variant: taskVariant, // kilocode_change
path: { cwd: ctx.directory, root: ctx.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -404,6 +406,19 @@ export const layer = Layer.effect(
throw error
}
// kilocode_change start - distinguish explicit workflow model selection from the effective subtask model
const workflow = yield* Effect.gen(function* () {
if (!task.command) return undefined
const command = yield* commands.get(task.command)
if (!command) return undefined
if (!command.model && !command.variant && !(command.agent && taskAgent.model)) return undefined
return {
model: task.model ?? { providerID: taskModel.providerID, modelID: taskModel.id },
variant: task.variant,
}
})
// kilocode_change end
let error: Error | undefined
const taskAbort = new AbortController()
// kilocode_change start - shared reader for the child session id written by task.ts ctx.metadata (#6321)
@@ -419,7 +434,13 @@ export const layer = Layer.effect(
sessionID,
abort: taskAbort.signal,
callID: part.callID,
extra: { bypassAgentCheck: true, promptOps },
// kilocode_change start
extra: {
bypassAgentCheck: true,
promptOps,
workflow, // kilocode_change
},
// kilocode_change end
messages: msgs,
metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
Effect.gen(function* () {
@@ -2083,7 +2104,7 @@ export const layer = Layer.effect(
return yield* currentModel(input.sessionID)
})
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const task = yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) // kilocode_change
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
@@ -2095,6 +2116,16 @@ export const layer = Layer.effect(
}
yield* agents.guardRequirements(agent) // kilocode_change - command agent overrides must satisfy requirements
// kilocode_change start
const variant = KiloWorkflowVariant.resolve({
command: cmd,
agent,
model: taskModel,
selected: task,
input: input.variant,
})
// kilocode_change end
const templateParts = yield* resolvePromptParts(template)
KiloSessionProcessor.markReviewTelemetry(templateParts, input.command) // kilocode_change - mark review commands for completion telemetry
const inputFiles = new Set(
@@ -2112,6 +2143,7 @@ export const layer = Layer.effect(
description: cmd.description ?? "",
command: input.command,
model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
variant, // kilocode_change
prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
},
]
@@ -2136,7 +2168,7 @@ export const layer = Layer.effect(
model: userModel,
agent: userAgent,
parts,
variant: input.variant,
variant: isSubtask ? input.variant : variant, // kilocode_change
snapshotInitialization: input.snapshotInitialization, // kilocode_change
})
yield* events.publish(Command.Event.Executed, {
+1
View File
@@ -213,6 +213,7 @@ export const TaskTool = Tool.define(
providerID: msg.info.providerID,
},
variant: msg.info.variant,
workflow: KiloTask.workflow(ctx.extra), // kilocode_change
provider,
})
const model = selected.model
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { KiloToolRegistry } from "@/kilocode/tool/registry"
import type * as Tool from "@/tool/tool"
// Minimal stub — select() only reads .id from each Tool.Def
const stub = (id: string) => ({ id }) as unknown as Tool.Def
const tools = {
codebase: stub("codebase"),
recall: stub("recall"),
managerModels: stub("managerModels"),
memory: stub("memory"),
save: stub("save"),
manager: stub("manager"),
process: stub("process"),
chart: stub("chart"),
image: stub("image"),
notify: stub("notify"),
send: stub("send_file"),
}
function ids(client: string) {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = client
return KiloToolRegistry.extra(tools, {}).map((t) => t.id)
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
else process.env.KILO_CLIENT = prev
}
}
test("chart tool is included for vscode", () => {
expect(ids("vscode")).toContain("chart")
})
test("chart tool is excluded for cli", () => {
expect(ids("cli")).not.toContain("chart")
})
test("chart tool is excluded for jetbrains", () => {
expect(ids("jetbrains")).not.toContain("chart")
})
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import { apply } from "../../../src/kilocode/command/override"
const hints = (template: string) => (template ? [template] : [])
describe("command overrides", () => {
test("updates an existing command without replacing its template", () => {
const commands = {
review: {
name: "review",
template: "Review the changes",
hints: ["existing"],
},
}
apply(commands, "review", { model: "anthropic/claude-sonnet", variant: "high" }, hints)
expect(commands.review).toMatchObject({
template: "Review the changes",
model: "anthropic/claude-sonnet",
variant: "high",
hints: ["existing"],
})
})
test("ignores a partial override for an unknown command", () => {
const commands = {}
expect(apply(commands, "missing", { model: "anthropic/claude-sonnet" }, hints)).toBe(false)
expect(commands).toEqual({})
})
test("preserves a lazy template while applying a partial override", () => {
let reads = 0
const commands = {
review: {
name: "review",
get template() {
reads++
return "Review the changes"
},
hints: ["existing"],
},
}
apply(commands, "review", { variant: "high" }, hints)
expect(reads).toBe(0)
expect((commands.review as { variant?: string }).variant).toBe("high")
})
})
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
describe("workflow model overrides", () => {
test("accepts a command entry containing only model and variant", () => {
const value = Schema.decodeUnknownSync(ConfigV1.Info)({
command: {
review: {
model: "anthropic/claude-sonnet-4-6",
variant: "high",
},
},
})
expect(value.command?.review).toEqual({
model: "anthropic/claude-sonnet-4-6",
variant: "high",
})
})
})
@@ -68,9 +68,21 @@ function session(dir: string) {
const headers = { "Content-Type": "application/json", "x-kilo-directory": dir }
const query = `directory=${encodeURIComponent(dir)}`
const json = async (route: string, init?: RequestInit) => {
const res = await app.request(route, { headers, ...init })
return await res.json()
const json = async (route: string, init?: RequestInit, retry = false) => {
const tries = retry ? 5 : 1
for (let attempt = 0; attempt < tries; attempt++) {
const res = await app.request(route, { headers, ...init })
const body = await res.text()
try {
return JSON.parse(body)
} catch (error) {
if (!retry || !res.ok || attempt === tries - 1) {
throw new Error(`${route} -> ${res.status} ${body.slice(0, 200)}`, { cause: error })
}
await sleep(100)
}
}
throw new Error(`failed to read JSON response from ${route}`)
}
return {
@@ -82,9 +94,9 @@ function session(dir: string) {
body: JSON.stringify({ parts: [{ type: "text", text }] }),
}),
abort: (id: string) => app.request(`/session/${id}/abort`, { method: "POST", headers }),
messages: (id: string) => json(`/session/${id}/message?${query}`) as Promise<Message[]>,
messages: (id: string) => json(`/session/${id}/message?${query}`, undefined, true) as Promise<Message[]>,
status: async (id: string) => {
const all = (await json(`/session/status?${query}`)) as Record<string, { type: string }>
const all = (await json(`/session/status?${query}`, undefined, true)) as Record<string, { type: string }>
return all[id]?.type ?? "idle"
},
}
@@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { Server } from "../../../src/server/server"
import { Config } from "../../../src/config/config"
import { ConfigParse } from "../../../src/config/parse"
import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
import { Permission } from "../../../src/permission"
@@ -24,6 +25,7 @@ type Overlay = {
fields: Record<string, { source: string; inherited: boolean; overridden: boolean; value?: unknown }>
collections: Record<string, Array<{ key: string; source: string; inherited: boolean; local?: unknown }>>
targets: { project: Target; global: Target; active: Target }
effective?: Config.Info
}
type Agent = {
name: string
@@ -598,6 +600,58 @@ describe("config overlay routes", () => {
expect(Object.keys(saved.mcp)).toEqual(["local"])
})
test.serial("writes partial global workflow overrides when both JSON and JSONC exist", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await Filesystem.write(path.join(global.path, "kilo.json"), JSON.stringify({ username: "legacy" }))
await Filesystem.write(path.join(global.path, "kilo.jsonc"), "{\n // Keep JSONC as the active target.\n}\n")
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } } },
}),
}),
)
const saved = ConfigParse.jsonc(await Bun.file(path.join(global.path, "kilo.jsonc")).text(), "kilo.jsonc")
expect(saved).toMatchObject({
command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } },
})
expect(await Bun.file(path.join(global.path, "kilo.json")).json()).toMatchObject({ username: "legacy" })
})
test.serial("merges workflow overrides with a command body in the lower-precedence global file", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await Filesystem.write(
path.join(global.path, "kilo.json"),
JSON.stringify({ command: { review: { template: "Review the changes" } } }),
)
await Filesystem.write(path.join(global.path, "kilo.jsonc"), '{\n "username": "legacy"\n}\n')
const response = await json<Overlay>(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } } },
}),
}),
)
expect(response.effective?.command?.review).toMatchObject({
template: "Review the changes",
model: "anthropic/claude-sonnet-4-6",
variant: "high",
})
})
test.serial("disables inherited mcp server with a minimal local override", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
@@ -644,7 +698,7 @@ describe("config overlay routes", () => {
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("ask")
@@ -678,7 +732,7 @@ describe("config overlay routes", () => {
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"ask",
)
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { resolve } from "../../../src/kilocode/session/workflow-variant"
const selected = { variants: { high: {} } }
const model = { providerID: ProviderV2.ID.make("anthropic"), modelID: ModelV2.ID.make("claude-sonnet") }
describe("workflow variant resolution", () => {
test("prefers the command variant", () => {
expect(
resolve({
command: { model: "anthropic/claude-sonnet", agent: undefined, variant: "high" },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
})
test("uses an agent variant only for the agent model", () => {
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model, variant: "high" },
model,
selected,
}),
).toBe("high")
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model, variant: "high" },
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-5") },
selected,
}),
).toBeUndefined()
})
test("uses chat variant when an agent does not select a model", () => {
expect(
resolve({
command: { model: undefined, agent: undefined, variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
expect(
resolve({
command: { model: "anthropic/claude-sonnet", agent: undefined, variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBeUndefined()
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
})
})
@@ -78,4 +78,90 @@ Skill content.
{ git: true },
),
)
it.live("applies a partial override to a skill command", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".kilo", "skill", "proj", "SKILL.md"),
"---\nname: proj\ndescription: Project skill.\n---\n\nReview files.\n",
),
)
const command = yield* Command.Service
const skill = yield* command.get("proj:skill")
expect(skill?.source).toBe("skill")
expect(skill?.model).toBe("anthropic/claude-sonnet")
expect(skill?.variant).toBe("high")
}),
{
git: true,
config: {
command: {
proj: {
model: "anthropic/claude-sonnet",
variant: "high",
},
},
},
},
),
)
it.live("applies a skill alias override when a command has the same name", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".kilo", "skill", "review", "SKILL.md"),
"---\nname: review\ndescription: Review skill.\n---\n\nReview files.\n",
),
)
const command = yield* Command.Service
const skill = yield* command.get("review:skill")
const list = yield* command.list()
expect(skill?.source).toBe("skill")
expect(skill?.model).toBe("anthropic/claude-sonnet")
expect(list.filter((item) => item.source === "skill" && item.name === "review")).toHaveLength(1)
}),
{
git: true,
config: {
command: {
"review:skill": { model: "anthropic/claude-sonnet" },
review: { template: "Command content." },
},
},
},
),
)
it.live("does not apply a missing MCP alias to a command with the same name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const command = yield* Command.Service
const plain = yield* command.get("review")
const missing = yield* command.get("review:mcp")
expect(plain?.model).toBeUndefined()
expect(missing).toBeUndefined()
}),
{
git: true,
config: {
command: {
review: { template: "Command content." },
"review:mcp": { model: "anthropic/claude-sonnet" },
},
},
},
),
)
})
@@ -42,6 +42,7 @@ function infos() {
save: info("kilo_memory_save"),
manager: info("agent_manager"),
process: info("background_process"),
chart: info("chart"),
image: info("generate_image"),
notify: info("notify_user"),
send: info("send_file"),
@@ -339,6 +339,7 @@ describe("kilocode tool registry indexing", () => {
save: def("kilo_memory_save"),
manager: def("agent_manager"),
process: def("background_process"),
chart: def("chart"),
image: def("generate_image"),
terminal: def("interactive_terminal"),
notify: def("notify_user"),
@@ -398,6 +399,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_recall",
"kilo_memory_save",
"recall",
"chart",
"background_process",
"agent_manager_models",
"agent_manager",
@@ -415,6 +417,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_recall",
"kilo_memory_save",
"recall",
"chart",
"background_process",
"agent_manager_models",
"agent_manager",
@@ -428,6 +431,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_recall",
"kilo_memory_save",
"recall",
"chart",
"background_process",
"agent_manager_models",
"agent_manager",
@@ -54,6 +54,7 @@ function infos() {
save: info("kilo_memory_save"),
manager: info("agent_manager"),
process: info("background_process"),
chart: info("chart"),
image: info("generate_image"),
notify: info("notify_user"),
send: info("send_file"),
@@ -460,6 +460,7 @@ describe("send_file tool", () => {
save: tool,
manager: tool,
process: tool,
chart: tool,
image: tool,
notify: { id: "notify_user" } as Tool.Def,
send: tool,
+5 -1
View File
@@ -633,6 +633,7 @@ export type SubtaskPart = {
providerID: string
modelID: string
}
variant?: string
command?: string
}
@@ -1555,7 +1556,7 @@ export type Config = {
server?: ServerConfig
command?: {
[key: string]: {
template: string
template?: string
description?: string
agent?: string
model?: string
@@ -2114,6 +2115,7 @@ export type Command = {
description?: string
agent?: string
model?: string
variant?: string
source?: "command" | "mcp" | "skill"
trusted?: boolean
template: string
@@ -2769,6 +2771,7 @@ export type SubtaskPartInput = {
providerID: string
modelID: string
}
variant?: string
command?: string
}
@@ -6215,6 +6218,7 @@ export type CommandV2Info = {
providerID: string
variant?: string
}
variant?: string
subtask?: boolean
}
+12 -1
View File
@@ -26696,6 +26696,9 @@
"required": ["providerID", "modelID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"command": {
"type": "string"
}
@@ -29314,7 +29317,6 @@
"type": "boolean"
}
},
"required": ["template"],
"additionalProperties": false
}
},
@@ -30960,6 +30962,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["command", "mcp", "skill"]
@@ -33007,6 +33012,9 @@
"required": ["providerID", "modelID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"command": {
"type": "string"
}
@@ -44606,6 +44614,9 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"subtask": {
"type": "boolean"
}