diff --git a/.changeset/bright-project-dnd.md b/.changeset/bright-project-dnd.md new file mode 100644 index 00000000000..750341b6225 --- /dev/null +++ b/.changeset/bright-project-dnd.md @@ -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. diff --git a/.changeset/faster-macos-speech.md b/.changeset/faster-macos-speech.md new file mode 100644 index 00000000000..2d9bd9cc258 --- /dev/null +++ b/.changeset/faster-macos-speech.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Start voice input faster on macOS with native AVFoundation capture. diff --git a/.changeset/fix-agent-manager-focus.md b/.changeset/fix-agent-manager-focus.md new file mode 100644 index 00000000000..f8d5d6c65e3 --- /dev/null +++ b/.changeset/fix-agent-manager-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions. diff --git a/.changeset/quick-model-search.md b/.changeset/quick-model-search.md new file mode 100644 index 00000000000..5b01ee44f97 --- /dev/null +++ b/.changeset/quick-model-search.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update model search results instantly and keep the active match visible while typing. diff --git a/.changeset/workflow-model-variants.md b/.changeset/workflow-model-variants.md new file mode 100644 index 00000000000..48b090358ce --- /dev/null +++ b/.changeset/workflow-model-variants.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Configure a model and reasoning variant for each workflow from Agent Behaviour settings. diff --git a/.kilo/skills/chart/SKILL.md b/.kilo/skills/chart/SKILL.md new file mode 100644 index 00000000000..ddff65e6677 --- /dev/null +++ b/.kilo/skills/chart/SKILL.md @@ -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. diff --git a/bun.lock b/bun.lock index bad97974669..e2b68afdf4f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 414454b4128..28dcf2f58ed 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index f6b8210be12..6b1008217c4 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -11,6 +11,7 @@ export class Info extends Schema.Class("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), }) {} diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts index 394079b1e98..01bdb2120d1 100644 --- a/packages/core/src/config/command.ts +++ b/packages/core/src/config/command.ts @@ -3,7 +3,7 @@ export * as ConfigCommand from "./command" import { Schema } from "effect" export class Info extends Schema.Class("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), diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e89..973efb83d4a 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -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 + }) } }) }), diff --git a/packages/core/src/v1/config/command.ts b/packages/core/src/v1/config/command.ts index 281d5309109..e0b578d1843 100644 --- a/packages/core/src/v1/config/command.ts +++ b/packages/core/src/v1/config/command.ts @@ -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), diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index eef2cee8df1..cdc9d5a5890 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -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> @@ -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> diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index da3bb749b45..f5e4f41af77 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -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"), + }, + }) + }), + ) }) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 30e223faac6..f74abf88a80 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -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, 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, 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() }), ) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 85a8ddb7b65..22ffe596c1a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5 -size 52887 +oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666 +size 52895 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png index c7ae8ac5589..c5f0c077fcb 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9 -size 33866 +oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402 +size 39173 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index c64a23bfc5f..6a4e3e8ba0b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c -size 658647 +oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a +size 758306 diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 37443249844..8724dc548f9 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -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" } } diff --git a/packages/kilo-ui/src/components/chart.css b/packages/kilo-ui/src/components/chart.css new file mode 100644 index 00000000000..c9752f9a0e6 --- /dev/null +++ b/packages/kilo-ui/src/components/chart.css @@ -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); +} diff --git a/packages/kilo-ui/src/components/chart.tsx b/packages/kilo-ui/src/components/chart.tsx new file mode 100644 index 00000000000..4d7c72b4c0b --- /dev/null +++ b/packages/kilo-ui/src/components/chart.tsx @@ -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 +} + +export function ChartTool(props: ToolProps) { + const [canvas, setCanvas] = createSignal() + const [error, setError] = createSignal() + + 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 ( + +
+ {error() ?
{error()}
: } +
+
+ ) +} diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index dc360fe7ff3..0a61e75f6c6 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -3097,3 +3097,9 @@ ToolRegistry.register({ ) }, }) + +import { ChartTool } from "./chart" +ToolRegistry.register({ + name: "chart", + render: ChartTool, +}) diff --git a/packages/kilo-ui/src/styles/index.css b/packages/kilo-ui/src/styles/index.css index c325eb00628..e972bb85e2f 100644 --- a/packages/kilo-ui/src/styles/index.css +++ b/packages/kilo-ui/src/styles/index.css @@ -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"; diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index eec3f60c1c1..39b844f9894 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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") { diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index b6790ebe29b..3a2b555e355 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -626,6 +626,7 @@ interface SetTabOrderIn { interface SetWorktreeOrderIn { type: "agentManager.setWorktreeOrder" + projectId?: string order: string[] } diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index 6250c0822c1..cbf5c54848a 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -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 | undefined export async function prewarmSpeechCapture(): Promise { + if (useMacCapture(process.platform, process.env)) return await resolveFFmpeg() } @@ -47,8 +68,15 @@ export async function startSpeechCapture(input: Input): Promise { 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 { 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 { }) } +async function startMac(file: string, input: Input): Promise { + 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 { 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 { diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 45a7a339fdc..6fda7cdbb95 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -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") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts new file mode 100644 index 00000000000..1c63c08b4fa --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-store.test.ts b/packages/kilo-vscode/tests/unit/project-store.test.ts new file mode 100644 index 00000000000..84dbe9e710c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-store.test.ts @@ -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" }, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts index 09b456e3e2b..62f3e02cb10 100644 --- a/packages/kilo-vscode/tests/unit/section-helpers.test.ts +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -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")] diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts index 8b68ca79c73..e68d29d90b2 100644 --- a/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts +++ b/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts @@ -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", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 2ea4b0ddaa3..939a8e505be 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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() @@ -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() - 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() - 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 = () => { 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} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 9ea5304843d..3fb1220c166 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -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 + store?: (projectId: string) => ProjectStore stats: Record> local: Record prs: Record> @@ -208,6 +210,7 @@ export const ProjectList: Component = (props) => { boolean stats?: Record local?: LocalGitStats @@ -40,8 +54,21 @@ interface Props { /** Permanent real sidebar body for one expanded project. */ export const ProjectSidebarBody: Component = (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() const [renaming, setRenaming] = createSignal() + const [renamingSection, setRenamingSection] = createSignal() + const [pendingSection, setPendingSection] = createSignal< + { ids: Set; state?: AgentManagerStateMessage } | undefined + >() + const [dragging, setDragging] = createSignal() + const [dragOrigin, setDragOrigin] = createSignal() const [name, setName] = createSignal("") let pendingTimer: ReturnType | undefined onCleanup(() => clearTimeout(pendingTimer)) @@ -61,14 +88,105 @@ export const ProjectSidebarBody: Component = (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) => 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) => { setRenaming(undefined) } - const renderWorktree = (worktree: NonNullable["worktrees"][number]) => ( - - 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 ( +
+ 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 })} + /> +
+ ) + } return (
@@ -202,52 +322,73 @@ export const ProjectSidebarBody: Component = (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)} />
- {/* - 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. - */} - {}} onDragEnd={() => {}}> + - - {(section, index) => ( - post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })} - onRename={(value: string) => - post({ type: "agentManager.renameSection", sectionId: section.id, name: value }) + + + + {(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 })} - > - -
- {renderWorktree} -
-
-
- )} -
- {renderWorktree} + const section = item.section + const list = members(section.id) + return ( + { + 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 })} + > + +
+ {(wt, wtIndex) => renderWorktree(wt, wtIndex, list)} +
+
+
+ ) + }} + + + + {(() => { + const wt = sorted().find((item) => item.id === dragging()) + if (!wt) return null + return ( +
+ + {wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch)} +
+ ) + })()} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx index 34dbedb6d21..823b3c4800a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx @@ -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.onToggle() } - const droppable = createDroppable(props.section.id) + const droppable = createDroppable(props.dropId ?? props.section.id) return (
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(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(OPTION)) { + if (option.disabled || option.closest("[inert]")) continue + option.focus({ preventScroll: true }) + return true + } + return false +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts index 1cc1ca3e80e..ec0ddf55075 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts @@ -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 = {} - for (const item of state.runStatuses ?? []) runs[item.worktreeId] = item - setRunStatuses(runs) + if (state.runStatuses) { + const runs: Record = {} + 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) => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts index 82ae1dcd8d6..c79eb3c5378 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -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() + 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() + 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)]) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index f0d82488ca0..2d01055f04f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -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 = (props) => { suggesting={suggesting} questioning={questioning} boxId={props.promptBoxId} + deferFocusToQuestion={props.deferFocusToQuestion} pendingSessionID={pendingSessionID()} focusOnDraftChange={props.focusOnDraftChange} onFocusChange={props.onFocusChange} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 3d7ba4ede10..530e4f89874 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -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 = (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 = (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 = (props) => { focus() if (!(event instanceof CustomEvent) || !event.detail?.restore) return const restore = () => { + if (defer()) return window.focus() focus() } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx index 61d1148af38..afcbba8a83b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx @@ -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>({}) @@ -16,6 +22,38 @@ const WorkflowsTab: Component = () => { setExpanded((prev) => ({ ...prev, [name]: !prev[name] })) } + const update = (name: string, patch: Partial) => { + 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 (
{/* Description */} @@ -118,6 +156,38 @@ const WorkflowsTab: Component = () => { {cmd.description}
+
e.stopPropagation()} + > + 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")} + /> + 0 || !!variant(cmd, name)}> + 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")}`} + /> + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index b6e83e2836d..2b3b6cac507 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -159,7 +159,6 @@ export const ModelSelectorBase: Component = (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 = (props) => { let listRef: HTMLDivElement | undefined let bodyRef: HTMLDivElement | undefined let previewTimer: ReturnType | undefined + let scrollFrame: number | undefined + let pointerX: number | undefined + let pointerY: number | undefined + let previousSearch: string | undefined const [virtualizer, setVirtualizer] = createSignal() const [pointer, setPointer] = createSignal(true) @@ -223,16 +226,9 @@ export const ModelSelectorBase: Component = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (props) => { previewTimer = setTimeout(() => setPreviewKey(key), 200) } + function pointerMove(e: MouseEvent) { + const target = e.target + if (!(target instanceof Element)) return + const item = target.closest('[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 = (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 = (props) => {
-
+
{language.t("dialog.model.empty")} @@ -871,16 +900,13 @@ export const ModelSelectorBase: Component = (props) => { return (
e.preventDefault()} onClick={() => toggleGroup(group.key)} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(key) - }} > = (props) => { {group.label} - +
@@ -906,15 +932,12 @@ export const ModelSelectorBase: Component = (props) => { return (
pickClear()} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(CLEAR_KEY) - }} > {props.clearLabel ?? language.t("dialog.model.notSet")} @@ -939,6 +962,7 @@ export const ModelSelectorBase: Component = (props) => { >
= (props) => { onDblClick={() => { if (expanded()) selectRow(row) }} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(row.key) - }} >
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index f49c649a9d1..ff6cd6f3d46 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -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 = (props) => { @@ -163,7 +165,7 @@ export const ThinkingSelectorBase: Component = (props open={open()} onOpenChange={onOpen} triggerAs={Button} - triggerProps={{ variant: "ghost", size: "small" }} + triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label }} trigger={ <> {display(props.value)} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 904c5fdcdb3..6d2785316b9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -40,6 +40,7 @@ export interface SaveError { interface ConfigContextValue { config: Accessor globalConfig: Accessor + globalDraft: Accessor> projectConfig: Accessor collections: Accessor settings: Accessor> @@ -397,6 +398,7 @@ export const ConfigProvider: ParentComponent = (props) => { const value: ConfigContextValue = { config, globalConfig, + globalDraft, projectConfig, collections, settings, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4aca678d7b7..6c425940a8f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 46e7fd6c135..1a7d9ae17ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 520a51a0678..509f0265201 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index d21ccf759eb..4f1d6c9034c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index a0ea7610299..7c1366c7261 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6e38f5ff211..5752f6b2a8c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 1caf01a9c0e..2b7ed694b0b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 928069dad2e..71b97a2d3ff 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -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": "نام", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 90d6a0d93c4..3fd9956d9de 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5ff35d8cb14..b8ec52156f4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 66fe638da35..fa1eda6f6b0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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レベルのサンドボックス内で実行", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 282acffa52a..360ed69cb00 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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 수준의 샌드박스 내에서 실행", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 4ed4b284f23..64f70685b4c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 580f7ff506d..103de2edff6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 1a2c0e2641d..18830cde9be 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index d0651d0e625..5eca69def5d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 344acc5c41f..c4dbc68bcf5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index c4cdb4d2226..0761b27b94a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 6f82c041064..b4b8c529d24 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -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 за замовчуванням — Запитувати.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index e98113e8d55..041c72d116b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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 状态目录内", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 9795bca81a6..52ecca5fa9e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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 狀態目錄內", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 6328f8d7818..16361cf8f0d 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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, diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9db2a45fa80..9ebef0f8766 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -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 => ({ 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 = [], 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={{ diff --git a/packages/kilo-vscode/webview-ui/src/stories/tool-call-lab.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/tool-call-lab.stories.tsx index 77248db697f..b5f8d714d1d 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/tool-call-lab.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/tool-call-lab.stories.tsx @@ -1227,6 +1227,231 @@ export const SearchPreviews: Story = {
+
+
+ Chart renderers + + Chart.js chart tool rendered with production ChartTool component. + +
+
+
+ Bar chart + +
+
+ Line plot + +
+
+ Scatter plot + +
+
+ Temporal (time series) + +
+
+ Pie chart + +
+
+ Doughnut chart + +
+
+ Radar chart + +
+
+ Bubble chart + +
+
+
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index b59633f7489..84a6e9ff5b9 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -25,10 +25,12 @@ export interface ConfigCollectionEntry { export type ConfigCollections = Record export interface CommandConfig { - template: string + template?: string description?: string agent?: string - model?: string + model?: string | null + variant?: string | null + subtask?: boolean } export interface SkillsConfig { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 6e770024e49..4f11b99d7c2 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -876,6 +876,7 @@ export interface SetTabOrderRequest { // Persist sidebar worktree order export interface SetWorktreeOrderRequest { type: "agentManager.setWorktreeOrder" + projectId?: string order: string[] } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index c4cffa639b8..332b2e5c872 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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 diff --git a/packages/opencode/src/kilocode/command/override.ts b/packages/opencode/src/kilocode/command/override.ts new file mode 100644 index 00000000000..277a62be9e4 --- /dev/null +++ b/packages/opencode/src/kilocode/command/override.ts @@ -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 + 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, 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 +} diff --git a/packages/opencode/src/kilocode/session/workflow-variant.ts b/packages/opencode/src/kilocode/session/workflow-variant.ts new file mode 100644 index 00000000000..16e3e6cc5a8 --- /dev/null +++ b/packages/opencode/src/kilocode/session/workflow-variant.ts @@ -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 + agent: Pick + model: { providerID: string; modelID: string } + selected: { variants?: Record } + 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 +} diff --git a/packages/opencode/src/kilocode/tool/chart.ts b/packages/opencode/src/kilocode/tool/chart.ts new file mode 100644 index 00000000000..c928c46ea79 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/chart.ts @@ -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, 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).type === "area") { + const s = spec as Record + s.type = "line" + const data = s.data as Record | undefined + if (data && Array.isArray(data.datasets)) { + data.datasets = (data.datasets as Record[]).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), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 3e1dda820b5..bb86481a6d1 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -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 && diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index c73774e4311..f5d094df7bc 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -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 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 = [ + 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, + } + } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b4c74fe5fe..1928ef8625b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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 }) => 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, { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 4981aa5e777..a67cf4e12a0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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 diff --git a/packages/opencode/test/kilocode/chart-tool-gating.test.ts b/packages/opencode/test/kilocode/chart-tool-gating.test.ts new file mode 100644 index 00000000000..b6eb0862802 --- /dev/null +++ b/packages/opencode/test/kilocode/chart-tool-gating.test.ts @@ -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") +}) diff --git a/packages/opencode/test/kilocode/command/override.test.ts b/packages/opencode/test/kilocode/command/override.test.ts new file mode 100644 index 00000000000..2c8806d0991 --- /dev/null +++ b/packages/opencode/test/kilocode/command/override.test.ts @@ -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") + }) +}) diff --git a/packages/opencode/test/kilocode/config/workflow-model-override.test.ts b/packages/opencode/test/kilocode/config/workflow-model-override.test.ts new file mode 100644 index 00000000000..6a90db18bdf --- /dev/null +++ b/packages/opencode/test/kilocode/config/workflow-model-override.test.ts @@ -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", + }) + }) +}) diff --git a/packages/opencode/test/kilocode/issue-8656-stall.test.ts b/packages/opencode/test/kilocode/issue-8656-stall.test.ts index c6c939e1ec4..dbdbf2a46b5 100644 --- a/packages/opencode/test/kilocode/issue-8656-stall.test.ts +++ b/packages/opencode/test/kilocode/issue-8656-stall.test.ts @@ -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, + messages: (id: string) => json(`/session/${id}/message?${query}`, undefined, true) as Promise, status: async (id: string) => { - const all = (await json(`/session/status?${query}`)) as Record + const all = (await json(`/session/status?${query}`, undefined, true)) as Record return all[id]?.type ?? "idle" }, } diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index c67598ba435..5a331d0fce8 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -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 collections: Record> 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( + 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(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(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", ) diff --git a/packages/opencode/test/kilocode/session/workflow-variant.test.ts b/packages/opencode/test/kilocode/session/workflow-variant.test.ts new file mode 100644 index 00000000000..d7cb4a48310 --- /dev/null +++ b/packages/opencode/test/kilocode/session/workflow-variant.test.ts @@ -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") + }) +}) diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts index 50ea098b034..7ef70b045a1 100644 --- a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -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" }, + }, + }, + }, + ), + ) }) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 10739afdf70..900be12330f 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -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"), diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 772a8042e1a..db247c7c63a 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -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", diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index e27689ec316..16bffc77f3a 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -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"), diff --git a/packages/opencode/test/kilocode/tool/send-file.test.ts b/packages/opencode/test/kilocode/tool/send-file.test.ts index 69ae8ffd2ae..6afb16046c2 100644 --- a/packages/opencode/test/kilocode/tool/send-file.test.ts +++ b/packages/opencode/test/kilocode/tool/send-file.test.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 02f843df6bb..64eb6aa5c4c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fe6d6064a00..b67a8255660 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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" }