mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into rune-polo
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start voice input faster on macOS with native AVFoundation capture.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update model search results instantly and keep the active match visible while typing.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
|
||||
size 52887
|
||||
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
|
||||
size 52895
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9
|
||||
size 33866
|
||||
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
|
||||
size 39173
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c
|
||||
size 658647
|
||||
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
|
||||
size 758306
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+71
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user