From d6e63d24e30c05ff2cf8f480c5ae4b19b343e958 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:41:30 +0000 Subject: [PATCH 001/107] ci: benchmark Windows install without Bun cache Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/actions/setup-bun/action.yml | 5 +++-- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 5b44517ec51..ae4a2f8db62 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -34,6 +34,7 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Restore Bun dependencies + if: runner.os != 'Windows' id: bun-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -52,9 +53,9 @@ runs: # e.g. ./patches/ for standard-openapi # https://github.com/oven-sh/bun/issues/28147 if [ "$RUNNER_OS" = "Windows" ]; then - bun install --linker hoisted ${{ inputs.install-flags }} + bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} else - bun install ${{ inputs.install-flags }} + bun install --frozen-lockfile ${{ inputs.install-flags }} fi shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c3b6b5a136..270f1fad4e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: - name: Cache Turbo uses: actions/cache@v5 # kilocode_change with: - path: node_modules/.cache/turbo + path: .turbo/cache key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}- From dd426811c8c146df109290e8ec5c088ae1242213 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:58:05 +0000 Subject: [PATCH 002/107] ci: benchmark Windows install with Bun cache Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/actions/setup-bun/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index ae4a2f8db62..e0bd48dff8e 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -34,7 +34,6 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Restore Bun dependencies - if: runner.os != 'Windows' id: bun-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: From bbc5b7ef83687760ee9614722fa6291228f281f3 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:07:50 +0000 Subject: [PATCH 003/107] ci: skip slower Bun cache restore on Windows Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/actions/setup-bun/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index e0bd48dff8e..ae4a2f8db62 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -34,6 +34,7 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Restore Bun dependencies + if: runner.os != 'Windows' id: bun-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: From b96ef375997a55b1ba1e3402860d6ef8b43035e7 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:19:58 +0000 Subject: [PATCH 004/107] ci: avoid saving unused Windows Bun cache Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/actions/setup-bun/action.yml | 8 ++++---- .github/workflows/test.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index ae4a2f8db62..4e9a078e7ba 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -34,7 +34,7 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Restore Bun dependencies - if: runner.os != 'Windows' + if: runner.os != 'Windows' # kilocode_change id: bun-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -53,14 +53,14 @@ runs: # e.g. ./patches/ for standard-openapi # https://github.com/oven-sh/bun/issues/28147 if [ "$RUNNER_OS" = "Windows" ]; then - bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} + bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} # kilocode_change else - bun install --frozen-lockfile ${{ inputs.install-flags }} + bun install --frozen-lockfile ${{ inputs.install-flags }} # kilocode_change fi shell: bash - name: Save Bun dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' + if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' # kilocode_change uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 270f1fad4e5..30bc1a64953 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: - name: Cache Turbo uses: actions/cache@v5 # kilocode_change with: - path: .turbo/cache + path: .turbo/cache # kilocode_change key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}- From 3e51e3c8743b27a8db2ed5650397019603ff0a2a Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:27:29 +0000 Subject: [PATCH 005/107] docs(ci): explain disabled Windows Bun cache Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/actions/setup-bun/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 4e9a078e7ba..ee76e6de97f 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -33,6 +33,8 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" + # Restoring and extracting the ~1 GB cache took 2m23s on Windows, while a # kilocode_change + # fresh install took 1m27s. Keep Windows off this cache until that reverses. # kilocode_change - name: Restore Bun dependencies if: runner.os != 'Windows' # kilocode_change id: bun-cache @@ -51,7 +53,7 @@ runs: run: | # Workaround for patched peer variants # e.g. ./patches/ for standard-openapi - # https://github.com/oven-sh/bun/issues/28147 + # https://github.com/oven-sh/bun/issues/28147 # kilocode_change if [ "$RUNNER_OS" = "Windows" ]; then bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} # kilocode_change else @@ -59,6 +61,7 @@ runs: fi shell: bash + # Do not upload a Windows cache that Windows jobs intentionally never restore. # kilocode_change - name: Save Bun dependencies if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' # kilocode_change uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 From 08577b2071bf00a9a5068e555e1fa0cef0c48633 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:43:56 +0000 Subject: [PATCH 006/107] feat(vscode): support multilingual notebook autocomplete Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/bright-notebooks-complete.md | 2 +- .../AutocompleteLanguageInfo.test.ts | 19 ++++ .../constants/AutocompleteLanguageInfo.ts | 36 ++++++ .../continuedev/core/autocomplete/notebook.ts | 26 +++-- .../templating/constructPrefixSuffix.ts | 4 +- .../core/autocomplete/util/HelperVars.ts | 4 +- .../core/autocomplete/util/types.ts | 1 + .../src/services/autocomplete/types.ts | 2 + .../tests/unit/notebook-context.test.ts | 103 +++++++++++++++--- 9 files changed, 171 insertions(+), 26 deletions(-) diff --git a/.changeset/bright-notebooks-complete.md b/.changeset/bright-notebooks-complete.md index afe6eb43733..1da4072fb4f 100644 --- a/.changeset/bright-notebooks-complete.md +++ b/.changeset/bright-notebooks-complete.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Enable autocomplete in Jupyter notebooks. +Enable autocomplete across supported languages in Jupyter notebooks. diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts index a6c35399519..e22b07b40d0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { languageForFilepath, + languageForId, LANGUAGES, Typescript, JavaScript, @@ -31,6 +32,24 @@ import { } from "./AutocompleteLanguageInfo" describe("AutocompleteLanguageInfo", () => { + describe("languageForId", () => { + it("resolves VS Code language identifiers", () => { + expect(languageForId("typescript")).toBe(Typescript) + expect(languageForId("typescriptreact")).toBe(Typescript) + expect(languageForId("javascript")).toBe(JavaScript) + expect(languageForId("javascriptreact")).toBe(JavaScript) + expect(languageForId("jsonc")).toBe(Json) + expect(languageForId("python")).toBe(Python) + expect(languageForId("r")).toBe(R) + expect(languageForId("julia")).toBe(Julia) + expect(languageForId("luau")).toBe(Lua) + }) + + it("rejects unknown language identifiers", () => { + expect(languageForId("custom-language")).toBeUndefined() + }) + }) + describe("languageForFilepath", () => { describe("TypeScript/JavaScript files", () => { it("should return TypeScript for .ts files", () => { diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts index aee32df9230..012e37828aa 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts @@ -367,6 +367,42 @@ export const LANGUAGES: { [extension: string]: AutocompleteLanguageInfo } = { luau: Lua, } +const IDS: Record = { + typescript: Typescript, + typescriptreact: Typescript, + javascript: JavaScript, + javascriptreact: JavaScript, + json: Json, + jsonc: Json, + python: Python, + java: Java, + cpp: Cpp, + c: C, + csharp: CSharp, + scala: Scala, + go: Go, + rust: Rust, + haskell: Haskell, + php: PHP, + ruby: Ruby, + swift: Swift, + kotlin: Kotlin, + clojure: Clojure, + julia: Julia, + fsharp: FSharp, + r: R, + dart: Dart, + solidity: Solidity, + yaml: YAML, + markdown: Markdown, + lua: Lua, + luau: Lua, +} + +export function languageForId(id: string): AutocompleteLanguageInfo | undefined { + return IDS[id] +} + export function languageForFilepath(fileUri: string): AutocompleteLanguageInfo { const extension = getUriFileExtension(fileUri) return LANGUAGES[extension] || Typescript diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts index 2dca033b18e..076e1aacbd3 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts @@ -2,9 +2,10 @@ // https://github.com/continuedev/continue/blob/d0a3c0b626b5bebc3bef4742eec05a0242be0bab/extensions/vscode/src/autocomplete/completionProvider.ts#L226-L263 // Copyright 2023 Continue // Licensed under the Apache License, Version 2.0. -// Modified by Kilo Code for notebook paths, cursor positions, and cache scoping. +// Modified by Kilo Code for notebook paths, cursor positions, multilingual context, and cache scoping. import * as vscode from "vscode" +import { languageForId } from "./constants/AutocompleteLanguageInfo" export interface NotebookContext { contents: string @@ -53,7 +54,7 @@ export function notebookUri(uri: vscode.Uri): vscode.Uri | undefined { export function supportsNotebook(document: vscode.TextDocument): boolean { if (document.uri.scheme !== "vscode-notebook-cell") return true const resolved = resolveNotebook(document.uri) - return resolved?.cell.kind === vscode.NotebookCellKind.Code && document.languageId === "python" + return resolved?.cell.kind === vscode.NotebookCellKind.Code && !!languageForId(document.languageId) } export function autocompleteScope(document: vscode.TextDocument): string { @@ -64,7 +65,7 @@ export function autocompleteScope(document: vscode.TextDocument): string { const siblings = resolved.cells .filter((_, index) => index !== resolved.index) .map((cell) => [cell.document.uri.toString(), cell.kind, cell.document.languageId, cell.document.version]) - return JSON.stringify([id, resolved.notebook.uri.toString(), resolved.index, siblings]) + return JSON.stringify([id, document.languageId, resolved.notebook.uri.toString(), resolved.index, siblings]) } export function getNotebookContext( @@ -77,13 +78,24 @@ export function getNotebookContext( if (!resolved) return const cells = resolved.cells + const lang = languageForId(document.languageId) + if (!lang) return + + const marker = document.languageId === "json" ? undefined : lang.singleLineComment + const comment = (text: string, label: string) => + text + .split("\n") + .map((line, index) => (marker ? `${marker} ${index === 0 ? `[${label}] ` : ""}${line}` : "")) + .join("\n") + const contents = cells .map((cell) => { const text = cell.document.getText() - if (cell.kind === vscode.NotebookCellKind.Markup) { - return `"""${text}"""` - } - return text + if (cell.kind === vscode.NotebookCellKind.Markup) return comment(text, "markdown") + const sibling = languageForId(cell.document.languageId) + const strict = document.languageId === "json" || cell.document.languageId === "json" + if (sibling === lang && (!strict || cell.document.languageId === document.languageId)) return text + return comment(text, cell.document.languageId) }) .join("\n\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts index 0e72eafcf79..5f6aa302da2 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts @@ -1,6 +1,6 @@ import { IDE } from "../.." import { getRangeInString } from "../../util/ranges" -import { languageForFilepath } from "../constants/AutocompleteLanguageInfo" +import { languageForFilepath, languageForId } from "../constants/AutocompleteLanguageInfo" import { AutocompleteInput } from "../util/types" /** @@ -14,7 +14,7 @@ export async function constructInitialPrefixSuffix( prefix: string suffix: string }> { - const lang = languageForFilepath(input.filepath) + const lang = (input.languageId && languageForId(input.languageId)) || languageForFilepath(input.filepath) const fileContents = input.manuallyPassFileContents ?? (await ide.readFile(input.filepath)) const fileLines = fileContents.split("\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts index 000fc7ee905..ed2a8613e16 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts @@ -1,6 +1,6 @@ import { IDE, TabAutocompleteOptions } from "../.." import { countTokens, pruneLinesFromBottom, pruneLinesFromTop } from "../../llm/countTokens" -import { AutocompleteLanguageInfo, languageForFilepath } from "../constants/AutocompleteLanguageInfo" +import { AutocompleteLanguageInfo, languageForFilepath, languageForId } from "../constants/AutocompleteLanguageInfo" import { constructInitialPrefixSuffix } from "../templating/constructPrefixSuffix" import { AstPath, getAst, getTreePathAtCursor } from "./ast" @@ -35,7 +35,7 @@ export const HelperVars = { modelName: string, ide: IDE, ): Promise => { - const lang = languageForFilepath(input.filepath) + const lang = (input.languageId && languageForId(input.languageId)) || languageForFilepath(input.filepath) const workspaceUris = await ide.getWorkspaceDirs() const fileContents = input.manuallyPassFileContents ?? (await ide.readFile(input.filepath)) const fileLines = fileContents.split("\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts index 1b11559f8a0..a366bf48668 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts @@ -11,6 +11,7 @@ export interface AutocompleteInput { isUntitledFile: boolean completionId: string filepath: string + languageId?: string pos: Position recentlyVisitedRanges: AutocompleteCodeSnippet[] recentlyEditedRanges: RecentlyEditedRange[] diff --git a/packages/kilo-vscode/src/services/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/types.ts index b7a88433828..cd400d47865 100644 --- a/packages/kilo-vscode/src/services/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/types.ts @@ -32,6 +32,7 @@ export interface AutocompleteInput { isUntitledFile: boolean completionId: string filepath: string + languageId?: string pos: Position recentlyVisitedRanges: AutocompleteCodeSnippet[] recentlyEditedRanges: RecentlyEditedRange[] @@ -201,6 +202,7 @@ export function contextToAutocompleteInput(context: AutocompleteSuggestionContex isUntitledFile: context.document.isUntitled, completionId: crypto.randomUUID(), filepath: context.document.uri.fsPath, + languageId: context.document.languageId, pos: { line: position.line, character: position.character }, recentlyVisitedRanges, recentlyEditedRanges, diff --git a/packages/kilo-vscode/tests/unit/notebook-context.test.ts b/packages/kilo-vscode/tests/unit/notebook-context.test.ts index c4f2742936c..7410242218e 100644 --- a/packages/kilo-vscode/tests/unit/notebook-context.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-context.test.ts @@ -7,7 +7,9 @@ import { supportsNotebook, } from "../../src/services/autocomplete/continuedev/core/autocomplete/notebook" import { accessible } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider" +import { constructInitialPrefixSuffix } from "../../src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix" import type { FileIgnoreController } from "../../src/services/autocomplete/shims/FileIgnoreController" +import type { AutocompleteInput } from "../../src/services/autocomplete/types" function uri(scheme: string, path: string, fragment = ""): vscode.Uri { const value = `${scheme}:${path}${fragment ? `#${fragment}` : ""}` @@ -38,8 +40,8 @@ function notebooks(value: vscode.NotebookDocument[]): void { describe("notebook context", () => { beforeEach(() => notebooks([])) - it("flattens notebook cells and translates the cursor", () => { - const markdown = document("markdown", "# Title\nNotes") + it("projects mixed-language context for the active Python cell", () => { + const markdown = document("markdown", "# Title\nNotes", "markdown") const code = document("code", "const value = 1\nvalue += 1", "javascript") const current = document("current", "print(value)\nprint('done')") const notebook = { @@ -55,34 +57,102 @@ describe("notebook context", () => { const context = getNotebookContext(current, new vscode.Position(1, 5)) expect(context).toEqual({ - contents: `"""# Title\nNotes"""\n\nconst value = 1\nvalue += 1\n\nprint(value)\nprint('done')`, + contents: `# [markdown] # Title\n# Notes\n\n# [javascript] const value = 1\n# value += 1\n\nprint(value)\nprint('done')`, filepath: "/workspace/example.ipynb", position: new vscode.Position(7, 5), }) }) - it("limits notebook completion to Python code cells", () => { - const python = document("python", "value = 1") - const javascript = document("javascript", "const value = 1", "javascript") - const markdown = document("markdown", "# Heading", "markdown") + it("projects mixed-language context for the active JavaScript cell", () => { + const markdown = document("markdown", "Setup\nvalues", "markdown") + const python = document("python", "value = 1\nprint(value)") + const current = document("current", "const value = 1", "javascript") const notebook = { uri: uri("file", "/workspace/example.ipynb"), getCells: () => [ - { kind: vscode.NotebookCellKind.Code, document: python }, - { kind: vscode.NotebookCellKind.Code, document: javascript }, { kind: vscode.NotebookCellKind.Markup, document: markdown }, + { kind: vscode.NotebookCellKind.Code, document: python }, + { kind: vscode.NotebookCellKind.Code, document: current }, ], } as vscode.NotebookDocument notebooks([notebook]) - expect(supportsNotebook(python)).toBe(true) - expect(supportsNotebook(javascript)).toBe(false) - expect(supportsNotebook(markdown)).toBe(false) - expect(getNotebookContext(javascript, new vscode.Position(0, 0))).toBeUndefined() - expect(getNotebookContext(markdown, new vscode.Position(0, 0))).toBeUndefined() + expect(getNotebookContext(current, new vscode.Position(0, 6))).toEqual({ + contents: `// [markdown] Setup\n// values\n\n// [python] value = 1\n// print(value)\n\nconst value = 1`, + filepath: "/workspace/example.ipynb", + position: new vscode.Position(6, 6), + }) + }) + + it("supports known code languages and rejects non-code or unknown cells", () => { + const cells = [ + document("python", "value = 1"), + document("javascript", "const value = 1", "javascript"), + document("typescript", "const value: number = 1", "typescript"), + document("r", "value <- 1", "r"), + document("julia", "value = 1", "julia"), + document("jsonc", "{ // comment\n}", "jsonc"), + document("luau", "local value = 1", "luau"), + document("unknown", "value = 1", "custom-language"), + document("markdown", "# Heading", "markdown"), + ] + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => + cells.map((document, index) => ({ + kind: index === cells.length - 1 ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code, + document, + })), + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(cells.slice(0, 7).every(supportsNotebook)).toBe(true) + expect(supportsNotebook(cells[7]!)).toBe(false) + expect(supportsNotebook(cells[8]!)).toBe(false) + expect(getNotebookContext(cells[7]!, new vscode.Position(0, 0))).toBeUndefined() expect(supportsNotebook({ uri: uri("file", "/workspace/file.ts") } as vscode.TextDocument)).toBe(true) }) + it("omits foreign and markup content from strict JSON context", () => { + const markdown = document("markdown", "Describe values", "markdown") + const javascript = document("javascript", "const value = 1", "javascript") + const current = document("current", '{"value": 1}', "json") + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => [ + { kind: vscode.NotebookCellKind.Markup, document: markdown }, + { kind: vscode.NotebookCellKind.Code, document: javascript }, + { kind: vscode.NotebookCellKind.Code, document: current }, + ], + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(getNotebookContext(current, new vscode.Position(0, 3))).toEqual({ + contents: `\n\n\n\n{"value": 1}`, + filepath: "/workspace/example.ipynb", + position: new vscode.Position(4, 3), + }) + }) + + it("uses the active cell language when constructing notebook prompts", async () => { + const input: AutocompleteInput = { + isUntitledFile: false, + completionId: "completion", + filepath: "/workspace/example.ipynb", + languageId: "javascript", + pos: { line: 0, character: 5 }, + recentlyVisitedRanges: [], + recentlyEditedRanges: [], + manuallyPassFileContents: "value = 1", + injectDetails: "notebook context", + } + + const result = await constructInitialPrefixSuffix(input, {} as never) + + expect(result.prefix).toBe("\n// notebook context\nvalue") + expect(result.suffix).toBe(" = 1") + }) + it("resolves file and notebook cell URIs", () => { const file = uri("file", "/workspace/file.ts") const cell = document("code", "value = 1") @@ -120,6 +190,11 @@ describe("notebook context", () => { Object.assign(notebook, { version: 3 }) expect(autocompleteScope(current)).not.toBe(initial) expect(autocompleteScope(current)).not.toBe(autocompleteScope(sibling)) + + const changed = autocompleteScope(current) + Object.assign(current, { languageId: "javascript" }) + Object.assign(notebook, { version: 4 }) + expect(autocompleteScope(current)).not.toBe(changed) }) it("changes autocomplete scope when sibling order changes", () => { From 31ee9a67e0286d4c614afd9dbf5926efe3340497 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:53:56 +0000 Subject: [PATCH 007/107] fix(vscode): isolate JSON notebook cell context Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../continuedev/core/autocomplete/notebook.ts | 10 +++++----- .../kilo-vscode/tests/unit/notebook-context.test.ts | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts index 076e1aacbd3..15d3180d277 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts @@ -81,7 +81,8 @@ export function getNotebookContext( const lang = languageForId(document.languageId) if (!lang) return - const marker = document.languageId === "json" ? undefined : lang.singleLineComment + const json = document.languageId === "json" || document.languageId === "jsonc" + const marker = json ? undefined : lang.singleLineComment const comment = (text: string, label: string) => text .split("\n") @@ -89,12 +90,11 @@ export function getNotebookContext( .join("\n") const contents = cells - .map((cell) => { + .map((cell, index) => { const text = cell.document.getText() + if (index === resolved.index) return text if (cell.kind === vscode.NotebookCellKind.Markup) return comment(text, "markdown") - const sibling = languageForId(cell.document.languageId) - const strict = document.languageId === "json" || cell.document.languageId === "json" - if (sibling === lang && (!strict || cell.document.languageId === document.languageId)) return text + if (!json && languageForId(cell.document.languageId) === lang) return text return comment(text, cell.document.languageId) }) .join("\n\n") diff --git a/packages/kilo-vscode/tests/unit/notebook-context.test.ts b/packages/kilo-vscode/tests/unit/notebook-context.test.ts index 7410242218e..99002dd05a4 100644 --- a/packages/kilo-vscode/tests/unit/notebook-context.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-context.test.ts @@ -116,21 +116,23 @@ describe("notebook context", () => { it("omits foreign and markup content from strict JSON context", () => { const markdown = document("markdown", "Describe values", "markdown") const javascript = document("javascript", "const value = 1", "javascript") + const sibling = document("sibling", '{"other": 2}', "json") const current = document("current", '{"value": 1}', "json") const notebook = { uri: uri("file", "/workspace/example.ipynb"), getCells: () => [ { kind: vscode.NotebookCellKind.Markup, document: markdown }, { kind: vscode.NotebookCellKind.Code, document: javascript }, + { kind: vscode.NotebookCellKind.Code, document: sibling }, { kind: vscode.NotebookCellKind.Code, document: current }, ], } as vscode.NotebookDocument notebooks([notebook]) expect(getNotebookContext(current, new vscode.Position(0, 3))).toEqual({ - contents: `\n\n\n\n{"value": 1}`, + contents: `\n\n\n\n\n\n{"value": 1}`, filepath: "/workspace/example.ipynb", - position: new vscode.Position(4, 3), + position: new vscode.Position(6, 3), }) }) From b793bf788f20e5d96898c0565916af7bc71a5683 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 13:42:15 +0200 Subject: [PATCH 008/107] fix(cli): block file/env references in untrusted project config Project-scoped config could resolve {file:...} and {env:...} tokens, letting a malicious repo exfiltrate arbitrary local files by pointing a provider apiKey at a local file and baseURL at an attacker server. Substitution now requires a trusted source (global config, KILO_CONFIG, KILO_CONFIG_CONTENT, well-known/org/MDM config). Untrusted project config rejects such tokens (surfaced as a warning). Threaded the trust flag through config.ts, agent.ts, overlay.ts, and tui.ts; TUI reuses the same project-boundary classification as config.ts. --- .changeset/config-file-substitution-trust.md | 5 ++ .../opencode/src/cli/cmd/tui/config/tui.ts | 34 ++++--- packages/opencode/src/config/agent.ts | 5 +- packages/opencode/src/config/config.ts | 90 +++++++++++++------ packages/opencode/src/config/variable.ts | 29 +++++- .../opencode/src/kilocode/config/overlay.ts | 15 ++-- packages/opencode/test/config/config.test.ts | 65 ++++++-------- packages/opencode/test/config/tui.test.ts | 35 ++++++-- .../test/kilocode/config/variable.test.ts | 51 +++++++++-- 9 files changed, 231 insertions(+), 98 deletions(-) create mode 100644 .changeset/config-file-substitution-trust.md diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md new file mode 100644 index 00000000000..3ae377de41e --- /dev/null +++ b/.changeset/config-file-substitution-trust.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent project config from reading local files or environment variables via `{file:...}` / `{env:...}` references. These references now resolve only in trusted user-owned config (global config, `KILO_CONFIG`, well-known org config), closing a path where a malicious project could exfiltrate local files through a provider API key. diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 4363e249faa..d9d136dc2f3 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,10 +112,15 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - const load = (text: string, configFilepath: string): Effect.Effect => + const load = ( + text: string, + configFilepath: string, + trusted: boolean, + ): Effect.Effect => // kilocode_change - trusted param Effect.gen(function* () { const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info @@ -149,7 +154,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - const loadFile = (filepath: string): Effect.Effect => + const loadFile = ( + filepath: string, + trusted: boolean, + ): Effect.Effect => // kilocode_change - trusted param Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. // Matches how parse/schema/plugin failures in load() are handled — every @@ -169,12 +177,16 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath) + return yield* load(text, filepath, trusted) }) - const mergeFile = (acc: Acc, file: string) => + const mergeFile = ( + acc: Acc, + file: string, + trusted: boolean, // kilocode_change - trusted param + ) => Effect.gen(function* () { - const data = yield* loadFile(file) + const data = yield* loadFile(file, trusted) if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -207,19 +219,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, true) // kilocode_change - global config is trusted } // 2. Explicit KILO_TUI_CONFIG override, if set. if (Flag.KILO_TUI_CONFIG) { const configFile = Flag.KILO_TUI_CONFIG - yield* mergeFile(acc, configFile) + yield* mergeFile(acc, configFile, true) // kilocode_change - explicit env-provided path is trusted log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, false) // kilocode_change - project config is untrusted } // kilocode_change start - load tui.json from supported Kilo config directories @@ -232,8 +244,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // kilocode_change end for (const dir of dirs) { + // kilocode_change - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted + const trusted = pluginScope(dir, ctx) === "global" for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, trusted) } } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 2b00ccdbde8..aca34884880 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -134,8 +134,8 @@ export const Info = AgentSchema.pipe( ).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type -// kilocode_change start -export async function load(dir: string, warnings?: Warning[]) { +// kilocode_change start - trusted controls whether agent prompts may resolve {file:}/{env:} tokens +export async function load(dir: string, warnings?: Warning[], trusted?: boolean) { // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -176,6 +176,7 @@ export async function load(dir: string, warnings?: Warning[]) { source: item, missing: "empty", escapeJson: false, + trusted, // kilocode_change - project agents must not resolve file/env references }) const config = { name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be259ff3d9d..48484f12692 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -116,6 +116,7 @@ async function substituteWellKnownRemoteConfig(input: { dir: input.dir, source: input.source, env: input.env, + trusted: true, // kilocode_change - well-known org config is a trusted source }) const headers = isRecord(input.value.headers) ? Object.fromEntries( @@ -130,6 +131,7 @@ async function substituteWellKnownRemoteConfig(input: { dir: input.dir, source: input.source, env: input.env, + trusted: true, // kilocode_change - well-known org config is a trusted source }), ]), ), @@ -566,13 +568,14 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, + trusted?: boolean, // kilocode_change - only user-owned config may resolve {file:}/{env:} tokens ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, + ? { text, type: "path", path: options.path, env, trusted } // kilocode_change + : { text, type: "virtual", ...options, env, trusted }, // kilocode_change ), ) const parsed = ConfigParse.jsonc(expanded, source) @@ -590,11 +593,15 @@ export const layer = Layer.effect( return data }) - const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + const loadFile = Effect.fnUntraced(function* ( + filepath: string, + env?: Record, + trusted?: boolean, // kilocode_change + ) { log.info("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env) + return yield* loadConfig(text, { path: filepath }, env, trusted) // kilocode_change }) let globalStamp = "" // kilocode_change @@ -615,13 +622,14 @@ export const layer = Layer.effect( .pipe(Effect.catch(() => Effect.void)) } } - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env)) + // kilocode_change - global config is user-owned and trusted to resolve {file:}/{env:} tokens + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env, true)) // kilocode_change start - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json"), env)) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json"), env, true)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"), env, true)) // kilocode_change end - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env)) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env, true)) // kilocode_change + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env, true)) // kilocode_change const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -799,6 +807,7 @@ export const layer = Layer.effect( source, }, authEnv, + true, // kilocode_change - well-known org config is a trusted source ) yield* merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) @@ -833,7 +842,8 @@ export const layer = Layer.effect( // kilocode_change start - capture KILO_CONFIG failures as warnings yield* merge( Flag.KILO_CONFIG, - yield* loadFile(Flag.KILO_CONFIG, authEnv).pipe( + // kilocode_change - KILO_CONFIG is an explicit user-provided path, trusted for {file:}/{env:} + yield* loadFile(Flag.KILO_CONFIG, authEnv, true).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, Flag.KILO_CONFIG!, err) return Effect.succeed({} as Info) @@ -850,7 +860,8 @@ export const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - yield* loadFile(file, authEnv).pipe( + // kilocode_change - project config is untrusted: {file:}/{env:} tokens are rejected + yield* loadFile(file, authEnv, false).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -886,19 +897,23 @@ export const layer = Layer.effect( // kilocode_change start for (const dir of unique(directories)) { const scope = primarySet.has(dir) ? "local" : undefined + // kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones + const dirScope = scope ?? (yield* pluginScopeForSource(dir)) + const dirTrusted = dirScope === "global" if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) yield* merge( source, - yield* loadFile(source, authEnv).pipe( + yield* loadFile(source, authEnv, dirTrusted).pipe( + // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) return Effect.succeed({} as Info) }), ), - scope, + dirScope, // kilocode_change ) result.agent ??= {} result.mode ??= {} @@ -937,13 +952,16 @@ export const layer = Layer.effect( result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir, warnings)), ) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings))) + result.agent = mergeDeep( + result.agent ?? {}, + yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted)), // kilocode_change + ) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) // kilocode_change end // kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load // returns normalized Specs and we only need to attach origin metadata here. const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* mergePluginOrigins(dir, list, scope) // kilocode_change + yield* mergePluginOrigins(dir, list, dirScope) // kilocode_change } if (process.env.KILO_CONFIG_CONTENT) { @@ -951,10 +969,15 @@ export const layer = Layer.effect( const source = "KILO_CONFIG_CONTENT" yield* merge( source, - yield* loadConfig(process.env.KILO_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }).pipe( + yield* loadConfig( + process.env.KILO_CONFIG_CONTENT, + { + dir: ctx.directory, + source, + }, + undefined, + true, // kilocode_change - KILO_CONFIG_CONTENT is user-provided, trusted for {file:}/{env:} + ).pipe( Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))), Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) @@ -985,10 +1008,15 @@ export const layer = Layer.effect( if (Option.isSome(configOpt)) { const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) + const next = yield* loadConfig( + JSON.stringify(configOpt.value), + { + dir: path.dirname(source), + source, + }, + undefined, + true, // kilocode_change - console-managed org config is a trusted source + ) for (const providerID of Object.keys(next.provider ?? {})) { consoleManagedProviders.add(providerID) } @@ -1010,7 +1038,8 @@ export const layer = Layer.effect( if (existsSync(managedDir)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + // kilocode_change - MDM/enterprise-managed config is a trusted source + yield* merge(source, yield* loadFile(source, undefined, true), "global") } } // kilocode_change end @@ -1021,10 +1050,15 @@ export const layer = Layer.effect( if (managed) { yield* merge( managed.source, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), + yield* loadConfig( + managed.text, + { + dir: path.dirname(managed.source), + source: managed.source, + }, + undefined, + true, // kilocode_change - MDM-managed preferences are a trusted source + ), "global", ) } diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 8a1aa2cab08..661c80fd851 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -21,6 +21,9 @@ type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" escapeJson?: boolean // kilocode_change + // kilocode_change start - only trusted (user-owned) sources may reference files/env; project config cannot + trusted?: boolean + // kilocode_change end env?: Record } @@ -32,10 +35,32 @@ function dir(input: ParseSource) { return input.type === "path" ? path.dirname(input.path) : input.dir } +// kilocode_change start - a token is inert when its line is commented out with // +function commented(text: string, index: number) { + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + return text.slice(lineStart, index).trimStart().startsWith("//") +} +// kilocode_change end + /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" const escape = input.escapeJson ?? true // kilocode_change + // kilocode_change start - only trusted (user-owned) config may read files or environment variables. + // Untrusted project config could otherwise exfiltrate arbitrary local files via a provider apiKey. + if (!(input.trusted ?? false)) { + const active = Array.from(input.text.matchAll(/\{(?:env|file):[^}]+\}/g)).find( + (m) => !commented(input.text, m.index), + ) + if (active) { + throw new InvalidError({ + path: source(input), + message: `file and environment references are not allowed in project config: "${active[0]}"`, + }) + } + return input.text + } + // kilocode_change end let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { // kilocode_change start - reject server credentials instead of silently changing config semantics if (!ConfigVariableGuard.env(varName)) { @@ -58,9 +83,7 @@ export async function substitute(input: SubstituteInput) { const index = match.index out += text.slice(cursor, index) - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { + if (commented(text, index)) { out += token cursor = index + token.length continue diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 67412155eaa..3e48ec81510 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -138,8 +138,8 @@ export namespace KilocodeConfigOverlay { } export async function resolve(input: Input): Promise { - const local = await withAgents(await project(input), await projectDirs(input)) - const global = await withAgents(input.global, globalDirs()) + const local = await withAgents(await project(input), await projectDirs(input), false) // kilocode_change - project agents untrusted + const global = await withAgents(input.global, globalDirs(), true) // kilocode_change - global agents trusted const targets = { global: globalTarget(), project: await projectTarget(input), @@ -185,19 +185,20 @@ export namespace KilocodeConfigOverlay { return [Global.Path.config, path.join(Global.Path.home, ".kilocode"), path.join(Global.Path.home, ".kilo")] } - async function withAgents(input: Config.Info, dirs: string[]): Promise { + async function withAgents(input: Config.Info, dirs: string[], trusted: boolean): Promise { const [dir, ...rest] = dirs if (!dir) return input - if (!existsSync(dir)) return withAgents(input, rest) - const agent = await ConfigAgent.load(dir) + if (!existsSync(dir)) return withAgents(input, rest, trusted) + const agent = await ConfigAgent.load(dir, undefined, trusted) // kilocode_change const mode = await ConfigAgent.loadMode(dir) const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode }) - return withAgents(next, rest) + return withAgents(next, rest, trusted) } async function load(file: string): Promise { const text = await Bun.file(file).text() - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file }) + // kilocode_change - overlay reads project config files; {file:}/{env:} references are untrusted here + const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} return ConfigParse.schema(Config.Info, parsed, file) as Config.Info diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4b22d4bb87b..d34c928167b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -547,70 +547,60 @@ it.instance("prefers .kilo directory config over legacy .kilocode", () => ) // kilocode_change end -it.instance("handles environment variable substitution", () => +// kilocode_change start - project config is untrusted: {env:}/{file:} references must not resolve +it.instance("rejects environment variable substitution in project config", () => withProcessEnv( "TEST_VAR", "test-user", Effect.gen(function* () { const test = yield* TestInstance yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change + $schema: "https://app.kilo.ai/config.json", username: "{env:TEST_VAR}", }) const config = yield* Config.use.get() - expect(config.username).toBe("test-user") + expect(config.username).not.toBe("test-user") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ), ) -it.instance("preserves env variables when adding $schema to config", () => - withProcessEnv( - "PRESERVE_VAR", - "secret_value", - Effect.gen(function* () { - const test = yield* TestInstance - // Config without $schema - should trigger auto-add - yield* AppFileSystem.use.writeWithDirs( - path.join(test.directory, "kilo.json"), // kilocode_change - JSON.stringify({ username: "{env:PRESERVE_VAR}" }), - ) - const config = yield* Config.use.get() - expect(config.username).toBe("secret_value") - - // Read the file to verify the env variable was preserved - const content = yield* AppFileSystem.use.readFileString(path.join(test.directory, "kilo.json")) // kilocode_change - expect(content).toContain("{env:PRESERVE_VAR}") - expect(content).not.toContain("secret_value") - expect(content).toContain("$schema") - }), - ), -) - -it.instance("handles file inclusion substitution", () => +it.instance("does not leak file contents via {file:} in project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "top-secret") yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change + $schema: "https://app.kilo.ai/config.json", username: "{file:included.txt}", }) const config = yield* Config.use.get() - expect(config.username).toBe("test-user") + expect(config.username).not.toBe("top-secret") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ) -it.instance("handles file inclusion with replacement tokens", () => +it.instance("rejects {file:} exfiltration of provider apiKey in project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "secret.txt"), "leaked-credential") yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change - username: "{file:included.md}", + $schema: "https://app.kilo.ai/config.json", + provider: { + "openai-compatible": { + options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:secret.txt}" }, + models: { "test-model": { name: "Test Model" } }, + }, + }, }) const config = yield* Config.use.get() - expect(config.username).toBe("const out = await Bun.$`echo hi`") + expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ) +// kilocode_change end const accountTokenIt = configIt({ account: Layer.mock(Account.Service)({ @@ -1758,8 +1748,11 @@ envIsolationWellKnown.it.instance( Effect.gen(function* () { process.env.TEST_TOKEN = "preexisting-token" const config = yield* Config.use.get() + // The well-known header (trusted source) resolves the auth-provided token... expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token") - expect(config.username).toBe("test-token") + // ...but the project config token is untrusted and must not be substituted. + expect(config.username).not.toBe("test-token") + // ...and the auth env used for substitution must not leak into the real process env. expect(process.env.TEST_TOKEN).toBe("preexisting-token") }), { git: true, config: { username: "{env:TEST_TOKEN}" } }, diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 1506e0e84fa..ff75384cd32 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -678,7 +678,30 @@ it.instance("does not derive tui path from KILO_CONFIG", () => ), ) -it.instance("applies env and file substitutions in tui.json", () => +it.instance("applies env and file substitutions in global tui.json", () => + withCleanState( + withEnv( + "TUI_THEME_TEST", + "env-theme", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + // Global config is trusted, so {env:}/{file:} references resolve. + yield* fs.writeFileString(path.join(Global.Path.config, "keybind.txt"), "ctrl+q") + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { + theme: "{env:TUI_THEME_TEST}", + keybinds: { app_exit: "{file:keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("env-theme") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + }), + ), + ), +) + +it.instance("does not substitute env or file references in untrusted project tui.json", () => withCleanState( withEnv( "TUI_THEME_TEST", @@ -692,9 +715,10 @@ it.instance("applies env and file substitutions in tui.json", () => keybinds: { app_exit: "{file:keybind.txt}" }, }) + // Project tui config with references is rejected and skipped, so nothing is substituted. const config = yield* getTuiConfig(test.directory) - expect(config.theme).toBe("env-theme") - expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.theme).not.toBe("env-theme") + expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") }), ), ), @@ -705,9 +729,10 @@ it.instance("applies file substitutions when first identical token is in a comme Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme") + // Global config is trusted, so the second (uncommented) reference resolves. + yield* fs.writeFileString(path.join(Global.Path.config, "theme.txt"), "resolved-theme") yield* fs.writeFileString( - path.join(test.directory, "tui.jsonc"), + path.join(Global.Path.config, "tui.jsonc"), `{ // "theme": "{file:theme.txt}", "theme": "{file:theme.txt}" diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index ac94471b0c8..c8131c0d25c 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -6,32 +6,67 @@ import { ConfigVariable } from "@/config/variable" import { InvalidError } from "@/config/error" const source = { type: "virtual" as const, source: "test", dir: process.cwd() } +const trusted = { ...source, trusted: true } + +test("rejects file references in untrusted (project) config", async () => { + await expect( + ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" }), + ).rejects.toBeInstanceOf(InvalidError) +}) + +test("rejects file references when trusted is omitted (secure by default)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-untrusted-")) + const file = path.join(dir, "secret") + await fs.writeFile(file, "top-secret") + try { + await expect(ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).rejects.toBeInstanceOf( + InvalidError, + ) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) + +test("rejects environment references in untrusted (project) config", async () => { + await expect( + ConfigVariable.substitute({ ...source, text: "value={env:SAFE_VALUE}", env: { SAFE_VALUE: "allowed" } }), + ).rejects.toBeInstanceOf(InvalidError) +}) + +test("leaves untrusted text without references untouched", async () => { + expect(await ConfigVariable.substitute({ ...source, text: "plain value" })).toBe("plain value") +}) + +test("ignores commented-out references in untrusted config", async () => { + const text = ["// {file:/etc/passwd}", "// {env:SAFE_VALUE}"].join("\n") + expect(await ConfigVariable.substitute({ ...source, text })).toBe(text) +}) test("rejects server credential environment substitutions", async () => { await expect( ConfigVariable.substitute({ - ...source, + ...trusted, text: "password={env:KILO_SERVER_PASSWORD}", env: { KILO_SERVER_PASSWORD: "secret" }, }), ).rejects.toBeInstanceOf(InvalidError) }) -test("continues to substitute ordinary environment variables", async () => { +test("continues to substitute ordinary environment variables when trusted", async () => { const result = await ConfigVariable.substitute({ - ...source, + ...trusted, text: "value={env:SAFE_VALUE}", env: { SAFE_VALUE: "allowed" }, }) expect(result).toBe("value=allowed") }) -test("reads ordinary file substitutions on every platform", async () => { +test("reads ordinary file substitutions on every platform when trusted", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-file-")) const file = path.join(dir, "value") await fs.writeFile(file, "allowed") try { - expect(await ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).toBe("allowed") + expect(await ConfigVariable.substitute({ ...trusted, text: `{file:${file}}` })).toBe("allowed") } finally { await fs.rm(dir, { recursive: true, force: true }) } @@ -40,7 +75,7 @@ test("reads ordinary file substitutions on every platform", async () => { test.skipIf(process.platform !== "linux")("does not substitute process environment files", async () => { await expect( ConfigVariable.substitute({ - ...source, + ...trusted, text: "{file:/proc/self/environ}", }), ).rejects.toBeInstanceOf(InvalidError) @@ -51,7 +86,9 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi const link = path.join(dir, "value") await fs.symlink("/proc/self/environ", link) try { - await expect(ConfigVariable.substitute({ ...source, text: `{file:${link}}` })).rejects.toBeInstanceOf(InvalidError) + await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf( + InvalidError, + ) } finally { await fs.rm(dir, { recursive: true, force: true }) } From ac62aef00c1a15048927bd8baae917fa31742003 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:04:40 +0200 Subject: [PATCH 009/107] chore(cli): annotate kilocode_change markers on config trust changes --- .../opencode/src/cli/cmd/tui/config/tui.ts | 30 ++++++++----------- packages/opencode/src/config/variable.ts | 2 ++ packages/opencode/test/config/tui.test.ts | 5 +++- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index d9d136dc2f3..8a74185a373 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,11 +112,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - const load = ( - text: string, - configFilepath: string, - trusted: boolean, - ): Effect.Effect => // kilocode_change - trusted param + // kilocode_change start - trusted controls whether tui config may resolve {file:}/{env:} tokens + const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => + // kilocode_change end Effect.gen(function* () { const expanded = yield* Effect.promise(() => // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens @@ -154,10 +152,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - const loadFile = ( - filepath: string, - trusted: boolean, - ): Effect.Effect => // kilocode_change - trusted param + // kilocode_change start - trusted param threaded to load + const loadFile = (filepath: string, trusted: boolean): Effect.Effect => + // kilocode_change end Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. // Matches how parse/schema/plugin failures in load() are handled — every @@ -177,16 +174,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath, trusted) + return yield* load(text, filepath, trusted) // kilocode_change }) - const mergeFile = ( - acc: Acc, - file: string, - trusted: boolean, // kilocode_change - trusted param - ) => + // kilocode_change start - trusted param threaded to loadFile + const mergeFile = (acc: Acc, file: string, trusted: boolean) => + // kilocode_change end Effect.gen(function* () { - const data = yield* loadFile(file, trusted) + const data = yield* loadFile(file, trusted) // kilocode_change if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -244,11 +239,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // kilocode_change end for (const dir of dirs) { - // kilocode_change - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted + // kilocode_change start - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted const trusted = pluginScope(dir, ctx) === "global" for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { yield* mergeFile(acc, file, trusted) } + // kilocode_change end } const keybinds = { ...acc.result.keybinds } diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 661c80fd851..3c05b27d3db 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -83,11 +83,13 @@ export async function substitute(input: SubstituteInput) { const index = match.index out += text.slice(cursor, index) + // kilocode_change start - skip tokens on commented-out lines if (commented(text, index)) { out += token cursor = index + token.length continue } + // kilocode_change end let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") if (filePath.startsWith("~/")) { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index ff75384cd32..a1e611257c8 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -678,6 +678,7 @@ it.instance("does not derive tui path from KILO_CONFIG", () => ), ) +// kilocode_change start - trusted global config substitutes; untrusted project config does not it.instance("applies env and file substitutions in global tui.json", () => withCleanState( withEnv( @@ -723,16 +724,18 @@ it.instance("does not substitute env or file references in untrusted project tui ), ), ) +// kilocode_change end it.instance("applies file substitutions when first identical token is in a commented line", () => withCleanState( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - // Global config is trusted, so the second (uncommented) reference resolves. + // kilocode_change start - global config is trusted, so the second (uncommented) reference resolves yield* fs.writeFileString(path.join(Global.Path.config, "theme.txt"), "resolved-theme") yield* fs.writeFileString( path.join(Global.Path.config, "tui.jsonc"), + // kilocode_change end `{ // "theme": "{file:theme.txt}", "theme": "{file:theme.txt}" From 7d312ee2caff654ba420eb957606ddf7309ec505 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:05:05 +0200 Subject: [PATCH 010/107] chore(cli): wrap tui substitute call in kilocode_change block --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 8a74185a373..bbb96d5ce8f 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -116,9 +116,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => // kilocode_change end Effect.gen(function* () { - const expanded = yield* Effect.promise(() => - // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + const expanded = yield* Effect.promise( + () => + // kilocode_change start - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + // kilocode_change end ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info From 47da23170d36db29fda3bccd2274017b6bb8f9b7 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:06:06 +0200 Subject: [PATCH 011/107] chore(cli): stabilize kilocode_change block around tui substitute --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index bbb96d5ce8f..2597068d22c 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -116,12 +116,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => // kilocode_change end Effect.gen(function* () { - const expanded = yield* Effect.promise( - () => - // kilocode_change start - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), - // kilocode_change end + // kilocode_change start - only trusted tui config resolves {file:}/{env:} tokens + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), ) + // kilocode_change end const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json From 6dfbc54c855a3209b26b3e688a83277510d636fd Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:37:42 +0200 Subject: [PATCH 012/107] refactor(cli): root-scope project {file:} instead of blocking; keep {env:} block Adopt the file-scoping approach from #11883: untrusted project config may still read {file:...} as long as the target stays inside the project root (absolute paths, ../ traversal, and symlink escapes are rejected via realpath). Keep {env:} fully blocked in project config (no safe scoped form). Make the /proc/self/fd guard cross-platform. Thread fileScope through config.ts, agent.ts, tui.ts, and overlay.ts. Update docs and changeset. --- .changeset/config-file-substitution-trust.md | 2 +- .../code-with-ai/agents/custom-models.md | 6 +- .../pages/code-with-ai/platforms/cli.md | 4 + .../opencode/src/cli/cmd/tui/config/tui.ts | 31 +++++--- packages/opencode/src/config/agent.ts | 9 ++- packages/opencode/src/config/config.ts | 26 +++++-- packages/opencode/src/config/variable.ts | 46 +++++++---- .../opencode/src/kilocode/config/overlay.ts | 28 ++++--- .../opencode/src/kilocode/config/variable.ts | 32 ++++++-- packages/opencode/test/config/config.test.ts | 76 ++++++++++++++++--- packages/opencode/test/config/tui.test.ts | 41 ++++++++-- .../test/kilocode/config/variable.test.ts | 46 +++++++---- 12 files changed, 264 insertions(+), 83 deletions(-) diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md index 3ae377de41e..3bbc2bfd358 100644 --- a/.changeset/config-file-substitution-trust.md +++ b/.changeset/config-file-substitution-trust.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Prevent project config from reading local files or environment variables via `{file:...}` / `{env:...}` references. These references now resolve only in trusted user-owned config (global config, `KILO_CONFIG`, well-known org config), closing a path where a malicious project could exfiltrate local files through a provider API key. +Harden config credential substitution against untrusted project config. Environment references (`{env:VAR}`) now resolve only in trusted config (global config, `KILO_CONFIG`, `KILO_CONFIG_CONTENT`, and org/MDM-managed config); a project-committed `kilo.json` / `opencode.json` can no longer use them. File references (`{file:...}`) still work in project config but are confined to the project root, so absolute paths, `../` traversal, and symlink escapes are rejected. This closes a path where a malicious repository could exfiltrate local secrets to an attacker-controlled `baseURL`. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 103043b6443..3793825dd30 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -385,8 +385,12 @@ You can also set options that apply to all models from a provider: | Option | Type | Description | |---|---|---| -| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | +| `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | + +{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} +`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root (absolute paths, `../` traversal, and symlink escapes are rejected). Keep provider credentials in your global config. +{% /callout %} | `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 9e68a71bddb..dcf1cf0eb5b 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -464,6 +464,10 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab } ``` +{% callout type="warning" title="Only works in trusted config" %} +`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root. +{% /callout %} + For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). ## Interactive Mode diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 2597068d22c..035db497f2f 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,13 +112,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - // kilocode_change start - trusted controls whether tui config may resolve {file:}/{env:} tokens - const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => + // kilocode_change start - trusted gates {env:}; fileScope confines untrusted {file:} reads + const load = ( + text: string, + configFilepath: string, + trusted: boolean, + fileScope?: ConfigVariable.FileScope, + ): Effect.Effect => // kilocode_change end Effect.gen(function* () { - // kilocode_change start - only trusted tui config resolves {file:}/{env:} tokens + // kilocode_change start - only trusted tui config resolves {env:}; untrusted {file:} confined to fileScope const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted, fileScope }), ) // kilocode_change end const data = ConfigParse.jsonc(expanded, configFilepath) @@ -153,8 +158,8 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - // kilocode_change start - trusted param threaded to load - const loadFile = (filepath: string, trusted: boolean): Effect.Effect => + // kilocode_change start - trusted + fileScope threaded to load + const loadFile = (filepath: string, trusted: boolean, fileScope?: ConfigVariable.FileScope): Effect.Effect => // kilocode_change end Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. @@ -175,14 +180,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath, trusted) // kilocode_change + return yield* load(text, filepath, trusted, fileScope) // kilocode_change }) - // kilocode_change start - trusted param threaded to loadFile - const mergeFile = (acc: Acc, file: string, trusted: boolean) => + // kilocode_change start - trusted + fileScope threaded to loadFile + const mergeFile = (acc: Acc, file: string, trusted: boolean, fileScope?: ConfigVariable.FileScope) => // kilocode_change end Effect.gen(function* () { - const data = yield* loadFile(file, trusted) // kilocode_change + const data = yield* loadFile(file, trusted, fileScope) // kilocode_change if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -227,7 +232,8 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* mergeFile(acc, file, false) // kilocode_change - project config is untrusted + // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to the project dir + yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) } // kilocode_change start - load tui.json from supported Kilo config directories @@ -242,8 +248,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { // kilocode_change start - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted const trusted = pluginScope(dir, ctx) === "global" + const fileScope = trusted ? undefined : { root: ctx.directory, source: dir } for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* mergeFile(acc, file, trusted) + yield* mergeFile(acc, file, trusted, fileScope) } // kilocode_change end } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index aca34884880..8888b6b9cdd 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -134,8 +134,8 @@ export const Info = AgentSchema.pipe( ).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type -// kilocode_change start - trusted controls whether agent prompts may resolve {file:}/{env:} tokens -export async function load(dir: string, warnings?: Warning[], trusted?: boolean) { +// kilocode_change start - trusted gates {env:}; fileScope confines untrusted agent prompt {file:} reads +export async function load(dir: string, warnings?: Warning[], trusted?: boolean, fileScope?: ConfigVariable.FileScope) { // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -176,7 +176,10 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean) source: item, missing: "empty", escapeJson: false, - trusted, // kilocode_change - project agents must not resolve file/env references + // kilocode_change start - project agents: no env, files confined to fileScope.root + trusted, + fileScope, + // kilocode_change end }) const config = { name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 48484f12692..f70f99b3752 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -568,14 +568,17 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, - trusted?: boolean, // kilocode_change - only user-owned config may resolve {file:}/{env:} tokens + // kilocode_change start - trusted allows {env:}; fileScope confines untrusted {file:} reads to a root + trusted?: boolean, + fileScope?: ConfigVariable.FileScope, + // kilocode_change end ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options - ? { text, type: "path", path: options.path, env, trusted } // kilocode_change - : { text, type: "virtual", ...options, env, trusted }, // kilocode_change + ? { text, type: "path", path: options.path, env, trusted, fileScope } // kilocode_change + : { text, type: "virtual", ...options, env, trusted, fileScope }, // kilocode_change ), ) const parsed = ConfigParse.jsonc(expanded, source) @@ -597,11 +600,12 @@ export const layer = Layer.effect( filepath: string, env?: Record, trusted?: boolean, // kilocode_change + fileScope?: ConfigVariable.FileScope, // kilocode_change ) { log.info("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env, trusted) // kilocode_change + return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change }) let globalStamp = "" // kilocode_change @@ -709,6 +713,8 @@ export const layer = Layer.effect( function* (ctx: InstanceContext) { // kilocode_change start - warning accumulator and legacy Kilo config const warnings: Warning[] = [] + // Untrusted project config may only read files inside this root (worktree, or directory for non-git projects). + const projectRoot = ctx.worktree === "/" ? ctx.directory : ctx.worktree const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} @@ -860,8 +866,8 @@ export const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - // kilocode_change - project config is untrusted: {file:}/{env:} tokens are rejected - yield* loadFile(file, authEnv, false).pipe( + // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to projectRoot + yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -900,13 +906,17 @@ export const layer = Layer.effect( // kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones const dirScope = scope ?? (yield* pluginScopeForSource(dir)) const dirTrusted = dirScope === "global" + // kilocode_change - untrusted config dirs confine {file:} reads to projectRoot + const dirFileScope = dirTrusted ? undefined : { root: projectRoot, source: dir } if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) + // kilocode_change - untrusted config dirs confine {file:} reads to projectRoot + const fileScope = dirTrusted ? undefined : { root: projectRoot, source } yield* merge( source, - yield* loadFile(source, authEnv, dirTrusted).pipe( + yield* loadFile(source, authEnv, dirTrusted, fileScope).pipe( // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) @@ -954,7 +964,7 @@ export const layer = Layer.effect( ) result.agent = mergeDeep( result.agent ?? {}, - yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted)), // kilocode_change + yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope)), // kilocode_change ) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) // kilocode_change end diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 3c05b27d3db..d39a51b26f9 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -17,12 +17,17 @@ type ParseSource = dir: string } +// kilocode_change start +export type FileScope = ConfigVariableGuard.FileScope +// kilocode_change end + type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" escapeJson?: boolean // kilocode_change - // kilocode_change start - only trusted (user-owned) sources may reference files/env; project config cannot + // kilocode_change start - trust gates {env:}; untrusted project config may only read files inside fileScope.root trusted?: boolean + fileScope?: ConfigVariableGuard.FileScope // kilocode_change end env?: Record } @@ -46,23 +51,33 @@ function commented(text: string, index: number) { export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" const escape = input.escapeJson ?? true // kilocode_change - // kilocode_change start - only trusted (user-owned) config may read files or environment variables. - // Untrusted project config could otherwise exfiltrate arbitrary local files via a provider apiKey. - if (!(input.trusted ?? false)) { - const active = Array.from(input.text.matchAll(/\{(?:env|file):[^}]+\}/g)).find( - (m) => !commented(input.text, m.index), - ) + // kilocode_change start - untrusted (project) config cannot read environment variables. {env:} has no safe + // scoped form, so it is rejected outright; {file:} is allowed but confined to fileScope.root below. + const trusted = input.trusted ?? false + if (!trusted) { + const active = Array.from(input.text.matchAll(/\{env:[^}]+\}/g)).find((m) => !commented(input.text, m.index)) if (active) { throw new InvalidError({ path: source(input), - message: `file and environment references are not allowed in project config: "${active[0]}"`, + message: `environment references are not allowed in project config: "${active[0]}"`, }) } - return input.text + // Secure default: untrusted config must supply a fileScope to read files. Without one, {file:} is rejected + // rather than allowed unrestricted, so a caller that forgets the scope cannot reopen the exfiltration path. + if (!input.fileScope) { + const file = Array.from(input.text.matchAll(/\{file:[^}]+\}/g)).find((m) => !commented(input.text, m.index)) + if (file) { + throw new InvalidError({ + path: source(input), + message: `file references are not allowed in project config: "${file[0]}"`, + }) + } + } } // kilocode_change end - let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - // kilocode_change start - reject server credentials instead of silently changing config semantics + let text = input.text.replace(/\{env:([^}]+)\}/g, (match, varName, offset: number) => { + // kilocode_change start - leave commented tokens literal; reject server credentials + if (commented(input.text, offset)) return match if (!ConfigVariableGuard.env(varName)) { throw new InvalidError({ path: source(input), message: `blocked environment reference: "{env:${varName}}"` }) } @@ -97,9 +112,14 @@ export async function substitute(input: SubstituteInput) { } const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - // kilocode_change start - validate and read one opened file to prevent credential substitution races + // kilocode_change start - validate and read one opened file to prevent credential substitution races; + // untrusted config passes a fileScope so reads are confined to the project root. const fileContent = ( - await ConfigVariableGuard.read(resolvedPath, Filesystem.readText).catch((error: NodeJS.ErrnoException) => { + await ConfigVariableGuard.read( + resolvedPath, + Filesystem.readText, + input.fileScope && { ...input.fileScope, token }, + ).catch((error: NodeJS.ErrnoException) => { if (missing === "empty") return "" const errMsg = `bad file reference: "${token}"` diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 3e48ec81510..ef6aae17ba7 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -119,7 +119,9 @@ export namespace KilocodeConfigOverlay { export async function project(input: { directory: string; worktree?: string }): Promise { const found = await projectFiles(input) - const configs = await Promise.all(found.map(load)) + // kilocode_change - project config is untrusted; confine {file:} reads to the project root + const root = input.worktree && input.worktree !== "/" ? input.worktree : input.directory + const configs = await Promise.all(found.map((file) => load(file, { root, source: file }))) return configs.reduce((result, cfg) => KilocodeConfig.mergeConfig(result, cfg), {} as Config.Info) } @@ -138,8 +140,11 @@ export namespace KilocodeConfigOverlay { } export async function resolve(input: Input): Promise { - const local = await withAgents(await project(input), await projectDirs(input), false) // kilocode_change - project agents untrusted - const global = await withAgents(input.global, globalDirs(), true) // kilocode_change - global agents trusted + // kilocode_change start - project agents untrusted, {file:} confined to the project root; global agents trusted + const root = input.worktree && input.worktree !== "/" ? input.worktree : input.directory + const local = await withAgents(await project(input), await projectDirs(input), false, root) + const global = await withAgents(input.global, globalDirs(), true) + // kilocode_change end const targets = { global: globalTarget(), project: await projectTarget(input), @@ -185,20 +190,23 @@ export namespace KilocodeConfigOverlay { return [Global.Path.config, path.join(Global.Path.home, ".kilocode"), path.join(Global.Path.home, ".kilo")] } - async function withAgents(input: Config.Info, dirs: string[], trusted: boolean): Promise { + // kilocode_change start - root confines untrusted agent {file:} reads + async function withAgents(input: Config.Info, dirs: string[], trusted: boolean, root?: string): Promise { const [dir, ...rest] = dirs if (!dir) return input - if (!existsSync(dir)) return withAgents(input, rest, trusted) - const agent = await ConfigAgent.load(dir, undefined, trusted) // kilocode_change + if (!existsSync(dir)) return withAgents(input, rest, trusted, root) + const fileScope = trusted || !root ? undefined : { root, source: dir } + const agent = await ConfigAgent.load(dir, undefined, trusted, fileScope) const mode = await ConfigAgent.loadMode(dir) const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode }) - return withAgents(next, rest, trusted) + return withAgents(next, rest, trusted, root) } + // kilocode_change end - async function load(file: string): Promise { + async function load(file: string, fileScope?: ConfigVariable.FileScope): Promise { const text = await Bun.file(file).text() - // kilocode_change - overlay reads project config files; {file:}/{env:} references are untrusted here - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false }) + // kilocode_change - overlay reads project config files: {env:} rejected, {file:} confined to fileScope.root + const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false, fileScope }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} return ConfigParse.schema(Config.Info, parsed, file) as Config.Info diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index 6575a8d858b..c1bbf8d57e9 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -1,21 +1,43 @@ import fs from "node:fs/promises" import { realpathSync } from "node:fs" +import path from "node:path" export namespace ConfigVariableGuard { + export type FileScope = { + root: string + source: string + } + const secret = new Set(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"]) export function env(name: string) { return !secret.has(name.toUpperCase()) } - export async function read(path: string, load: (path: string) => Promise) { - if (process.platform !== "linux") return load(path) - const file = await fs.open(path, "r") + function inside(root: string, file: string) { + const rel = path.relative(root, file) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) + } + + function check(file: string, token: string, scope?: FileScope) { + if (!scope) return + const root = realpathSync.native(scope.root) + if (inside(root, file)) return + throw new Error(`blocked file reference outside project config scope: "${token}"`) + } + + export async function read( + filePath: string, + load: (path: string) => Promise, + scope?: FileScope & { token?: string }, + ) { + const file = await fs.open(filePath, "r") try { - const target = `/proc/self/fd/${file.fd}` + const target = process.platform === "linux" ? `/proc/self/fd/${file.fd}` : filePath const resolved = realpathSync.native(target) + check(resolved, scope?.token ?? "{file:...}", scope) if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") - return await load(target) + return await load(process.platform === "linux" ? target : resolved) } finally { await file.close() } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index d34c928167b..c81de67060b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -547,7 +547,7 @@ it.instance("prefers .kilo directory config over legacy .kilocode", () => ) // kilocode_change end -// kilocode_change start - project config is untrusted: {env:}/{file:} references must not resolve +// kilocode_change start - project config is untrusted: {env:} rejected; {file:} confined to the project root it.instance("rejects environment variable substitution in project config", () => withProcessEnv( "TEST_VAR", @@ -566,40 +566,94 @@ it.instance("rejects environment variable substitution in project config", () => ), ) -it.instance("does not leak file contents via {file:} in project config", () => +it.instance("allows {file:} that stays inside the project root", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "top-secret") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project") yield* writeConfigEffect(test.directory, { $schema: "https://app.kilo.ai/config.json", username: "{file:included.txt}", }) const config = yield* Config.use.get() - expect(config.username).not.toBe("top-secret") - const issues = yield* Config.Service.use((svc) => svc.warnings()) - expect(issues.length).toBeGreaterThan(0) + expect(config.username).toBe("in-project") }), ) -it.instance("rejects {file:} exfiltration of provider apiKey in project config", () => +it.instance("rejects {file:} that reads an absolute path from project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "secret.txt"), "leaked-credential") + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:/etc/passwd}", + }) + const config = yield* Config.use.get() + expect(config.username ?? "").not.toContain("root:") + }), +) + +it.instance("rejects {file:} that escapes the project root with parent directories", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "secret.txt") + yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret") + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:../secret.txt}", + }) + const config = yield* Config.use.get() + expect(config.username).not.toBe("outside-secret") + }), +) + +it.instance("rejects {file:} that escapes the project root through a symlink", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "secret.txt") + const link = path.join(test.directory, "secret-link") + yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret") + yield* Effect.promise(() => fs.symlink(outside, link)) + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:secret-link}", + }) + const config = yield* Config.use.get() + expect(config.username).not.toBe("outside-secret") + }), +) + +it.instance("blocks provider apiKey {file:} exfiltration that escapes the project root", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "creds.txt") + yield* AppFileSystem.use.writeWithDirs(outside, "leaked-credential") yield* writeConfigEffect(test.directory, { $schema: "https://app.kilo.ai/config.json", provider: { "openai-compatible": { - options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:secret.txt}" }, + options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:../creds.txt}" }, models: { "test-model": { name: "Test Model" } }, }, }, }) const config = yield* Config.use.get() expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential") - const issues = yield* Config.Service.use((svc) => svc.warnings()) - expect(issues.length).toBeGreaterThan(0) }), ) + +it.instance("still allows global config to read absolute files", () => + withGlobalConfig({}, ({ dir }) => + Effect.gen(function* () { + const secret = path.join(dir, "secret.txt") + yield* AppFileSystem.use.writeWithDirs(secret, "global-secret") + yield* writeConfigEffect(dir, { + $schema: "https://app.kilo.ai/config.json", + username: `{file:${secret}}`, + }) + const config = yield* Config.use.get() + expect(config.username).toBe("global-secret") + }), + ), +) // kilocode_change end const accountTokenIt = configIt({ diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a1e611257c8..c58fbc4285f 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -702,7 +702,7 @@ it.instance("applies env and file substitutions in global tui.json", () => ), ) -it.instance("does not substitute env or file references in untrusted project tui.json", () => +it.instance("does not substitute env references in untrusted project tui.json", () => withCleanState( withEnv( "TUI_THEME_TEST", @@ -710,20 +710,51 @@ it.instance("does not substitute env or file references in untrusted project tui Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q") yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "{env:TUI_THEME_TEST}", - keybinds: { app_exit: "{file:keybind.txt}" }, }) - // Project tui config with references is rejected and skipped, so nothing is substituted. + // {env:} in project config is rejected, so the file is skipped and the theme is not applied. const config = yield* getTuiConfig(test.directory) expect(config.theme).not.toBe("env-theme") - expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") }), ), ), ) + +it.instance("applies in-project file references in project tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + // {file:} that stays inside the project root is allowed even in untrusted project config. + yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { app_exit: "{file:keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + }), + ), +) + +it.instance("rejects project tui.json file references that escape the project root", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "keybind.txt") + yield* fs.writeFileString(outside, "ctrl+q") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { app_exit: "{file:../keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") + }), + ), +) // kilocode_change end it.instance("applies file substitutions when first identical token is in a commented line", () => diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index c8131c0d25c..9e792dba764 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -8,22 +8,42 @@ import { InvalidError } from "@/config/error" const source = { type: "virtual" as const, source: "test", dir: process.cwd() } const trusted = { ...source, trusted: true } -test("rejects file references in untrusted (project) config", async () => { - await expect( - ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" }), - ).rejects.toBeInstanceOf(InvalidError) +test("rejects file references in untrusted config without a fileScope", async () => { + await expect(ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" })).rejects.toBeInstanceOf( + InvalidError, + ) }) -test("rejects file references when trusted is omitted (secure by default)", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-untrusted-")) - const file = path.join(dir, "secret") +test("rejects untrusted file references that escape the scope root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-root-")) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-outside-")) + const file = path.join(outside, "secret") await fs.writeFile(file, "top-secret") try { - await expect(ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).rejects.toBeInstanceOf( - InvalidError, - ) + await expect( + ConfigVariable.substitute({ ...source, text: `{file:${file}}`, fileScope: { root, source: "test" } }), + ).rejects.toBeInstanceOf(InvalidError) } finally { - await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(root, { recursive: true, force: true }) + await fs.rm(outside, { recursive: true, force: true }) + } +}) + +test("allows untrusted file references that stay inside the scope root", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-inside-"))) + const file = path.join(root, "value") + await fs.writeFile(file, "allowed") + try { + expect( + await ConfigVariable.substitute({ + ...source, + dir: root, + text: "{file:value}", + fileScope: { root, source: path.join(root, "kilo.json") }, + }), + ).toBe("allowed") + } finally { + await fs.rm(root, { recursive: true, force: true }) } }) @@ -86,9 +106,7 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi const link = path.join(dir, "value") await fs.symlink("/proc/self/environ", link) try { - await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf( - InvalidError, - ) + await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf(InvalidError) } finally { await fs.rm(dir, { recursive: true, force: true }) } From ebb0029d88e2bb061c807c4d6fdca489b93e3c37 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:38:13 +0200 Subject: [PATCH 013/107] chore(cli): inline kilocode_change marker on project tui mergeFile --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 035db497f2f..8289b559b9a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -232,8 +232,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to the project dir - yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) + yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) // kilocode_change - untrusted, {file:} confined to project } // kilocode_change start - load tui.json from supported Kilo config directories From 6ad16cd86924cc5400bf1a02ec1007d9f896559c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 16:32:25 +0200 Subject: [PATCH 014/107] feat(vscode): prewarm microphone capture before showing voice input as recording --- .changeset/warm-speech-capture.md | 5 + .../kilo-vscode/src/services/input-tools.ts | 6 + .../kilo-vscode/src/speech-to-text/capture.ts | 24 +++- .../tests/unit/speech-to-text-prewarm.test.ts | 104 ++++++++++++++++++ .../tests/unit/use-speech-to-text.test.ts | 56 ++++++++++ .../agent-manager/AgentManagerApp.tsx | 2 + .../webview-ui/diff-viewer/DiffViewerApp.tsx | 2 + packages/kilo-vscode/webview-ui/src/App.tsx | 2 + .../speech-to-text/SpeechToTextButton.tsx | 19 +++- .../speech-to-text/SpeechToTextPrewarm.tsx | 20 ++++ .../speech-to-text/useSpeechToText.ts | 7 +- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + .../src/types/messages/webview-messages.ts | 5 + 32 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 .changeset/warm-speech-capture.md create mode 100644 packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/speech-to-text/SpeechToTextPrewarm.tsx diff --git a/.changeset/warm-speech-capture.md b/.changeset/warm-speech-capture.md new file mode 100644 index 00000000000..64d9be64cc6 --- /dev/null +++ b/.changeset/warm-speech-capture.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Wait for microphone capture to start before showing voice input as recording. diff --git a/packages/kilo-vscode/src/services/input-tools.ts b/packages/kilo-vscode/src/services/input-tools.ts index 7d722b684ed..23479bbf208 100644 --- a/packages/kilo-vscode/src/services/input-tools.ts +++ b/packages/kilo-vscode/src/services/input-tools.ts @@ -1,6 +1,7 @@ import type { KiloConnectionService } from "./cli-backend/connection-service" import { routeAutocompleteMessage } from "./autocomplete/settings" import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler" +import { prewarmSpeechCapture } from "../speech-to-text/capture" type Msg = { type: string @@ -18,6 +19,11 @@ type Ctx = { export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise { if (await routeAutocompleteMessage(message, ctx.post)) return true + if (message.type === "speechToTextPrewarm") { + void prewarmSpeechCapture().catch((err) => console.warn("[Kilo New] Speech capture prewarm failed:", err)) + return true + } + if (message.type === "speechToTextStart") { if (!message.requestId) return true handleSpeechToTextStart( diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index 03efa67aaff..a2d61885bc7 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -36,13 +36,18 @@ type Args = { let active: Recording | undefined let starting: string | undefined +let ffmpeg: Promise | undefined + +export async function prewarmSpeechCapture(): Promise { + await resolveFFmpeg() +} export async function startSpeechCapture(input: Input): Promise { if (active || starting) throw new Error("Speech recording is already in progress") starting = input.requestId try { - const bin = await findFFmpeg() + const bin = await resolveFFmpeg() const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`) const state = await startWithArgs(bin, file, input, await inputArgSets(bin)) return !state.stopped @@ -229,6 +234,23 @@ function requireActive(requestId: string): Recording { return active } +async function resolveFFmpeg(): Promise { + if (ffmpeg) { + const bin = await ffmpeg + if (!path.isAbsolute(bin) || existsSync(bin)) return bin + ffmpeg = undefined + } + + const task = findFFmpeg() + ffmpeg = task + try { + return await task + } catch (err) { + if (ffmpeg === task) ffmpeg = undefined + throw err + } +} + async function findFFmpeg(): Promise { const paths = [ process.env.KILO_FFMPEG_PATH, diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts new file mode 100644 index 00000000000..48288a0c532 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test" +import path from "node:path" + +// Run from webview-ui so the child transpiles JSX with solid-js (its tsconfig sets +// jsxImportSource: solid-js). The package root tsconfig has no jsx setting, which makes +// bun fall back to react/jsx-dev-runtime and fail whenever react isn't hoisted nearby. +const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui") + +// solid-js effects only run under the browser export condition, so the component has to be +// exercised in a child process resolved with `--conditions=browser`. The child prints an +// explicit PASS/FAIL sentinel: a FAIL means the prewarm logic is wrong (fail immediately), +// while any other non-zero exit is a transient spawn failure under load and is retried so +// the suite stays deterministic. +const PASS = "PREWARM_PASS" +const FAIL = "PREWARM_FAIL:" + +const SCRIPT = ` + import { Window } from "happy-dom" + + const window = new Window() + globalThis.window = window + globalThis.document = window.document + globalThis.Node = window.Node + + const sent = [] + globalThis.acquireVsCodeApi = () => ({ + postMessage: (message) => sent.push(message), + getState: () => undefined, + setState: () => {}, + }) + + const { createComponent, createSignal } = await import("solid-js") + const { render } = await import("solid-js/web") + const { ConfigContext } = await import("./src/context/config.tsx") + const { ProviderContext } = await import("./src/context/provider.tsx") + const { SpeechToTextPrewarm } = await import( + "./src/components/speech-to-text/SpeechToTextPrewarm.tsx" + ) + + const fail = (reason) => { + console.log("${FAIL}" + reason) + process.exit(2) + } + + const [config, setConfig] = createSignal({ disabled_providers: ["kilo"] }) + const [auth, setAuth] = createSignal({}) + const root = document.createElement("div") + const dispose = render( + () => + createComponent(ProviderContext.Provider, { + value: { authStates: auth }, + get children() { + return createComponent(ConfigContext.Provider, { + value: { config }, + get children() { + return createComponent(SpeechToTextPrewarm, {}) + }, + }) + }, + }), + root, + ) + + if (sent.length !== 0) fail("prewarmed without Kilo access") + setAuth({ kilo: "api" }) + if (sent.length !== 0) fail("prewarmed while Kilo was disabled") + setConfig({}) + if (sent.length !== 1 || sent[0]?.type !== "speechToTextPrewarm") { + fail("did not prewarm after Kilo access became available") + } + setAuth({ kilo: "oauth" }) + if (sent.length !== 1) fail("prewarmed more than once") + dispose() + console.log("${PASS}") +` + +describe("speech-to-text prewarm", () => { + it("starts only after Kilo speech access becomes available", () => { + const attempts = 3 + const failures: string[] = [] + + for (let attempt = 1; attempt <= attempts; attempt++) { + const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], { + cwd: WEBVIEW, + stdout: "pipe", + stderr: "pipe", + }) + const output = result.stdout.toString() + result.stderr.toString() + + if (output.includes(PASS)) return + + const logic = output.indexOf(FAIL) + // A FAIL sentinel is a real assertion failure in the prewarm logic — surface it now. + if (logic !== -1) { + expect.unreachable(output.slice(logic + FAIL.length).split("\n")[0]?.trim()) + } + + // Otherwise the child died before it could run (starved/transient spawn) — retry. + failures.push(`attempt ${attempt} exit ${result.exitCode}: ${output.trim() || ""}`) + } + + expect.unreachable(`prewarm child never reported success:\n${failures.join("\n")}`) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts index ae28f0b4171..3b406e9cdd9 100644 --- a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts +++ b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts @@ -41,6 +41,59 @@ function setup() { } describe("useSpeechToText", () => { + it("waits for microphone readiness before reporting recording", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + expect(ctx.speech.state()).toBe("starting") + expect(ctx.speech.active()).toBe(true) + + ctx.fire({ type: "speechToTextStarted", requestId: "another-request" }) + expect(ctx.speech.state()).toBe("starting") + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("recording") + ctx.dispose() + }) + + it("does not stop or start another recording while the microphone is starting", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.stop() + ctx.speech.start({ model: "other", insert: () => {} }) + expect(ctx.speech.state()).toBe("starting") + expect(ctx.sent).toEqual([start]) + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + ctx.speech.stop() + expect(ctx.speech.state()).toBe("transcribing") + expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId }) + ctx.dispose() + }) + + it("cancels a pending microphone startup and ignores its late acknowledgement", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.cancel() + expect(ctx.sent[1]).toEqual({ type: "speechToTextCancel", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("idle") + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + it("offers sign-in when stored credentials stop authenticating", () => { const ctx = setup() @@ -70,6 +123,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++ }) ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" }) @@ -87,6 +141,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: () => {} }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++ }) ctx.speech.cancel() @@ -105,6 +160,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++, ready: () => false }) ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a092686fcb5..1363d949cf6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -83,6 +83,7 @@ import { FeedbackProvider } from "../src/context/feedback" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" 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" @@ -3131,6 +3132,7 @@ export const AgentManagerApp: Component = () => { + diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index a60338c62c3..5ab642292e1 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -23,6 +23,7 @@ import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/dif import type { DiffViewerNotice } from "../src/types/messages/extension-messages" import { DiffPickerHeader } from "./DiffPickerHeader" import { BaseBranchPicker } from "./BaseBranchPicker" +import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" const NOTICE_KEYS: Record = { "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", @@ -305,6 +306,7 @@ export const DiffViewerApp: Component = () => { + diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index de57030df63..d47cf5d952f 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -25,6 +25,7 @@ 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. @@ -378,6 +379,7 @@ const App: Component = () => { + diff --git a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/SpeechToTextButton.tsx b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/SpeechToTextButton.tsx index 3da53666ec7..ade6048443c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/SpeechToTextButton.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/SpeechToTextButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@kilocode/kilo-ui/button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Spinner } from "@kilocode/kilo-ui/spinner" -import type { Component } from "solid-js" +import { onCleanup, type Component } from "solid-js" import type { SpeechToText } from "./useSpeechToText" type Props = { @@ -12,8 +12,10 @@ type Props = { } export const SpeechToTextButton: Component = (props) => { - const disabled = () => !!props.disabled + const unavailable = () => !!props.disabled && props.speech.state() === "idle" + const busy = () => props.speech.state() === "starting" || props.speech.state() === "transcribing" const label = () => { + if (props.speech.state() === "starting") return props.label("speechToText.tooltip.starting") if (props.speech.state() === "recording") return props.label("speechToText.tooltip.stop") if (props.speech.state() === "transcribing") return props.label("speechToText.tooltip.transcribing") if (props.speech.state() === "error") return props.speech.error() || props.label("speechToText.tooltip.error") @@ -21,6 +23,7 @@ export const SpeechToTextButton: Component = (props) => { } const click = () => { + if (props.speech.state() === "starting") return if (props.speech.state() === "recording") { props.speech.stop() return @@ -33,22 +36,28 @@ export const SpeechToTextButton: Component = (props) => { props.speech.clear() return } - if (disabled()) return + if (unavailable()) return props.start() } + onCleanup(() => { + if (props.speech.active()) props.speech.cancel() + }) + return ( + + + {(path, index) => ( +
+ + {path} + + removePath(index())} /> +
+ )} +
+ + ) } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index d4435bcf03f..a15b6913272 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1422,6 +1422,9 @@ export const dict = { "settings.sandboxing.network.title": "Restrict Network Access", "settings.sandboxing.network.description": "Block outbound network access from model-originated commands and HTTP tools. Local MCP servers and plugin hooks run outside this restriction. Provider and model inference traffic remains available.", + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP Timeout (ms)", "settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds", "settings.experimental.remote.title": "Remote Control", 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 8ceed88a130..cf7fefb2cfe 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -48,6 +48,7 @@ export interface ExperimentalConfig { mcp_timeout?: number sandbox?: boolean sandbox_restrict_network?: boolean + sandbox_writable_paths?: string[] } export interface CommitMessageConfig { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be259ff3d9d..eae0c08f1c4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -418,6 +418,10 @@ export const Info = Schema.Struct({ description: "Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)", }), + sandbox_writable_paths: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: + "Additional filesystem paths the sandbox allows writes to (e.g. ['/tmp', '/var/log']). These are merged with the default writable paths when the sandbox is active.", + }), // kilocode_change end mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index c02d66e5bc8..ad0611db729 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -92,7 +92,11 @@ function isolated(ctx: InstanceContext) { return linked(path.resolve(ctx.directory), path.resolve(ctx.worktree)) } -export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] = "deny"): Profile { +export function profile( + ctx: InstanceContext, + mode: Profile["network"]["mode"] = "deny", + extraWritable?: readonly string[], +): Profile { const project = isolated(ctx) ? [ctx.directory] : ctx.directory === ctx.worktree @@ -108,6 +112,7 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] = Global.Path.bin, Global.Path.log, Global.Path.repos, + ...(extraWritable ?? []), ].map(root) return { filesystem: { @@ -297,7 +302,9 @@ function execute(sessionID: SessionID, effect: Effect.Effect) const current = yield* snapshot(sessionID) const support = backendSupport({ mode: current.state.mode, allowedHosts: [] }) if (!current.state.enabled || !support.available) return yield* unrestricted(effect) - return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode), effect) + const cfg = yield* (yield* Config.Service).get() + const extraWritable = cfg.experimental?.sandbox_writable_paths + return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode, extraWritable), effect) }) } From 1fc36e2e83e911af25aab780082f0c07f8593889 Mon Sep 17 00:00:00 2001 From: trim21 Date: Tue, 7 Jul 2026 12:54:27 +0800 Subject: [PATCH 034/107] fix(i18n): translate sandbox writable paths for zh/zht and add keys to all locales --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/br.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/da.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/de.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/es.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/it.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/no.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/th.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 4 ++++ packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 4 ++++ 19 files changed, 76 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index a87c8ac9b6c..62c7b7bce9c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1411,6 +1411,10 @@ export const dict = { "settings.sandboxing.network.title": "تقييد الوصول إلى الشبكة", "settings.sandboxing.network.description": "احظر الوصول الصادر إلى الشبكة من الأوامر الصادرة عن النموذج وأدوات HTTP. تعمل خوادم MCP المحلية وخطافات المكونات الإضافية خارج هذا التقييد. تظل حركة مرور استدلال الموفّر والنموذج متاحة.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)", "settings.experimental.mcpTimeout.description": "مهلة طلبات خادم MCP بالمللي ثانية", "settings.experimental.remote.title": "التحكم Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 5daf082020e..531b64e5198 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1447,6 +1447,10 @@ export const dict = { "settings.sandboxing.network.title": "Restringir acesso à rede", "settings.sandboxing.network.description": "Bloqueie o acesso de saída à rede para comandos originados pelo modelo e ferramentas HTTP. Servidores MCP locais e hooks de plugins são executados fora dessa restrição. O tráfego de inferência de provedores e modelos permanece disponível.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)", "settings.experimental.mcpTimeout.description": "Tempo limite para solicitações do servidor MCP em milissegundos", "settings.experimental.remote.title": "Controle Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a5a894c7f0f..08eac42dbb3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1443,6 +1443,10 @@ export const dict = { "settings.sandboxing.network.title": "Ograniči pristup mreži", "settings.sandboxing.network.description": "Blokiraj odlazni mrežni pristup za naredbe koje potiču od modela i HTTP alate. Lokalni MCP serveri i hookovi dodataka izvršavaju se izvan ovog ograničenja. Saobraćaj za inferenciju pružatelja i modela ostaje dostupan.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)", "settings.experimental.mcpTimeout.description": "Istek vremena za MCP server zahtjeve u milisekundama", "settings.experimental.remote.title": "Remote kontrola", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 14b21e2ab80..8084198a5c9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1437,6 +1437,10 @@ export const dict = { "settings.sandboxing.network.title": "Begræns netværksadgang", "settings.sandboxing.network.description": "Bloker udgående netværksadgang fra kommandoer, der stammer fra modellen, og HTTP-værktøjer. Lokale MCP-servere og plugin-hooks er ikke underlagt denne begrænsning. Inferenstrafik til udbydere og modeller er fortsat tilgængelig.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP-timeout (ms)", "settings.experimental.mcpTimeout.description": "Timeout for MCP-serveranmodninger i millisekunder", "settings.experimental.remote.title": "Remote-styring", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c3ebe2962b..e250600c9f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1467,6 +1467,10 @@ export const dict = { "settings.sandboxing.network.title": "Netzwerkzugriff einschränken", "settings.sandboxing.network.description": "Blockiert den ausgehenden Netzwerkzugriff für vom Modell initiierte Befehle und HTTP-Tools. Lokale MCP-Server und Plugin-Hooks sind von dieser Einschränkung ausgenommen. Anbieter- und Modellinferenzdatenverkehr bleibt verfügbar.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP-Zeitlimit (ms)", "settings.experimental.mcpTimeout.description": "Zeitlimit für MCP-Server-Anfragen in Millisekunden", "settings.experimental.remote.title": "Remote-Steuerung", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a5bf4b39bd0..11a1c4eb7c9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1455,6 +1455,10 @@ export const dict = { "settings.sandboxing.network.title": "Restringir el acceso a la red", "settings.sandboxing.network.description": "Bloquea el acceso saliente a la red para los comandos iniciados por el modelo y las herramientas HTTP. Los servidores MCP locales y los hooks de plugins no están sujetos a esta restricción. El tráfico de proveedores y de inferencia de modelos sigue estando disponible.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)", "settings.experimental.mcpTimeout.description": "Tiempo de espera para solicitudes del servidor MCP en milisegundos", "settings.experimental.remote.title": "Control Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e793315324c..a5cbfe1fff0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1470,6 +1470,10 @@ export const dict = { "settings.sandboxing.network.title": "Restreindre l'accès au réseau", "settings.sandboxing.network.description": "Bloquer l'accès réseau sortant des commandes provenant du modèle et des outils HTTP. Les serveurs MCP locaux et les hooks de plugin ne sont pas soumis à cette restriction. Le trafic d'inférence des fournisseurs et des modèles reste disponible.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Délai MCP (ms)", "settings.experimental.mcpTimeout.description": "Délai des requêtes du serveur MCP en millisecondes", "settings.experimental.remote.title": "Contrôle Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5e1079426a4..0af26debd75 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1229,6 +1229,10 @@ export const dict = { "settings.sandboxing.network.title": "Limita l'accesso alla rete", "settings.sandboxing.network.description": "Blocca l'accesso in uscita alla rete per i comandi avviati dal modello e gli strumenti HTTP. I server MCP locali e gli hook dei plugin operano al di fuori di questa restrizione. Il traffico verso i provider e per l'inferenza dei modelli rimane disponibile.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Timeout MCP (ms)", "settings.experimental.mcpTimeout.description": "Timeout per richieste server MCP in millisecondi", "settings.experimental.remote.title": "Controllo remoto", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 5483784d610..480fc818ee0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1432,6 +1432,10 @@ export const dict = { "settings.sandboxing.network.title": "ネットワークアクセスを制限", "settings.sandboxing.network.description": "モデルから発行されたコマンドと HTTP ツールによる外部ネットワークアクセスをブロックします。ローカル MCP サーバーとプラグインフックは、この制限の対象外です。プロバイダーおよびモデルへの推論通信は引き続き利用できます。", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)", "settings.experimental.mcpTimeout.description": "MCPサーバーリクエストのタイムアウト(ミリ秒)", "settings.experimental.remote.title": "Remote コントロール", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1c68d5d743a..592d78f9ff0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1424,6 +1424,10 @@ export const dict = { "settings.sandboxing.network.title": "네트워크 액세스 제한", "settings.sandboxing.network.description": "모델이 실행한 명령과 HTTP 도구의 아웃바운드 네트워크 액세스를 차단합니다. 로컬 MCP 서버와 플러그인 훅에는 이 제한이 적용되지 않습니다. 공급자 및 모델 추론 트래픽은 계속 사용할 수 있습니다.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)", "settings.experimental.mcpTimeout.description": "MCP 서버 요청의 타임아웃 시간 (밀리초)", "settings.experimental.remote.title": "Remote 제어", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 845b5d5a162..2ad81ecb59e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1441,6 +1441,10 @@ export const dict = { "settings.sandboxing.network.title": "Netwerktoegang beperken", "settings.sandboxing.network.description": "Blokkeer uitgaande netwerktoegang voor door het model geïnitieerde opdrachten en HTTP-tools. Lokale MCP-servers en plugin-hooks vallen buiten deze beperking. Netwerkverkeer voor providers en modelinferentie blijft beschikbaar.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP Timeout (ms)", "settings.experimental.mcpTimeout.description": "Timeout voor MCP-serververzoeken in milliseconden", "settings.experimental.remote.title": "Remote-bediening", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 775d9dc7e6e..a6fb9502cb8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1400,6 +1400,10 @@ export const dict = { "settings.sandboxing.network.title": "Begrens nettverkstilgang", "settings.sandboxing.network.description": "Blokker utgående nettverkstilgang fra kommandoer generert av modellen og HTTP-verktøy. Lokale MCP-servere og programtilleggskroker kjører utenfor denne begrensningen. Trafikk for leverandør- og modellinferens forblir tilgjengelig.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)", "settings.experimental.mcpTimeout.description": "Tidsavbrudd for MCP-serverforespørsler i millisekunder", "settings.experimental.remote.title": "Remote-kontroll", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d3fa876fb7b..4c580f8b688 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1400,6 +1400,10 @@ export const dict = { "settings.sandboxing.network.title": "Ogranicz dostęp do sieci", "settings.sandboxing.network.description": "Blokuj wychodzący dostęp do sieci z poleceń pochodzących od modelu i narzędzi HTTP. Lokalne serwery MCP i hooki wtyczek nie podlegają temu ograniczeniu. Ruch do dostawców i modeli na potrzeby wnioskowania pozostaje dostępny.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)", "settings.experimental.mcpTimeout.description": "Limit czasu żądań serwera MCP w milisekundach", "settings.experimental.remote.title": "Sterowanie Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6f616a81d8c..f2abffcf8b1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1441,6 +1441,10 @@ export const dict = { "settings.sandboxing.network.title": "Ограничить доступ к сети", "settings.sandboxing.network.description": "Блокировать исходящий доступ к сети для команд, инициированных моделью, и HTTP-инструментов. Локальные серверы MCP и хуки плагинов не подпадают под это ограничение. Трафик к провайдерам и моделям для инференса остаётся доступным.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)", "settings.experimental.mcpTimeout.description": "Таймаут запросов MCP-сервера в миллисекундах", "settings.experimental.remote.title": "Управление Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index e5ec7f48ed4..360857fba29 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1420,6 +1420,10 @@ export const dict = { "settings.sandboxing.network.title": "จำกัดการเข้าถึงเครือข่าย", "settings.sandboxing.network.description": "บล็อกการเข้าถึงเครือข่ายขาออกจากคำสั่งที่มาจากโมเดลและเครื่องมือ HTTP เซิร์ฟเวอร์ MCP ภายในเครื่องและฮุกของปลั๊กอินทำงานอยู่นอกข้อจำกัดนี้ การรับส่งข้อมูลสำหรับการอนุมานของผู้ให้บริการและโมเดลยังคงใช้งานได้", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)", "settings.experimental.mcpTimeout.description": "หมดเวลาสำหรับคำขอเซิร์ฟเวอร์ MCP เป็นมิลลิวินาที", "settings.experimental.remote.title": "การควบคุม Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e57d018906b..9337af1b820 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1431,6 +1431,10 @@ export const dict = { "settings.sandboxing.network.title": "Ağ Erişimini Kısıtla", "settings.sandboxing.network.description": "Model tarafından başlatılan komutların ve HTTP araçlarının giden ağ erişimini engelleyin. Yerel MCP sunucuları ve eklenti kancaları bu kısıtlamanın dışında çalışır. Sağlayıcı ve model çıkarım trafiği kullanılabilir durumda kalır.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)", "settings.experimental.mcpTimeout.description": "MCP sunucu istekleri için milisaniye cinsinden zaman aşımı", "settings.experimental.remote.title": "Remote Kontrolü", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 06c0f00dfa4..bd849a94b46 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1429,6 +1429,10 @@ export const dict = { "settings.sandboxing.network.title": "Обмежити доступ до мережі", "settings.sandboxing.network.description": "Блокуйте вихідний доступ до мережі для команд, ініційованих моделлю, та HTTP-інструментів. Локальні MCP-сервери й хуки плагінів працюють поза цим обмеженням. Трафік провайдерів та інференсу моделей залишається доступним.", + + "settings.sandboxing.writablePaths.title": "Additional Writable Paths", + "settings.sandboxing.writablePaths.description": + "Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.", "settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)", "settings.experimental.mcpTimeout.description": "Тайм-аут у мілісекундах для запитів до MCP-сервера", "settings.experimental.remote.title": "Керування Remote", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 3f0eafa95e2..cf9dd5844e2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1394,6 +1394,10 @@ export const dict = { "settings.sandboxing.network.title": "限制网络访问", "settings.sandboxing.network.description": "阻止模型发起的命令和 HTTP 工具进行出站网络访问。本地 MCP 服务器和插件钩子不受此限制。提供商和模型推理流量仍然可用。", + + "settings.sandboxing.writablePaths.title": "额外可写路径", + "settings.sandboxing.writablePaths.description": + "沙盒允许写入的额外文件系统路径(例如 /tmp、/var/log)。沙盒启用后,这些路径会与默认可写路径合并。", "settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)", "settings.experimental.mcpTimeout.description": "MCP 服务器请求的超时时间(毫秒)", "settings.experimental.remote.title": "Remote 控制", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d50689f1f76..16e14f70e25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1358,6 +1358,10 @@ export const dict = { "settings.sandboxing.network.title": "限制網路存取", "settings.sandboxing.network.description": "封鎖模型發起的命令和 HTTP 工具的對外網路存取。本機 MCP 伺服器和外掛程式鉤子不受此限制。供應商與模型推論流量仍然可用。", + + "settings.sandboxing.writablePaths.title": "額外可寫路徑", + "settings.sandboxing.writablePaths.description": + "沙盒允許寫入的額外檔案系統路徑(例如 /tmp、/var/log)。沙盒啟用後,這些路徑會與預設可寫路徑合併。", "settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)", "settings.experimental.mcpTimeout.description": "MCP 伺服器請求的逾時時間(毫秒)", "settings.experimental.remote.title": "Remote 控制", From 889e2f4358042250d87817b41d085a20a7c24a9b Mon Sep 17 00:00:00 2001 From: trim21 Date: Tue, 7 Jul 2026 12:58:44 +0800 Subject: [PATCH 035/107] style: fix prettier formatting for SandboxingTab --- .../webview-ui/src/components/settings/SandboxingTab.tsx | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/it.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 4 ++-- 18 files changed, 35 insertions(+), 36 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx index 6e69d7b14d4..74f6e656faa 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -104,8 +104,7 @@ const SandboxingTab: Component = () => { "align-items": "center", "justify-content": "space-between", padding: "6px 0", - "border-bottom": - index() < writablePaths().length - 1 ? "1px solid var(--border-weak-base)" : "none", + "border-bottom": index() < writablePaths().length - 1 ? "1px solid var(--border-weak-base)" : "none", }} > Date: Tue, 7 Jul 2026 09:32:11 +0200 Subject: [PATCH 036/107] fix(cli): show remote badge in prompt status (#11976) --- .changeset/remote-tui-badge.md | 5 +++ .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../src/kilocode/plugins/home-footer.tsx | 44 ++++--------------- .../opencode/src/kilocode/plugins/remote.tsx | 32 ++++++++++++++ 4 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 .changeset/remote-tui-badge.md create mode 100644 packages/opencode/src/kilocode/plugins/remote.tsx diff --git a/.changeset/remote-tui-badge.md b/.changeset/remote-tui-badge.md new file mode 100644 index 00000000000..bb23dbb560a --- /dev/null +++ b/.changeset/remote-tui-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the Remote badge in the TUI prompt status area when remote session relay is enabled. diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 88dd992a1c4..1b9481664ba 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -14,6 +14,7 @@ import KiloMemoryPalette from "@/kilocode/plugins/memory-palette" // kilocode_ch import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" import KiloSandbox from "@/kilocode/plugins/sandbox" +import KiloRemote from "@/kilocode/plugins/remote" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" import SidebarMcp from "../feature-plugins/sidebar/mcp" @@ -52,6 +53,7 @@ export function internalTuiPlugins(flags: Pick props.api.theme.current - const [status, setStatus] = createSignal(null) - - onMount(() => { - void props.api.client.remote - .status() - .then((res: { data?: Status }) => { - if (res.data) setStatus(res.data) - }) - .catch(() => undefined) - const off = props.api.event.on("kilo-sessions.remote-status-changed", (evt) => setStatus(evt.properties)) - onCleanup(off) - }) - - return ( - - - ◆ Remote{status()?.connected ? "" : " …"} - - - ) -} - // --------------------------------------------------------------------------- // Sub-components (mirror upstream home/footer with kilo additions) // --------------------------------------------------------------------------- @@ -105,6 +73,7 @@ function Version(props: { api: TuiPluginApi }) { function View(props: { api: TuiPluginApi }) { const kilo = createMemo(() => props.api.state.provider.some((p) => p.id === "kilo")) + const sdk = { client: props.api.client } return ( - + diff --git a/packages/opencode/src/kilocode/plugins/remote.tsx b/packages/opencode/src/kilocode/plugins/remote.tsx new file mode 100644 index 00000000000..098a94563ba --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/remote.tsx @@ -0,0 +1,32 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" +import { RemoteIndicator } from "@/kilocode/remote-tui" + +const id = "internal:remote" + +function View(props: { api: TuiPluginApi }) { + return ( + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 51, + slots: { + session_prompt_right() { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin From 75b994330fa42bc59f498b58a2750c389402ddfd Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 7 Jul 2026 10:57:43 +0200 Subject: [PATCH 037/107] docs: add deprecation notice to App Builder page (#11997) Co-authored-by: eshurakov <54751+eshurakov@users.noreply.github.com> Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-docs/pages/code-with-ai/app-builder.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/app-builder.md b/packages/kilo-docs/pages/code-with-ai/app-builder.md index c67d1c80bc5..ce9f87f0279 100644 --- a/packages/kilo-docs/pages/code-with-ai/app-builder.md +++ b/packages/kilo-docs/pages/code-with-ai/app-builder.md @@ -5,6 +5,10 @@ description: "Build complete applications with Kilo Code" # App Builder +{% callout type="warning" title="App Builder is deprecated" %} +App Builder is deprecated and no longer available to new users. Only existing users who previously used App Builder can still access it. +{% /callout %} + Kilo's **App Builder** lets you create end-to-end applications through natural language conversation. Describe what you want to build, watch it come to life in a real-time preview, and deploy directly from your Kilo dashboard. No local environment setup required. --- From 61b9e0935cb3314acdabb4d3237b95395bfffb06 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 7 Jul 2026 11:30:13 +0200 Subject: [PATCH 038/107] fix(cli): honor cloud-selected Kilo organization --- .changeset/selected-organization-default.md | 6 +++++ packages/kilo-gateway/src/api/profile.ts | 2 ++ packages/kilo-gateway/src/server/handlers.ts | 19 ++++++++++--- packages/kilo-gateway/src/server/routes.ts | 1 + packages/kilo-gateway/src/types.ts | 1 + .../src/services/cli-backend/types.ts | 1 + .../webview-ui/src/types/messages/profile.ts | 1 + .../server/httpapi/groups/kilo-gateway.ts | 1 + .../server/httpapi/handlers/kilo-gateway.ts | 27 ++++++++++++++++--- .../kilocode/server/httpapi-public.test.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 +++ 12 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 .changeset/selected-organization-default.md diff --git a/.changeset/selected-organization-default.md b/.changeset/selected-organization-default.md new file mode 100644 index 00000000000..1f4c262b29b --- /dev/null +++ b/.changeset/selected-organization-default.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Use the cloud-selected organization as the active Kilo account when provided. diff --git a/packages/kilo-gateway/src/api/profile.ts b/packages/kilo-gateway/src/api/profile.ts index aa717a64c43..ae7d46eeed8 100644 --- a/packages/kilo-gateway/src/api/profile.ts +++ b/packages/kilo-gateway/src/api/profile.ts @@ -25,6 +25,7 @@ export async function fetchProfile(token: string): Promise { email?: string name?: string organizations?: Organization[] + selectedOrganizationId?: string | null } // Backend returns { user: { email, name, ... }, organizations } // Transform to flat KilocodeProfile structure @@ -32,6 +33,7 @@ export async function fetchProfile(token: string): Promise { email: data.user?.email ?? data.email ?? "", name: data.user?.name ?? data.name, organizations: data.organizations, + selectedOrganizationId: data.selectedOrganizationId ?? undefined, } } diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts index 167bfbf4db2..ebf5aa37348 100644 --- a/packages/kilo-gateway/src/server/handlers.ts +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -68,12 +68,25 @@ export async function getProfile(auth: AuthStore): Promise { const info = await auth.get("kilo") if (!info || info.type !== "oauth") throw new UnauthorizedError("Not authenticated with Kilo Gateway") - const currentOrgId = info.accountId ?? null - const [profile, balance, kiloPass] = await Promise.all([ + const [profile, kiloPass] = await Promise.all([ fetchProfile(info.access), - fetchBalance(info.access, currentOrgId ?? undefined), fetchKiloPassState(info.access), ]) + + const selected = profile.selectedOrganizationId + const valid = selected && profile.organizations?.some((org) => org.id === selected) ? selected : undefined + const currentOrgId = valid ?? info.accountId ?? null + if (valid && valid !== info.accountId) { + await auth.set("kilo", { + type: "oauth", + refresh: info.refresh, + access: info.access, + expires: info.expires, + accountId: valid, + }) + } + + const balance = await fetchBalance(info.access, currentOrgId ?? undefined) return { profile, balance, kiloPass, currentOrgId } } diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 40fb57937c3..cc60ecc62c8 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -101,6 +101,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { email: z.string(), name: z.string().optional(), organizations: z.array(Organization).optional(), + selectedOrganizationId: z.string().optional(), }) const Balance = z.object({ diff --git a/packages/kilo-gateway/src/types.ts b/packages/kilo-gateway/src/types.ts index 801464966b7..c9014f1bc25 100644 --- a/packages/kilo-gateway/src/types.ts +++ b/packages/kilo-gateway/src/types.ts @@ -27,6 +27,7 @@ export interface KilocodeProfile { email: string name?: string organizations?: Organization[] + selectedOrganizationId?: string } export interface KilocodeBalance { diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index b6755004547..99f52c80d31 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -31,6 +31,7 @@ export interface KilocodeProfile { email: string name?: string organizations?: KilocodeOrganization[] + selectedOrganizationId?: string } export interface KilocodeBalance { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts b/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts index 6b05d3fb519..2d2a845a2b2 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts @@ -30,6 +30,7 @@ export interface ProfileData { email: string name?: string organizations?: Array<{ id: string; name: string; role: string }> + selectedOrganizationId?: string } balance: KilocodeBalance | null kiloPass: KiloPassState | null diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index f807b480bd1..8f255191b05 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -21,6 +21,7 @@ export const Profile = Schema.Struct({ email: Schema.String, name: Schema.optional(Schema.String), organizations: Schema.optional(Schema.Array(Organization)), + selectedOrganizationId: Schema.optional(Schema.String), }) export const Balance = Schema.Struct({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index dc37a5628a2..334a8192767 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -67,16 +67,37 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) if (!info || info.type !== "oauth") return yield* Effect.fail(new HttpApiError.Unauthorized({})) - const currentOrgId = info.accountId ?? null - const [profile, balance, kiloPass] = yield* Effect.tryPromise({ + const [profile, kiloPass] = yield* Effect.tryPromise({ try: () => Promise.all([ fetchProfile(info.access), - fetchBalance(info.access, currentOrgId ?? undefined), fetchKiloPassState(info.access), ]), catch: () => new HttpApiError.BadRequest({}), }) + + const selected = profile.selectedOrganizationId + const valid = selected && profile.organizations?.some((org) => org.id === selected) ? selected : undefined + const currentOrgId = valid ?? info.accountId ?? null + if (valid && valid !== info.accountId) { + yield* auth + .set("kilo", { + type: "oauth", + refresh: info.refresh, + access: info.access, + expires: info.expires, + accountId: valid, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + yield* cache.clear("kilo") + clearModesCache() + yield* store.disposeAll().pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + } + + const balance = yield* Effect.tryPromise({ + try: () => fetchBalance(info.access, currentOrgId ?? undefined), + catch: () => new HttpApiError.BadRequest({}), + }) return { profile, balance, kiloPass, currentOrgId } }) diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index 66cf90a7ec4..3adc9c77eec 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -189,6 +189,7 @@ describe("Kilo PublicApi OpenAPI contract", () => { const profile = response(KiloGatewayPaths.profile)?.properties expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] }) expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] }) + expect(profile?.profile?.properties?.selectedOrganizationId).toEqual({ type: "string" }) const pass = profile?.kiloPass?.anyOf?.find((item) => item.type === "object")?.properties expect(pass?.nextBillingAt).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5282b260f4c..e7a7c7c3f97 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10787,6 +10787,7 @@ export type KiloProfileResponses = { name: string role: string }> + selectedOrganizationId?: string } balance: { balance: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 267d53bb1d7..b1bb912b56a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13509,6 +13509,9 @@ "required": ["id", "name", "role"], "additionalProperties": false } + }, + "selectedOrganizationId": { + "type": "string" } }, "required": ["email"], From 81213b9f251286fb87d677c8af3dba304efea136 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 7 Jul 2026 11:46:34 +0200 Subject: [PATCH 039/107] fix: respect unavailable personal Kilo accounts --- .changeset/selected-organization-default.md | 2 +- packages/kilo-gateway/src/api/profile.ts | 2 ++ packages/kilo-gateway/src/server/routes.ts | 1 + packages/kilo-gateway/src/tui/helpers.ts | 17 ++++++++----- packages/kilo-gateway/src/types.ts | 1 + .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 1 + .../backend/app/KiloBackendAppServiceTest.kt | 2 ++ .../ui/account/SessionAccountOverlay.kt | 5 ++-- .../settings/profile/LoggedInProfileUi.kt | 7 +++--- .../ui/account/SessionAccountOverlayTest.kt | 18 +++++++++++++ .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 1 + .../src/services/cli-backend/types.ts | 1 + .../src/components/profile/ProfileView.tsx | 25 +++++++++++++++---- .../src/components/shared/AccountSwitcher.tsx | 23 +++++++++-------- .../webview-ui/src/types/messages/profile.ts | 1 + .../components/dialog-kilo-auto-method.tsx | 11 ++++---- .../components/dialog-kilo-organization.tsx | 3 ++- .../components/dialog-kilo-team-select.tsx | 7 +++++- .../opencode/src/kilocode/kilo-commands.tsx | 1 + .../server/httpapi/groups/kilo-gateway.ts | 1 + .../kilocode/server/httpapi-public.test.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 +++ 23 files changed, 100 insertions(+), 35 deletions(-) diff --git a/.changeset/selected-organization-default.md b/.changeset/selected-organization-default.md index 1f4c262b29b..78a9cec07cd 100644 --- a/.changeset/selected-organization-default.md +++ b/.changeset/selected-organization-default.md @@ -3,4 +3,4 @@ "@kilocode/kilo-gateway": patch --- -Use the cloud-selected organization as the active Kilo account when provided. +Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts. diff --git a/packages/kilo-gateway/src/api/profile.ts b/packages/kilo-gateway/src/api/profile.ts index ae7d46eeed8..fb3413ddf88 100644 --- a/packages/kilo-gateway/src/api/profile.ts +++ b/packages/kilo-gateway/src/api/profile.ts @@ -26,6 +26,7 @@ export async function fetchProfile(token: string): Promise { name?: string organizations?: Organization[] selectedOrganizationId?: string | null + hasPersonalAccount?: boolean | null } // Backend returns { user: { email, name, ... }, organizations } // Transform to flat KilocodeProfile structure @@ -34,6 +35,7 @@ export async function fetchProfile(token: string): Promise { name: data.user?.name ?? data.name, organizations: data.organizations, selectedOrganizationId: data.selectedOrganizationId ?? undefined, + hasPersonalAccount: data.hasPersonalAccount ?? undefined, } } diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index cc60ecc62c8..177674cd186 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -102,6 +102,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { name: z.string().optional(), organizations: z.array(Organization).optional(), selectedOrganizationId: z.string().optional(), + hasPersonalAccount: z.boolean().optional(), }) const Balance = z.object({ diff --git a/packages/kilo-gateway/src/tui/helpers.ts b/packages/kilo-gateway/src/tui/helpers.ts index 6884312e49e..cc7a72311ac 100644 --- a/packages/kilo-gateway/src/tui/helpers.ts +++ b/packages/kilo-gateway/src/tui/helpers.ts @@ -56,6 +56,7 @@ export function formatProfileInfo( export function getOrganizationOptions( organizations: Organization[], currentOrgId?: string, + hasPersonalAccount = true, ): Array<{ title: string value: string | null @@ -63,12 +64,16 @@ export function getOrganizationOptions( category: string }> { return [ - { - title: "Personal Account", - value: null, - description: !currentOrgId ? "→ (current)" : undefined, - category: "Accounts", - }, + ...(hasPersonalAccount + ? [ + { + title: "Personal Account", + value: null, + description: !currentOrgId ? "→ (current)" : undefined, + category: "Accounts", + }, + ] + : []), ...organizations.map((org) => ({ title: org.name, value: org.id, diff --git a/packages/kilo-gateway/src/types.ts b/packages/kilo-gateway/src/types.ts index c9014f1bc25..3100b1e6304 100644 --- a/packages/kilo-gateway/src/types.ts +++ b/packages/kilo-gateway/src/types.ts @@ -28,6 +28,7 @@ export interface KilocodeProfile { name?: string organizations?: Organization[] selectedOrganizationId?: string + hasPersonalAccount?: boolean } export interface KilocodeBalance { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index b593cb5dbaf..5e02bbb8819 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -143,6 +143,7 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( organizations = p.profile.organizations.orEmpty().map { org -> ProfileOrganizationDto(id = org.id, name = org.name, role = org.role) }, + hasPersonalAccount = p.profile.hasPersonalAccount ?: true, balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) }, kiloPass = p.kiloPass?.let { ProfileKiloPassDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 7ba64fec23a..5cb922a1d39 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -790,6 +790,7 @@ class KiloBackendAppServiceTest { "profile":{ "email":"alice@test.com", "name":"Alice", + "hasPersonalAccount":false, "organizations":[{"id":"org_1","name":"Acme","role":"ADMIN"}] }, "balance":{"balance":42.5}, @@ -804,6 +805,7 @@ class KiloBackendAppServiceTest { assertEquals("alice@test.com", dto.profile?.email) assertEquals("Alice", dto.profile?.name) assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role) + assertFalse(dto.profile?.hasPersonalAccount ?: true) assertEquals(42.5, dto.profile?.balance?.balance) assertEquals("org_1", dto.profile?.currentOrgId) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt index 82a04299116..7a05e01202a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -124,7 +124,8 @@ internal class SessionAccountOverlay( var layout = false val orgs = prof.organizations - val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) + + val personal = prof.hasPersonalAccount + val next = (if (personal) listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) else emptyList()) + orgs.map { org -> AccountChoice(org.id, org.name) } if (next != choices) { choices = next @@ -133,7 +134,7 @@ internal class SessionAccountOverlay( if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId - val activeId = if (switching) target else prof.currentOrgId + val activeId = if (switching) target else prof.currentOrgId ?: if (personal) null else orgs.firstOrNull()?.id val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull() val title = "${active?.title ?: " "} ▾" if (picker.text != title) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt index 520aa26ae71..63fa8589d67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -331,12 +331,11 @@ internal class LoggedInProfileUi( @RequiresEdt private fun applyOrganizations(profile: ProfileDto) { val orgs = profile.organizations - val keys: List> = listOf(null to KiloBundle.message("profile.personalAccount")) + + val personal = profile.hasPersonalAccount + val keys: List> = (if (personal) listOf(null to KiloBundle.message("profile.personalAccount")) else emptyList()) + orgs.map { it.id to it.name } - val target = profile.currentOrgId - ?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) } - ?: 0 + val target = keys.indexOfFirst { it.first == profile.currentOrgId }.takeIf { it >= 0 } ?: 0 currentOrgId = profile.currentOrgId diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt index 4aaa851181c..428f682fb62 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -42,10 +42,12 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { organizations: List = emptyList(), balance: ProfileBalanceDto? = null, currentOrgId: String? = null, + hasPersonalAccount: Boolean = true, ) = ProfileDto( email = email, name = name, organizations = organizations, + hasPersonalAccount = hasPersonalAccount, balance = balance, currentOrgId = currentOrgId, ) @@ -106,6 +108,22 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { } } + fun `test profile without personal account hides personal choice`() { + val acme = org("org_1", "Acme", "MEMBER") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = "org_1", + hasPersonalAccount = false, + ) + show(snap(prof)) + edt { + assertEquals("Acme", panel.accountTitle()) + assertEquals(1, panel.choiceCount()) + assertEquals(0, panel.selectedIndex()) + } + } + // --- test 4: programmatic update does not call select callback --- fun `test programmatic update does not call select callback`() { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index cbc72c6db79..9fe4d903c56 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -155,6 +155,7 @@ data class ProfileDto( val email: String, val name: String? = null, val organizations: List = emptyList(), + val hasPersonalAccount: Boolean = true, val balance: ProfileBalanceDto? = null, val kiloPass: ProfileKiloPassDto? = null, val currentOrgId: String? = null, diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index 99f52c80d31..9ff83a941fc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -32,6 +32,7 @@ export interface KilocodeProfile { name?: string organizations?: KilocodeOrganization[] selectedOrganizationId?: string + hasPersonalAccount?: boolean } export interface KilocodeBalance { diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx index 05c1cb1bcc5..e1087247033 100644 --- a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx @@ -65,20 +65,23 @@ const ProfileView: Component = (props) => { const orgOptions = createMemo(() => { const orgs = props.profileData?.profile.organizations ?? [] if (orgs.length === 0) return [] + const personal = props.profileData?.profile.hasPersonalAccount !== false return [ - { value: PERSONAL, label: language.t("profile.personalAccount") }, + ...(personal ? [{ value: PERSONAL, label: language.t("profile.personalAccount") }] : []), ...orgs.map((org) => ({ value: org.id, label: org.name, description: org.role })), ] }) const currentOrg = createMemo(() => { - const id = props.profileData?.currentOrgId ?? PERSONAL + const personal = props.profileData?.profile.hasPersonalAccount !== false + const id = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value) return orgOptions().find((o) => o.value === id) }) const selectOrg = (option: OrgOption | undefined) => { if (!option) return - const current = props.profileData?.currentOrgId ?? PERSONAL + const personal = props.profileData?.profile.hasPersonalAccount !== false + const current = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value) if (option.value === current) return setTarget(option.value) vscode.postMessage({ @@ -258,7 +261,13 @@ const ProfileView: Component = (props) => { {/* Kilo Pass is part of personal credits, so only show it on the personal account */} - + {(pass) => (
= (props) => { {/* No active Kilo Pass on the personal account — nudge to subscribe */} - +
= (props) => { const profile = () => server.profileData() const orgs = () => profile()?.profile.organizations ?? [] + const personal = () => profile()?.profile.hasPersonalAccount !== false const visible = () => !!profile() && orgs().length > 0 - const current = () => profile()?.currentOrgId ?? PERSONAL + const current = () => profile()?.currentOrgId ?? (personal() ? PERSONAL : (orgs()[0]?.id ?? PERSONAL)) const selected = createMemo(() => { const id = current() @@ -119,15 +120,17 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => { {/* Settings tabs */} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 0ee4d792328..b7a95fd62ba 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -158,6 +158,14 @@ export function useSlashCommand( action: sandbox.action, enabled: sandbox.enabled, }, + { + name: "reload", + description: "Reload config, skills, agents, and commands from disk", + hints: ["refresh"], + action: () => { + vscode.postMessage({ type: "reload" }) + }, + }, ] const excluded = () => { diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index d4435bcf03f..823fe3232b3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1156,6 +1156,7 @@ export const dict = { "common.retry": "Retry", "common.refresh": "Refresh", + "common.reload": "Reload", "profile.title": "Profile", "profile.notLoggedIn": "Not logged in", 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 6bf6ea39e9a..cc4fa032619 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 @@ -924,6 +924,10 @@ export interface RetryConnectionRequest { type: "retryConnection" } +export interface ReloadRequest { + type: "reload" +} + // Open a sub-agent session in a read-only editor panel export interface OpenSubAgentViewerRequest { type: "openSubAgentViewer" @@ -1333,6 +1337,7 @@ export type WebviewMessage = | DiffViewerSetBaseBranchRequest | DiffVirtualSetMarkdownRenderRequest | RetryConnectionRequest + | ReloadRequest | OpenSubAgentViewerRequest | PreviewImageRequest | SaveImageRequest diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 1b9481664ba..343bb5eeb3b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -15,6 +15,7 @@ import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" import KiloSandbox from "@/kilocode/plugins/sandbox" import KiloRemote from "@/kilocode/plugins/remote" +import KiloReload from "@/kilocode/plugins/reload" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" import SidebarMcp from "../feature-plugins/sidebar/mcp" @@ -54,6 +55,7 @@ export function internalTuiPlugins(flags: Pick { + api.keymap.registerLayer({ + commands: [ + { + namespace: "palette", + name: "app.reload", + title: "Reload", + desc: "Reload config, skills, agents, and commands from disk", + category: "System", + slashName: "reload", + async run() { + try { + await api.client.instance.reload({}, { throwOnError: true }) + api.ui.toast({ message: "Reloaded", variant: "success" }) + } catch (err) { + api.ui.toast({ message: String(err), variant: "error", duration: 5000 }) + } + }, + }, + ], + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts new file mode 100644 index 00000000000..64d04a598d7 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts @@ -0,0 +1,49 @@ +import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { described } from "@/server/routes/instance/httpapi/groups/metadata" +import { ConflictError } from "@/server/routes/instance/httpapi/errors" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const ReloadPaths = { + reload: "/instance/reload", +} as const + +export const InstanceReloadApi = HttpApi.make("instance-reload") + .add( + HttpApiGroup.make("instance-reload") + .add( + HttpApiEndpoint.post("reload", ReloadPaths.reload, { + query: WorkspaceRoutingQuery, + success: described(Schema.Boolean, "Instance reloaded"), + error: ConflictError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "instance.reload", + summary: "Reload instance", + description: + "Atomically dispose and reboot the current Kilo instance, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if a session is actively running.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "instance-reload", + description: "Kilo instance reload route.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "kilo HttpApi", + version: "0.0.1", + description: "Kilo HttpApi surface.", + }), + ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts new file mode 100644 index 00000000000..db95453a2a6 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts @@ -0,0 +1,40 @@ +import * as InstanceState from "@/effect/instance-state" +import { SessionStatus } from "@/session/status" +import { ConflictError } from "@/server/routes/instance/httpapi/errors" +import { markInstanceForReload } from "@/server/routes/instance/httpapi/lifecycle" +import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import type { SessionID } from "@/session/schema" + +export function hasBusySession(statuses: Map): boolean { + for (const info of statuses.values()) { + if (info.type === "busy") return true + } + return false +} + +export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance-reload", (handlers) => + Effect.gen(function* () { + const status = yield* SessionStatus.Service + + const reload = Effect.fn("InstanceReloadHttpApi.reload")(function* () { + const ctx = yield* InstanceState.context + if (hasBusySession(yield* status.list())) { + return yield* Effect.fail( + new ConflictError({ + message: "Cannot reload while a session is running. Wait for it to finish or abort it first.", + }), + ) + } + yield* markInstanceForReload(ctx, { + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project, + }) + return true + }) + + return handlers.handle("reload", reload) + }), +) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index 9681c31d19e..a6bd9210c89 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -15,6 +15,7 @@ import { commitMessageHandlers } from "./handlers/commit-message" import { configConsoleHandlers } from "./handlers/config-console" import { enhancePromptHandlers } from "./handlers/enhance-prompt" import { indexingHandlers } from "./handlers/indexing" +import { instanceReloadHandlers } from "./handlers/instance-reload" import { interactiveTerminalHandlers } from "./handlers/interactive-terminal" import { kiloGatewayHandlers } from "./handlers/kilo-gateway" import { kilocodeHandlers } from "./handlers/kilocode" @@ -35,6 +36,7 @@ export const provide = Layer.provide([ configConsoleHandlers, enhancePromptHandlers, indexingHandlers, + instanceReloadHandlers, interactiveTerminalHandlers, kiloGatewayHandlers, kilocodeHandlers, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 984f9097c32..ab1edae6be5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -28,6 +28,7 @@ import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/backgroun import { ConfigConsoleApi } from "@/kilocode/server/httpapi/groups/config-console" import { EnhancePromptApi } from "@/kilocode/server/httpapi/groups/enhance-prompt" import { IndexingApi } from "@/kilocode/server/httpapi/groups/indexing" +import { InstanceReloadApi } from "@/kilocode/server/httpapi/groups/instance-reload" import { InteractiveTerminalApi } from "@/kilocode/server/httpapi/groups/interactive-terminal" import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway" import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode" @@ -76,6 +77,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ConfigConsoleApi) .addHttpApi(EnhancePromptApi) .addHttpApi(IndexingApi) + .addHttpApi(InstanceReloadApi) .addHttpApi(InteractiveTerminalApi) .addHttpApi(KiloGatewayApi) .addHttpApi(KilocodeApi) diff --git a/packages/opencode/test/kilocode/instance-reload.test.ts b/packages/opencode/test/kilocode/instance-reload.test.ts new file mode 100644 index 00000000000..b537811bcc8 --- /dev/null +++ b/packages/opencode/test/kilocode/instance-reload.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test" +import { hasBusySession } from "@/kilocode/server/httpapi/handlers/instance-reload" +import type { SessionStatus } from "@/session/status" +import { SessionID } from "@/session/schema" + +const entries = (items: [string, SessionStatus.Info][]) => + items.map(([k, v]) => [k as SessionID, v] as [SessionID, SessionStatus.Info]) + +describe("instance-reload hasBusySession", () => { + it("returns false for an empty map", () => { + expect(hasBusySession(new Map(entries([])))).toBe(false) + }) + + it("returns false when all sessions are idle", () => { + expect( + hasBusySession( + new Map( + entries([ + ["s1", { type: "idle" }], + ["s2", { type: "idle" }], + ]), + ), + ), + ).toBe(false) + }) + + it("returns true when any session is busy", () => { + expect( + hasBusySession( + new Map( + entries([ + ["s1", { type: "idle" }], + ["s2", { type: "busy" }], + ]), + ), + ), + ).toBe(true) + }) + + it("returns true when the only session is busy", () => { + expect(hasBusySession(new Map(entries([["s1", { type: "busy" }]])))).toBe(true) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index c315c6edc86..fd6e3cfc522 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -134,6 +134,8 @@ import type { IndexingWarningsResponses, InstanceDisposeErrors, InstanceDisposeResponses, + InstanceReloadErrors, + InstanceReloadResponses, InteractiveTerminalCloseErrors, InteractiveTerminalCloseResponses, InteractiveTerminalGetErrors, @@ -2257,6 +2259,36 @@ export class Instance extends HeyApiClient { ...params, }) } + + /** + * Reload instance + * + * Atomically dispose and reboot the current Kilo instance, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if a session is actively running. + */ + public reload( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/reload", + ...options, + ...params, + }) + } } export class Path extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5282b260f4c..05b86ba1d58 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2571,6 +2571,12 @@ export type KiloEmbeddingModelCatalog = { } } +export type ConflictError = { + _tag: "ConflictError" + message: string + resource?: string +} + export type InteractiveTerminalSnapshot = { info: InteractiveTerminalInfo output: string @@ -10588,6 +10594,38 @@ export type IndexingModelsResponses = { export type IndexingModelsResponse = IndexingModelsResponses[keyof IndexingModelsResponses] +export type InstanceReloadData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/reload" +} + +export type InstanceReloadErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * ConflictError + */ + 409: ConflictError +} + +export type InstanceReloadError = InstanceReloadErrors[keyof InstanceReloadErrors] + +export type InstanceReloadResponses = { + /** + * Instance reloaded + */ + 200: boolean +} + +export type InstanceReloadResponse = InstanceReloadResponses[keyof InstanceReloadResponses] + export type InteractiveTerminalListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 267d53bb1d7..e99bbffe086 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13085,6 +13085,71 @@ ] } }, + "/instance/reload": { + "post": { + "tags": ["instance-reload"], + "operationId": "instance.reload", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Instance reloaded", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Instance reloaded" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Atomically dispose and reboot the current Kilo instance, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if a session is actively running.", + "summary": "Reload instance", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.instance.reload({\n ...\n})" + } + ] + } + }, "/interactive-terminal": { "get": { "tags": ["interactive-terminal"], @@ -27801,6 +27866,23 @@ "required": ["defaultModel", "models", "aliases"], "additionalProperties": false }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ConflictError"] + }, + "message": { + "type": "string" + }, + "resource": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, "InteractiveTerminalSnapshot": { "type": "object", "properties": { @@ -36967,6 +37049,10 @@ "name": "indexing", "description": "Kilo indexing routes." }, + { + "name": "instance-reload", + "description": "Kilo instance reload route." + }, { "name": "interactive-terminal", "description": "Kilo human-driven interactive terminal routes." From 64deacf66c59224b7c54a8995ed1540424d8e18a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Jul 2026 12:45:11 +0200 Subject: [PATCH 046/107] feat: translate common.reload, use circular reload icon, add tooltip - Add common.reload translations for all 18 non-English locales - Add a circular reload icon to kilo-ui's icon map (the standard two-arrow refresh symbol) and use it on both the task header and settings reload buttons instead of the reset (undo) icon - Add a tooltip to the settings reload button reusing common.reload, matching the existing tooltip on the task header button --- packages/kilo-ui/src/components/icon.tsx | 4 ++++ .../webview-ui/src/components/chat/TaskHeader.tsx | 2 +- .../src/components/settings/Settings.tsx | 15 +++++++-------- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/br.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/da.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/de.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/es.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/it.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/no.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/th.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 22 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 773d17d1f5c..87172ed841d 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -42,6 +42,10 @@ const icons: Record = { viewBox: "0 0 16 16", path: ``, }, + reload: { + viewBox: "0 0 24 24", + path: ``, + }, } type Name = keyof typeof icons diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index cd43ce32808..cccaa7ab721 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -214,7 +214,7 @@ export const TaskHeader: Component = (props) => { vscode.postMessage({ type: "reload" })} 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 9564a0fe750..e65e7005edd 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -2,6 +2,7 @@ import { Component, createSignal, createEffect, createMemo, on, Show } from "sol import { Icon } from "@kilocode/kilo-ui/icon" import { Tabs } from "@kilocode/kilo-ui/tabs" import { Button } from "@kilocode/kilo-ui/button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { showToast } from "@kilocode/kilo-ui/toast" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" @@ -143,14 +144,12 @@ const Settings: Component = (props) => { - + + +
{/* Settings tabs */} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index a87c8ac9b6c..29504be4723 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1183,6 +1183,7 @@ export const dict = { "common.retry": "إعادة المحاولة", "common.refresh": "تحديث", + "common.reload": "إعادة التحميل", "profile.title": "الملف الشخصي", "profile.notLoggedIn": "لم يتم تسجيل الدخول", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 5daf082020e..c19f9f4aa11 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1204,6 +1204,7 @@ export const dict = { "common.retry": "Tentar novamente", "common.refresh": "Atualizar", + "common.reload": "Recarregar", "profile.title": "Perfil", "profile.notLoggedIn": "Não conectado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a5a894c7f0f..5aa114866a3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1245,6 +1245,7 @@ export const dict = { "common.retry": "Pokušaj ponovo", "common.refresh": "Osvježi", + "common.reload": "Učitaj ponovo", "profile.title": "Profil", "profile.notLoggedIn": "Niste prijavljeni", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 14b21e2ab80..8a8c8400973 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1238,6 +1238,7 @@ export const dict = { "common.retry": "Prøv igen", "common.refresh": "Opdatér", + "common.reload": "Genindlæs", "profile.title": "Profil", "profile.notLoggedIn": "Ikke logget ind", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c3ebe2962b..8e2eb24669f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1259,6 +1259,7 @@ export const dict = { "common.retry": "Erneut versuchen", "common.refresh": "Aktualisieren", + "common.reload": "Neu laden", "profile.title": "Profil", "profile.notLoggedIn": "Nicht angemeldet", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a5bf4b39bd0..58221c028dc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1252,6 +1252,7 @@ export const dict = { "common.retry": "Reintentar", "common.refresh": "Actualizar", + "common.reload": "Recargar", "profile.title": "Perfil", "profile.notLoggedIn": "No conectado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e793315324c..df7e722b0ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1262,6 +1262,7 @@ export const dict = { "common.retry": "Réessayer", "common.refresh": "Actualiser", + "common.reload": "Recharger", "profile.title": "Profil", "profile.notLoggedIn": "Non connecté", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5e1079426a4..dba78c79c9b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1006,6 +1006,7 @@ export const dict = { "deviceAuth.error.detailsTitle": "Dettagli errore accesso", "common.retry": "Riprova", "common.refresh": "Aggiorna", + "common.reload": "Ricarica", "profile.title": "Profilo", "profile.notLoggedIn": "Accesso non effettuato", "profile.action.login": "Accedi con Kilo Code", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 5483784d610..e51d83a2ead 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1233,6 +1233,7 @@ export const dict = { "common.retry": "再試行", "common.refresh": "更新", + "common.reload": "再読み込み", "profile.title": "プロフィール", "profile.notLoggedIn": "ログインしていません", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1c68d5d743a..d07c77309fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1190,6 +1190,7 @@ export const dict = { "common.retry": "재시도", "common.refresh": "새로고침", + "common.reload": "새로고침", "profile.title": "프로필", "profile.notLoggedIn": "로그인하지 않음", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 845b5d5a162..968029377c2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1198,6 +1198,7 @@ export const dict = { "common.retry": "Opnieuw proberen", "common.refresh": "Vernieuwen", + "common.reload": "Herladen", "profile.title": "Profiel", "profile.notLoggedIn": "Niet ingelogd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 775d9dc7e6e..f31d7ad4589 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1202,6 +1202,7 @@ export const dict = { "common.retry": "Prøv igjen", "common.refresh": "Oppdater", + "common.reload": "Last inn på nytt", "profile.title": "Profil", "profile.notLoggedIn": "Ikke pålogget", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d3fa876fb7b..91201db107b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1201,6 +1201,7 @@ export const dict = { "common.retry": "Ponów", "common.refresh": "Odśwież", + "common.reload": "Przeładuj", "profile.title": "Profil", "profile.notLoggedIn": "Nie zalogowano", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6f616a81d8c..1655da15764 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1244,6 +1244,7 @@ export const dict = { "common.retry": "Повторить", "common.refresh": "Обновить", + "common.reload": "Перезагрузить", "profile.title": "Профиль", "profile.notLoggedIn": "Не авторизован", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index e5ec7f48ed4..ade94cd6721 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1226,6 +1226,7 @@ export const dict = { "common.retry": "ลองอีกครั้ง", "common.refresh": "รีเฟรช", + "common.reload": "โหลดใหม่", "profile.title": "โปรไฟล์", "profile.notLoggedIn": "ยังไม่ได้เข้าสู่ระบบ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e57d018906b..a0e4038b14a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1195,6 +1195,7 @@ export const dict = { "common.retry": "Tekrar Dene", "common.refresh": "Yenile", + "common.reload": "Yeniden yükle", "profile.title": "Profil", "profile.notLoggedIn": "Giriş yapılmadı", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 06c0f00dfa4..d0609a1dea6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1193,6 +1193,7 @@ export const dict = { "common.retry": "Спробувати ще раз", "common.refresh": "Оновити", + "common.reload": "Перезавантажити", "profile.title": "Профіль", "profile.notLoggedIn": "Не увійшов", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 3f0eafa95e2..50a25a2ede7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1206,6 +1206,7 @@ export const dict = { "common.retry": "重试", "common.refresh": "刷新", + "common.reload": "重新加载", "profile.title": "个人资料", "profile.notLoggedIn": "未登录", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d50689f1f76..3659703f728 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1170,6 +1170,7 @@ export const dict = { "common.retry": "重試", "common.refresh": "重新整理", + "common.reload": "重新載入", "profile.title": "個人資料", "profile.notLoggedIn": "尚未登入", From 02cc08a884904f8fca9a3554bb84fabb26fc02de Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:06:02 +0000 Subject: [PATCH 047/107] docs(kilo-docs): match Authentication page title to nav label --- packages/kilo-docs/pages/automate/agent-manager.md | 2 +- packages/kilo-docs/pages/getting-started/quickstart.md | 2 +- .../kilo-docs/pages/getting-started/setup-authentication.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index f190c92763e..446672c539b 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -39,7 +39,7 @@ The panel opens as an editor tab and stays active across focus changes. Agent Manager uses the same sign-in, provider settings, models, BYOK keys, custom providers, MCP servers, and permission rules as the extension sidebar. Configure them from extension Settings and they apply to Agent Manager as well. -See [Setup & Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details. +See [Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details. ## Working with Worktrees diff --git a/packages/kilo-docs/pages/getting-started/quickstart.md b/packages/kilo-docs/pages/getting-started/quickstart.md index 200f263abbb..1b567df31f9 100644 --- a/packages/kilo-docs/pages/getting-started/quickstart.md +++ b/packages/kilo-docs/pages/getting-started/quickstart.md @@ -61,7 +61,7 @@ Run the `kilo` command to start the interactive TUI (terminal user interface): kilo ``` -If this is your first time, run `kilo auth login` first to authenticate (see [Setup & Authentication](/docs/getting-started/setup-authentication)). +If this is your first time, run `kilo auth login` first to authenticate (see [Authentication](/docs/getting-started/setup-authentication)). ### Step 3: Type Your Task diff --git a/packages/kilo-docs/pages/getting-started/setup-authentication.md b/packages/kilo-docs/pages/getting-started/setup-authentication.md index 1085f660e77..55946f5fedc 100644 --- a/packages/kilo-docs/pages/getting-started/setup-authentication.md +++ b/packages/kilo-docs/pages/getting-started/setup-authentication.md @@ -1,9 +1,9 @@ --- -title: "Setup & Authentication" +title: "Authentication" description: "Configure Kilo Code and connect to your AI providers" --- -# Setup & Authentication +# Authentication When you install Kilo Code, you'll be prompted to sign in or create a free account. This automatically configures everything you need to get started. From cd94ffe67ec18ba46626aa0b78457d02b4905948 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:07:23 +0000 Subject: [PATCH 048/107] docs(kilo-docs): match Quickstart page title to nav label --- packages/kilo-docs/pages/getting-started/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/getting-started/quickstart.md b/packages/kilo-docs/pages/getting-started/quickstart.md index 1b567df31f9..98354ee1520 100644 --- a/packages/kilo-docs/pages/getting-started/quickstart.md +++ b/packages/kilo-docs/pages/getting-started/quickstart.md @@ -1,9 +1,9 @@ --- -title: "Quickstart" +title: "Your First Task" description: "Get up and running with Kilo Code in minutes" --- -# Quickstart +# Your First Task After you [set up Kilo Code](/docs/getting-started/setup-authentication), follow the guide for your platform below. From 1c31d7ea4925fd9993bb127abe67b6cfeee06f32 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Jul 2026 13:22:30 +0200 Subject: [PATCH 049/107] fix: address review feedback on reload handler - Block reload for retry and offline session states, not just busy (hasActiveSession rejects any non-idle state) - Close TOCTOU race by calling store.reload inline instead of deferring to a pre-response handler, so the busy check and reload run in the same effect - Avoid double-firing reloadAfterAuthChange: only call it eagerly for worktree sessions where the SSE directory check won't match; for the workspace root the existing SSE handler already fires it - Replace err: any with unknown + narrowing - Add in-flight guard to the CLI /reload plugin to prevent overlapping reloads from rapid double-invocation - Remove the TaskHeader reload button (redundant with /reload slash command, avoids chat visual regression baseline churn) --- packages/kilo-vscode/src/KiloProvider.ts | 11 ++++-- .../src/components/chat/TaskHeader.tsx | 9 ----- .../opencode/src/kilocode/plugins/reload.tsx | 5 +++ .../httpapi/handlers/instance-reload.ts | 11 +++--- .../test/kilocode/instance-reload.test.ts | 38 +++++++++++++++---- 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c044c6f637d..8358930f059 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -3620,8 +3620,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const dir = this.getWorkspaceDirectory(this.currentSession?.id) try { await this.client.instance.reload({ directory: dir }, { throwOnError: true }) - } catch (err: any) { - const status = err?.response?.status + } catch (err) { + const status = + err && typeof err === "object" && "response" in err + ? (err as { response?: { status?: number } }).response?.status + : undefined if (status === 409) { vscode.window.showWarningMessage( "Cannot reload while a session is running. Wait for it to finish or abort it first.", @@ -3633,7 +3636,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } this.clearCommandsCache() - await this.reloadAfterAuthChange() + if (!sameDirectory(dir, this.getWorkspaceDirectory())) { + await this.reloadAfterAuthChange() + } } /** Public reload entry point for VS Code commands. */ diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index cccaa7ab721..96ac022f93f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -212,15 +212,6 @@ export const TaskHeader: Component = (props) => { aria-label={language.t("command.session.compact")} /> - - vscode.postMessage({ type: "reload" })} - aria-label={language.t("common.reload")} - /> -
- +
- - - - {(path, index) => ( -
- + {(path, index) => ( +
- {path} - - removePath(index())} /> -
- )} - -
- + + {path} + + removePath(index())} /> + + )} +
+ + + ) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index e405398721d..467282a3463 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -242,6 +242,41 @@ describe("kilocode indexing config", () => { }) }) +describe("kilocode sandbox writable paths config", () => { + test("honors sandbox_writable_paths from global config only, ignoring project config", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + experimental: { sandbox_writable_paths: ["/tmp/global"] }, + }) + // A project kilo.json must not widen the sandbox: its writable paths are dropped at merge time. + await writeConfig(tmp.path, { + experimental: { sandbox_writable_paths: ["/tmp/project"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"]) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) +}) + describe("custom provider model config", () => { test("persists and removes reasoning across a global config reload", async () => { await using globalTmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/sandbox/policy.test.ts b/packages/opencode/test/kilocode/sandbox/policy.test.ts index 03523526999..240c1e1f388 100644 --- a/packages/opencode/test/kilocode/sandbox/policy.test.ts +++ b/packages/opencode/test/kilocode/sandbox/policy.test.ts @@ -234,4 +234,34 @@ describe("sandbox policy", () => { expect(roots(ctx)).not.toContain(dirs.approved) expect(Exit.isFailure(result)).toBe(true) }) + + test("makes configured extra writable paths writable while unlisted paths stay denied", async () => { + await using tmp = await fixture() + const dirs = tmp.extra + const ctx = context(dirs.a, dirs.a, dirs) + const policy = profile(ctx, "deny", [dirs.approved]) + const result = await Effect.runPromise( + Effect.all({ + extra: runSandbox(policy, assertWrite(path.join(dirs.approved, "allowed.txt")).pipe(Effect.exit)), + other: runSandbox(policy, assertWrite(path.join(dirs.b, "denied.txt")).pipe(Effect.exit)), + }), + ) + + expect(policy.filesystem.allowWrite.map((rule) => rule.path)).toContain(dirs.approved) + expect(roots(ctx)).not.toContain(dirs.approved) + expect(Exit.isSuccess(result.extra)).toBe(true) + expect(Exit.isFailure(result.other)).toBe(true) + }) + + test("keeps .git denied inside a configured extra writable path", async () => { + await using tmp = await fixture() + const dirs = tmp.extra + const ctx = context(dirs.a, dirs.a, dirs) + const policy = profile(ctx, "deny", [dirs.approved]) + const result = await Effect.runPromise( + runSandbox(policy, assertWrite(path.join(dirs.approved, ".git", "config")).pipe(Effect.exit)), + ) + + expect(Exit.isFailure(result)).toBe(true) + }) }) From d70977fd92dba79a52176a1a7ddaa4cd11a7de00 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 7 Jul 2026 14:38:02 +0200 Subject: [PATCH 062/107] test: cover Kilo account selection defaults --- packages/kilo-gateway/src/index.ts | 1 + packages/kilo-gateway/src/server/handlers.ts | 36 +++++++---- .../kilo-gateway/test/server/handlers.test.ts | 59 +++++++++++++++++++ .../src/components/profile/ProfileView.tsx | 8 +-- .../components/dialog-kilo-auto-method.tsx | 12 ++-- .../server/httpapi/handlers/kilo-gateway.ts | 16 ++--- 6 files changed, 102 insertions(+), 30 deletions(-) create mode 100644 packages/kilo-gateway/test/server/handlers.test.ts diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 850047c4dd1..034902c614a 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -75,6 +75,7 @@ export { getCloudSessions, getNotifications, getProfile, + resolveProfileOrganization, getToken, normalizeClawStatus, setOrganization, diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts index ac8b5d2907e..dc0a5424fd8 100644 --- a/packages/kilo-gateway/src/server/handlers.ts +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -36,6 +36,11 @@ export interface OrganizationDeps { dispose(): Promise } +export interface OrganizationResolution { + currentOrgId: string | null + persistOrgId: string | null +} + export interface CloudSessionsInput { cursor?: string limit?: number @@ -64,6 +69,22 @@ export function getOrganizationId(auth: KiloAuth | undefined) { return undefined } +export function resolveProfileOrganization(profile: KilocodeProfile, auth: Extract): OrganizationResolution { + const selected = profile.selectedOrganizationId + const orgs = profile.organizations ?? [] + const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined + const cloud = valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined) + if (auth.accountSelection === "manual" && (auth.accountId || profile.hasPersonalAccount !== false)) { + return { currentOrgId: auth.accountId ?? null, persistOrgId: null } + } + + const local = auth.accountSelection === "cloud" ? undefined : auth.accountId + const currentOrgId = local ?? cloud ?? auth.accountId ?? null + const invalid = auth.accountSelection === "manual" && !auth.accountId && profile.hasPersonalAccount === false + const persistOrgId = currentOrgId && !local && (auth.accountSelection !== "manual" || invalid) && currentOrgId !== auth.accountId ? currentOrgId : null + return { currentOrgId, persistOrgId } +} + export async function getProfile(auth: AuthStore): Promise { const info = await auth.get("kilo") if (!info || info.type !== "oauth") throw new UnauthorizedError("Not authenticated with Kilo Gateway") @@ -73,25 +94,20 @@ export async function getProfile(auth: AuthStore): Promise { fetchKiloPassState(info.access), ]) - const selected = profile.selectedOrganizationId - const orgs = profile.organizations ?? [] - const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined - const cloud = valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined) - const local = info.accountSelection === "cloud" ? undefined : info.accountId - const currentOrgId = info.accountSelection === "manual" ? (info.accountId ?? null) : (local ?? cloud ?? info.accountId ?? null) - if (currentOrgId && !local && info.accountSelection !== "manual" && currentOrgId !== info.accountId) { + const org = resolveProfileOrganization(profile, info) + if (org.persistOrgId) { await auth.set("kilo", { type: "oauth", refresh: info.refresh, access: info.access, expires: info.expires, - accountId: currentOrgId, + accountId: org.persistOrgId, accountSelection: "cloud", }).catch((err) => console.warn("Failed to persist cloud account selection:", err)) } - const balance = await fetchBalance(info.access, currentOrgId ?? undefined) - return { profile, balance, kiloPass, currentOrgId } + const balance = await fetchBalance(info.access, org.currentOrgId ?? undefined) + return { profile, balance, kiloPass, currentOrgId: org.currentOrgId } } export async function getNotifications(auth: AuthStore) { diff --git a/packages/kilo-gateway/test/server/handlers.test.ts b/packages/kilo-gateway/test/server/handlers.test.ts new file mode 100644 index 00000000000..0b888c9ca88 --- /dev/null +++ b/packages/kilo-gateway/test/server/handlers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { resolveProfileOrganization, type KiloAuth } from "../../src/server/handlers.js" +import type { KilocodeProfile } from "../../src/types.js" + +const oauth = (input: Partial> = {}): Extract => ({ + type: "oauth", + access: "token", + refresh: "token", + expires: 1, + ...input, +}) + +const profile = (input: Partial = {}): KilocodeProfile => ({ + email: "user@example.com", + organizations: [{ id: "org_1", name: "Acme", role: "MEMBER" }], + ...input, +}) + +describe("resolveProfileOrganization", () => { + test("uses cloud selected organization only when no local selection exists", () => { + expect(resolveProfileOrganization(profile({ selectedOrganizationId: "org_1" }), oauth())).toEqual({ + currentOrgId: "org_1", + persistOrgId: "org_1", + }) + }) + + test("preserves a manual organization selection over the cloud default", () => { + expect( + resolveProfileOrganization( + profile({ + selectedOrganizationId: "org_1", + organizations: [ + { id: "org_1", name: "Acme", role: "MEMBER" }, + { id: "org_2", name: "Beta", role: "MEMBER" }, + ], + }), + oauth({ accountId: "org_2", accountSelection: "manual" }), + ), + ).toEqual({ currentOrgId: "org_2", persistOrgId: null }) + }) + + test("preserves explicit personal selection when personal is available", () => { + expect( + resolveProfileOrganization( + profile({ selectedOrganizationId: "org_1", hasPersonalAccount: true }), + oauth({ accountSelection: "manual" }), + ), + ).toEqual({ currentOrgId: null, persistOrgId: null }) + }) + + test("uses the first organization when personal is unavailable and no valid cloud selection exists", () => { + expect( + resolveProfileOrganization( + profile({ selectedOrganizationId: "missing", hasPersonalAccount: false }), + oauth({ accountSelection: "manual" }), + ), + ).toEqual({ currentOrgId: "org_1", persistOrgId: "org_1" }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx index bed2bfbc987..436784c75c0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx @@ -44,6 +44,8 @@ const ProfileView: Component = (props) => { const language = useLanguage() const [target, setTarget] = createSignal(null) + const personal = createMemo(() => props.profileData?.profile.hasPersonalAccount !== false) + // Always fetch fresh profile+balance when navigating to this view onMount(() => { vscode.postMessage({ type: "refreshProfile" }) @@ -58,16 +60,14 @@ const ProfileView: Component = (props) => { const orgOptions = createMemo(() => { const orgs = props.profileData?.profile.organizations ?? [] if (orgs.length === 0) return [] - const personal = props.profileData?.profile.hasPersonalAccount !== false return [ - ...(personal ? [{ value: PERSONAL, label: language.t("profile.personalAccount") }] : []), + ...(personal() ? [{ value: PERSONAL, label: language.t("profile.personalAccount") }] : []), ...orgs.map((org) => ({ value: org.id, label: org.name, description: org.role })), ] }) const currentId = createMemo(() => { - const personal = props.profileData?.profile.hasPersonalAccount !== false - return props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value) + return props.profileData?.currentOrgId ?? (personal() ? PERSONAL : orgOptions()[0]?.value) }) const switching = createMemo(() => { diff --git a/packages/opencode/src/kilocode/components/dialog-kilo-auto-method.tsx b/packages/opencode/src/kilocode/components/dialog-kilo-auto-method.tsx index 533571d02e4..239ea52eecc 100644 --- a/packages/opencode/src/kilocode/components/dialog-kilo-auto-method.tsx +++ b/packages/opencode/src/kilocode/components/dialog-kilo-auto-method.tsx @@ -83,12 +83,12 @@ export function KiloAutoMethod(props: KiloAutoMethodProps) { await sync.bootstrap() dialog.replace(() => ( - diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index bd610d3595a..7e4d9968c37 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -7,6 +7,7 @@ import { getToken, importSessionToDb, normalizeClawStatus, + resolveProfileOrganization, } from "@kilocode/kilo-gateway" import { HEADER_FEATURE, @@ -76,28 +77,23 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", catch: () => new HttpApiError.BadRequest({}), }) - const selected = profile.selectedOrganizationId - const orgs = profile.organizations ?? [] - const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined - const cloud = valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined) - const local = info.accountSelection === "cloud" ? undefined : info.accountId - const currentOrgId = info.accountSelection === "manual" ? (info.accountId ?? null) : (local ?? cloud ?? info.accountId ?? null) - if (currentOrgId && !local && info.accountSelection !== "manual" && currentOrgId !== info.accountId) { + const org = resolveProfileOrganization(profile, info) + if (org.persistOrgId) { yield* auth.set("kilo", { type: "oauth", refresh: info.refresh, access: info.access, expires: info.expires, - accountId: currentOrgId, + accountId: org.persistOrgId, accountSelection: "cloud", }).pipe(Effect.catch((err) => Effect.sync(() => log.warn("failed to persist cloud account selection", { err })))) } const balance = yield* Effect.tryPromise({ - try: () => fetchBalance(info.access, currentOrgId ?? undefined), + try: () => fetchBalance(info.access, org.currentOrgId ?? undefined), catch: () => new HttpApiError.BadRequest({}), }) - return { profile, balance, kiloPass, currentOrgId } + return { profile, balance, kiloPass, currentOrgId: org.currentOrgId } }) const authStatus = Effect.fn("KiloGatewayHttpApi.authStatus")(function* () { From 21848889cdee0f0e485780bd2fd97be1cc68ef61 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Jul 2026 14:41:05 +0200 Subject: [PATCH 063/107] feat(vscode): render read-tool images inline in chat view The read tool already produces base64 data URL attachments for image files and threads them through SSE into the webview part store, but the tool renderer never received them. Thread part.state.attachments into ToolProps and render image attachments inline below the read tool card, reusing the existing user-attachment image pattern with click-to-open preview. No backend, schema, or SDK changes needed. --- .changeset/inline-read-images.md | 5 ++++ .../kilo-ui/src/components/message-part.css | 29 +++++++++++++++++++ .../kilo-ui/src/components/message-part.tsx | 25 ++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .changeset/inline-read-images.md diff --git a/.changeset/inline-read-images.md b/.changeset/inline-read-images.md new file mode 100644 index 00000000000..7d4a511f0d0 --- /dev/null +++ b/.changeset/inline-read-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Render images inline in the chat view when the agent reads an image file. Images appear below the read tool card and can be clicked to open a full-size preview. diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 5a3fd732da0..d55bc2c4ce9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -412,6 +412,35 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { margin-bottom: 0px; } +[data-slot="tool-read-images"] { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 8px; + max-width: min(100%, 64ch); +} + +[data-slot="tool-read-image"] { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius-sm, 6px); + overflow: hidden; + border: 1px solid var(--border-weak-base); + cursor: pointer; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--border-strong-base); + } +} + +[data-slot="tool-read-image-img"] { + display: block; + max-width: 100%; + max-height: 200px; + object-fit: contain; +} + [data-component="todos"] { [data-slot="message-part-todo-hidden"] { padding: 2px 0 2px 28px; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 7fca6106088..a5ab17ed51d 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -995,6 +995,7 @@ export interface ToolProps { callID?: string output?: string status?: string + attachments?: FilePart[] hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean @@ -1243,6 +1244,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { // @ts-expect-error output={part.state.output} status={part.state.status} + // @ts-expect-error + attachments={part.state.attachments} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} animate @@ -1746,6 +1749,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const dialog = useDialog() const args: string[] = [] if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) @@ -1755,6 +1759,10 @@ ToolRegistry.register({ return value.filter((p): p is string => typeof p === "string") }) const pending = createMemo(() => busy(props.status)) + const images = createMemo(() => + (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url), + ) + const preview = (url: string, alt?: string) => dialog.show(() => ) return ( <> )} + 0}> +
+ + {(file) => ( +
preview(file.url, file.filename)}> + {file.filename +
+ )} +
+
+
) }, From 0ab3702cf706bd2b50dc528871f773a0182cca0d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Jul 2026 14:35:14 +0200 Subject: [PATCH 064/107] fix(vscode): render variant dropdown story inline for visual regression The dialog.show() approach portaled the dialog to document.body (outside #storybook-root), and Kobalte's modal set aria-hidden on #storybook-root, causing Playwright's toHaveScreenshot to time out (element not visible). Replace with an inline fixture that reproduces the real CSS clipping chain: .am-nv-dialog > .am-nv-dialog-content > .am-prompt-input-container (position: relative, overflow: hidden from agent-manager.css) with the real inline ThinkingSelectorBase (portal={false}). The variant picker opens upward; with the overflow escape fix the popover renders fully above the container, without it floating-ui repositions the popover lower. The screenshot baseline captures the fixed state and catches any regression. --- .../src/stories/agent-manager.stories.tsx | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) 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 b35303e1273..4f2aef6e2dd 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 @@ -10,8 +10,6 @@ import { FileTree } from "../../diff-viewer/FileTree" import { DiffPanel } from "../../agent-manager/DiffPanel" import { FullScreenDiffView } from "../../diff-viewer/FullScreenDiffView" import { WorktreeItem } from "../../agent-manager/WorktreeItem" -import { NewWorktreeDialog } from "../../agent-manager/NewWorktreeDialog" -import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { ChatView } from "../components/chat/ChatView" import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides" import { SessionContext } from "../context/session" @@ -25,6 +23,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Icon } from "@kilocode/kilo-ui/icon" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" +import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector" import { createSignal, onCleanup, onMount, type JSX } from "solid-js" import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages" import type { ReviewComment } from "../../diff-viewer/review-comments" @@ -932,40 +931,67 @@ export const TabBarSingleTab: Story = { } // --------------------------------------------------------------------------- -// NewWorktreeDialog — variant dropdown must escape the dialog scroll containers -// (regression: inline popovers were clipped by .am-nv-dialog-content overflow) +// NewWorktreeDialog — inline selector popovers must escape the dialog scroll +// containers. Regression: the reasoning-variant and mode pickers were clipped +// by .am-nv-dialog-content (overflow-y: auto) and .am-prompt-input-container +// (overflow: hidden) because the overflow escape hatch only covered the model +// picker. This fixture reproduces the real clipping chain (same CSS classes + +// the real inline ThinkingSelectorBase with portal={false}) so a screenshot +// baseline catches any future regression. Rendered inline (no dialog portal) +// because the visual-regression harness screenshots #storybook-root. // --------------------------------------------------------------------------- -const NewWorktreeVariantOpener = () => { - const dialog = useDialog() +const VariantPickerOpener = () => { + let frame = 0 + let attempts = 0 const open = () => { if (document.querySelector("[data-component='popover-content']")) return + if (attempts++ >= 120) return window.dispatchEvent(new CustomEvent("openVariantPicker")) - requestAnimationFrame(open) + frame = requestAnimationFrame(open) } onMount(() => { - dialog.show(() => {}} />) - requestAnimationFrame(open) + frame = requestAnimationFrame(open) }) + onCleanup(() => cancelAnimationFrame(frame)) return null } export const NewWorktreeVariantDropdown1280: Story = { name: "NewWorktreeDialog — variant dropdown open", parameters: { layout: "fullscreen" }, - render: () => { - const session = { - ...mockSessionValue(), - configModel: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), - } - return ( - - - - - - ) - }, + render: () => ( + + {/* Filler pushes the prompt container to the bottom of the dialog content. + The variant popover opens upward from the trigger, extending above the + container's top edge. Without the overflow escape fix, .am-prompt-input-container + (overflow: hidden + position: relative) clips the top of the popover. */} +
+
+
+
+
+
+
+ {}} + portal={false} + deferDismiss + /> +
+
+
+
+
+
+ + + ), } const searchSection = { id: "polish", name: "Polish", color: "Blue", order: 0, collapsed: false } From 62c41e21c6cff2ef9686de5ef678de33173c54bc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 08:48:21 -0400 Subject: [PATCH 065/107] fix(jetbrains): prune old CLI binaries from the download cache Each pinned CLI version was cached under the IDE system dir forever, so old versions accumulated across plugin updates. After a version resolves successfully, delete every other cached version and drop the downloaded archive after extraction. Reinstall still deletes and re-downloads the active version. --- .changeset/jetbrains-prune-old-cli.md | 5 ++ .../kilocode/backend/cli/KiloCliDownloader.kt | 22 +++++++++ .../backend/cli/KiloCliDownloaderTest.kt | 46 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .changeset/jetbrains-prune-old-cli.md diff --git a/.changeset/jetbrains-prune-old-cli.md b/.changeset/jetbrains-prune-old-cli.md new file mode 100644 index 00000000000..07600c3f7eb --- /dev/null +++ b/.changeset/jetbrains-prune-old-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Remove old JetBrains CLI binaries so they no longer accumulate in the IDE cache. Only the active version is kept, the downloaded archive is deleted after extraction, and reinstalling re-downloads a fresh binary. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index f7a6e063819..b50204f2ed5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -51,6 +51,7 @@ class KiloCliDownloader( if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) { log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") if (!SystemInfo.isWindows) exe.setExecutable(true) + prune(version) return@withContext exe } @@ -75,8 +76,12 @@ class KiloCliDownloader( extract(archive, dir) if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") if (!SystemInfo.isWindows) exe.setExecutable(true) + if (archive.exists() && !archive.delete()) { + log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") + } done.writeText("$digest\n") onProgress(CliDownload(100, version, platform)) + prune(version) exe } @@ -223,6 +228,23 @@ class KiloCliDownloader( } } + /** + * Delete every cached CLI version under [root] except [keep]. Each plugin update pins a new + * CLI version, so without this old versions would accumulate in the IDE system directory + * indefinitely. Runs only after the active version resolved successfully so a failed download + * never wipes the last working copy. + */ + private fun prune(keep: String) { + val entries = root.listFiles() ?: return + for (entry in entries) { + if (!entry.isDirectory || entry.name == keep) continue + log.info("Removing stale Kilo CLI version ${entry.absolutePath}") + if (!entry.deleteRecursively()) { + log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}") + } + } + } + private fun url(version: String, platform: String, ext: String) = "${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext" } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 6a2e94bc02b..b2cc0369ef2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -87,6 +87,52 @@ class KiloCliDownloaderTest { } } + @Test + fun `prunes stale versions and removes the extracted archive`() = runBlocking { + MockWebServer().use { server -> + val stale = File(File(dir, "0.0.1"), KiloCliPlatform.current()) + assertTrue(stale.mkdirs()) + File(stale, "leftover").writeText("old") + + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + + assertTrue(cli.isFile) + assertFalse(File(dir, "0.0.1").exists()) + val archived = File(cli.parentFile.parentFile, "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}") + assertFalse(archived.exists()) + } + } + + @Test + fun `forced resolve re-downloads and keeps only the active version`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + repeat(2) { + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + } + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ) + cli.resolve("1.2.3") + assertEquals(2, server.requestCount) + + val forced = cli.resolve("1.2.3", force = true) + assertTrue(forced.isFile) + assertEquals(4, server.requestCount) + assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory }?.map { it.name }) + } + } + @Test fun `rejects cli archive with mismatched digest`() = runBlocking { MockWebServer().use { server -> From a659bb2571bd5f7b52eceadbb42a975fe31aa7c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Jul 2026 12:50:04 +0000 Subject: [PATCH 066/107] chore: update kilo-vscode visual regression baselines --- .../new-worktree-variant-dropdown-1280-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png new file mode 100644 index 00000000000..2b594d86ac1 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f7edbc1bf6f10260626bf21406bb02e32dc17bf969c83e8b56251c50f755a1f +size 7737 From bd29b94d20bb6639d55f6ac009c793f6e313030d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 08:52:25 -0400 Subject: [PATCH 067/107] fix(jetbrains): report exact CLI release resolution failure The collapsed metadata lookup threw a misleading "did not include" error whether the asset was absent, had no digest, or had a malformed digest. Split into distinct, logged messages so the actual cause is clear, including the nullable-digest case where GitHub has not published a checksum. --- .../kilocode/backend/cli/KiloCliDownloader.kt | 21 +++++++-- .../backend/cli/KiloCliDownloaderTest.kt | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index b50204f2ed5..269331e13ce 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -85,6 +85,11 @@ class KiloCliDownloader( exe } + private fun fail(message: String): Nothing { + log.warn(message) + throw IllegalStateException(message) + } + private fun asset(version: String, platform: String, ext: String): String { val name = "kilo-$platform.$ext" val url = "${api.trimEnd('/')}/v$version" @@ -112,12 +117,18 @@ class KiloCliDownloader( log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" } val body = response.body?.string() ?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body") - val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray - ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } - ?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull - ?: throw IllegalStateException("Kilo CLI release $version did not include $name") + val assets = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + val entry = assets?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + if (entry == null) { + val names = assets?.mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull }?.joinToString(", ") + fail("Kilo CLI release $version has no asset named $name (available assets: ${names ?: "none"})") + } + val digest = entry.jsonObject["digest"]?.jsonPrimitive?.contentOrNull + if (digest == null) { + fail("Kilo CLI release $version asset $name has no digest yet; GitHub has not published a SHA-256 checksum for it") + } if (!digest.matches(DIGEST)) { - throw IllegalStateException("Kilo CLI release $version asset $name has invalid digest") + fail("Kilo CLI release $version asset $name has a malformed digest '$digest'; expected sha256:<64 hex chars>") } return digest } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index b2cc0369ef2..face684e5f7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -153,6 +153,52 @@ class KiloCliDownloaderTest { } } + @Test + fun `fails clearly and logs when the release has no matching asset`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"other.zip","digest":"sha256:${"a".repeat(64)}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}" + assertContains(ex.message.orEmpty(), "has no asset named $name") + assertContains(ex.message.orEmpty(), "other.zip") + assertTrue(log.messages.any { it.contains("has no asset named $name") }) + } + } + + @Test + fun `fails clearly and logs when the asset has no digest`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + assertContains(ex.message.orEmpty(), "has no digest yet") + assertTrue(log.messages.any { it.contains("has no digest yet") }) + } + } + @Test fun `reports github api rate limits while resolving metadata`() = runBlocking { MockWebServer().use { server -> From b0348cbc01438f603f767117ca6f2e15370e099e Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Tue, 7 Jul 2026 15:44:24 +0200 Subject: [PATCH 068/107] feat(vscode): integrate project memory into the extension (#11954) * feat(vscode): integrate project memory into the extension Wire the kilo-memory system into the VS Code extension host and webview, building on the shared client helpers and /memory command catalog in @kilocode/kilo-memory. Extension host: - KiloProviderMemory bridges the SDK memory client (status/show/enable/ disable/configure/rebuild/remember/correct/forget/purge, plus status and edit) with serialized operations and a small per-directory cache - memory.status/updated/error events fan out to the active and tracked sessions and refresh the webview - showMemory / toggleMemory commands, routed through the sidebar provider or the Agent Manager panel depending on which is active Webview: - MemoryProvider context, memory status controls in the Context settings tab, task-header and assistant-message affordances, and the /memory prompt command (help/show/operation) driven by the shared catalog - message types and i18n strings across all locales * fix(vscode): address memory PR review suggestions - serialize toggleMemory's status pre-check via KiloProviderMemory.toggle() so rapid toggles can't double-apply the same operation - collapse duplicate enabled/active memos in memory context - extract shared formatCompactCount util (was triplicated K/M formatter) - drop dead reject handler in serial() and never-produced member from MemoryOperationResultMessage.result union * fix(memory): audit cleanups: inspect accuracy, shared marker decoder, dead i18n key * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] --- .changeset/kilo-memory-vscode.md | 5 + bun.lock | 1 + ...task-header-with-memory-chromium-linux.png | 3 + packages/kilo-memory/src/marker-meta.ts | 5 +- packages/kilo-memory/test/decisions.test.ts | 14 + packages/kilo-vscode/package.json | 11 + packages/kilo-vscode/src/KiloProvider.ts | 77 +++ .../src/agent-manager/AgentManagerProvider.ts | 60 ++- .../kilo-vscode/src/agent-manager/host.ts | 2 + .../src/agent-manager/vscode-host.ts | 2 + packages/kilo-vscode/src/extension.ts | 20 + .../kilo-vscode/src/kilo-provider/memory.ts | 483 ++++++++++++++++++ .../kilo-vscode/tests/setup/vscode-mock.ts | 6 + .../agent-manager-memory-commands.test.ts | 102 ++++ .../unit/kilo-provider-memory-events.test.ts | 183 +++++++ .../tests/unit/kilo-provider-memory.test.ts | 212 ++++++++ .../tests/unit/memory-command.test.ts | 68 +++ .../agent-manager/AgentManagerApp.tsx | 17 +- packages/kilo-vscode/webview-ui/src/App.tsx | 13 +- .../src/components/chat/AssistantMessage.tsx | 64 +++ .../src/components/chat/ContextProgress.tsx | 7 +- .../src/components/chat/PromptInput.tsx | 55 ++ .../src/components/chat/TaskHeader.tsx | 77 +++ .../src/components/chat/TaskUsage.tsx | 7 +- .../src/components/settings/ContextTab.tsx | 80 ++- .../webview-ui/src/context/memory.tsx | 220 ++++++++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/br.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/bs.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/da.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/de.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 36 ++ .../kilo-vscode/webview-ui/src/i18n/es.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/fr.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/it.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/ja.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/ko.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/nl.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/no.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/pl.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/ru.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/th.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/tr.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/uk.ts | 37 ++ .../kilo-vscode/webview-ui/src/i18n/zh.ts | 36 ++ .../kilo-vscode/webview-ui/src/i18n/zht.ts | 36 ++ .../webview-ui/src/stories/StoryProviders.tsx | 51 +- .../webview-ui/src/stories/chat.stories.tsx | 47 ++ .../webview-ui/src/styles/chat-layout.css | 8 + .../webview-ui/src/styles/task-header.css | 36 ++ .../src/types/messages/extension-messages.ts | 4 + .../webview-ui/src/types/messages/index.ts | 1 + .../webview-ui/src/types/messages/memory.ts | 103 ++++ .../src/types/messages/webview-messages.ts | 5 + .../webview-ui/src/utils/format.ts | 8 + .../webview-ui/src/utils/memory-command.ts | 11 + 56 files changed, 2752 insertions(+), 53 deletions(-) create mode 100644 .changeset/kilo-memory-vscode.md create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png create mode 100644 packages/kilo-vscode/src/kilo-provider/memory.ts create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts create mode 100644 packages/kilo-vscode/tests/unit/memory-command.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/memory.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/memory.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/format.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/memory-command.ts diff --git a/.changeset/kilo-memory-vscode.md b/.changeset/kilo-memory-vscode.md new file mode 100644 index 00000000000..6dc668d915d --- /dev/null +++ b/.changeset/kilo-memory-vscode.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Integrate project memory into the VS Code extension: memory status and controls in the Context settings tab, task-header and assistant-message affordances, the `/memory` prompt command, and Show/Toggle Project Memory command-palette entries. diff --git a/bun.lock b/bun.lock index a1f8b9ef20f..e0c5b1329c5 100644 --- a/bun.lock +++ b/bun.lock @@ -338,6 +338,7 @@ "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", + "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png new file mode 100644 index 00000000000..1ae814a6502 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92cf06c01b19386f2041e378a08afdaf880fdc5c3e19132350fbda05684dbeec +size 5775 diff --git a/packages/kilo-memory/src/marker-meta.ts b/packages/kilo-memory/src/marker-meta.ts index 727b147da4f..b2db71714f8 100644 --- a/packages/kilo-memory/src/marker-meta.ts +++ b/packages/kilo-memory/src/marker-meta.ts @@ -20,6 +20,7 @@ export namespace MemoryMarkerMeta { type: Type tokens: number count: number + files: string[] } export function metadata(marker: Info) { @@ -30,7 +31,6 @@ export namespace MemoryMarkerMeta { tokens: marker.tokens, count: marker.count, files: marker.files, - sources: marker.files, }, } } @@ -89,13 +89,14 @@ export namespace MemoryMarkerMeta { const value = meta as { type?: unknown; tokens?: unknown; count?: unknown; files?: unknown; sources?: unknown } const type = value.type === "startup" ? "startup" : "recall" const tokens = typeof value.tokens === "number" ? value.tokens : 0 + // `sources` fallback covers parts persisted before the key was dropped from metadata(). const files = Array.isArray(value.files) ? value.files.filter((item) => typeof item === "string") : Array.isArray(value.sources) ? value.sources.filter((item) => typeof item === "string") : [] const count = typeof value.count === "number" ? value.count : files.length - return { type, tokens, count } + return { type, tokens, count, files } } return undefined } diff --git a/packages/kilo-memory/test/decisions.test.ts b/packages/kilo-memory/test/decisions.test.ts index f75731af0d2..c5eb563ebd5 100644 --- a/packages/kilo-memory/test/decisions.test.ts +++ b/packages/kilo-memory/test/decisions.test.ts @@ -162,6 +162,20 @@ describe("memory marker metadata", () => { type: "recall", tokens: 4, count: 2, + files: ["project.md", "environment.md"], + }) + }) + + test("decodes legacy metadata that only carries sources", () => { + expect( + MemoryMarkerMeta.fromParts([ + { type: "text", metadata: { kiloMemory: { type: "startup", tokens: 3, sources: ["project.md"] } } }, + ]), + ).toEqual({ + type: "startup", + tokens: 3, + count: 1, + files: ["project.md"], }) }) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index ada2fcae6a5..b24e7463067 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -195,6 +195,16 @@ "title": "Migrate Settings from Legacy Version", "category": "Kilo Code" }, + { + "command": "kilo-code.new.showMemory", + "title": "Show Project Memory", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.toggleMemory", + "title": "Toggle Project Memory", + "category": "Kilo Code" + }, { "command": "kilo-code.new.autocomplete.generateSuggestions", "title": "Generate Suggested Edits", @@ -1147,6 +1157,7 @@ "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", + "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 8358930f059..f9199c6e22f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -138,6 +138,7 @@ import { import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" +import { KiloProviderMemory } from "./kilo-provider/memory" import { buildActionContext, @@ -386,6 +387,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly confirmations = new MessageConfirmation() private readonly costs = new MaxCostNudge() private readonly activeAlerts = new Map() // sid -> limit currently shown in UI + private readonly memory = new KiloProviderMemory({ + client: () => this.client ?? undefined, + session: () => this.currentSession ?? undefined, + // Honor disabled project scope (null in a multi-root panel): no workspace fallback, + // so memory operations never silently target an arbitrary folder. + dir: (sessionID) => this.getProjectDirectory(sessionID), + post: (message) => this.postMessage(message), + }) private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null /** Cached migration data so migration doesn't re-read from disk/SecretStorage. */ // legacy-migration @@ -931,6 +940,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } if (await this.handleModelSelectorExpandedMessage(message)) return this.visibleTaskStreams.handle(message) + if (await this.handleMemoryMessage(message)) return switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") @@ -1515,6 +1525,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Remote status events are global and should always pass through if (event.type === "kilo-sessions.remote-status-changed") return true + if (event.type === "memory.status" || event.type === "memory.updated" || event.type === "memory.error") + return true const sessionId = this.resolveEventSessionId(event) // message.part.* events are always session-scoped; drop if session unknown. @@ -1659,6 +1671,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendConfig(), this.fetchAndSendIndexingStatus(), this.fetchAndSendNotifications(), + this.memory.fetch(), this.seedSessionStatusMap(), ]) this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) @@ -2390,6 +2403,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async handleMemoryMessage(message: Record): Promise { + return this.memory.handle(message) + } + /** * Fetch backend config and send to webview. */ @@ -3762,6 +3779,48 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (event.type === "memory.status" || event.type === "memory.updated" || event.type === "memory.error") { + const props = event.properties as { sessionID?: unknown; detail?: unknown; reason?: unknown } + const eventSessionID = typeof props.sessionID === "string" ? props.sessionID : undefined + const active = this.currentSession?.id + const local = + !directory || sameDirectory(directory, this.getProjectDirectory(active) ?? this.getWorkspaceDirectory(active)) + const trackedById = Boolean(eventSessionID && this.trackedSessionIds.has(eventSessionID)) + // Directory-scoped events (enable/disable/rebuild/configure/purge) carry no + // sessionID, so also match any tracked session sharing the event directory — + // e.g. a non-active Agent Manager tab on the same worktree. + const trackedByDir = directory + ? [...this.sessionDirectories.entries()] + .filter(([sid, dir]) => this.trackedSessionIds.has(sid) && sameDirectory(directory, dir)) + .map(([sid]) => sid) + : [] + const tracked = trackedById || trackedByDir.length > 0 + if (!local && !tracked) return + if (trackedById && eventSessionID && directory) this.trackDirectory(eventSessionID, directory) + const targets = new Set() + if (trackedById && eventSessionID) targets.add(eventSessionID) + for (const sid of trackedByDir) targets.add(sid) + if (local && active) targets.add(active) + if (targets.size === 0 && local) targets.add(undefined) + const detail = + props.detail && typeof props.detail === "object" + ? props.detail + : event.type === "memory.error" && typeof props.reason === "string" + ? { type: "error", message: props.reason, reason: props.reason } + : undefined + for (const sessionID of targets) { + if (detail) { + this.postMessage({ + type: "memoryEvent", + sessionID, + detail, + }) + } + void this.memory.fetch(sessionID, false) + } + return + } + // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. @@ -4007,6 +4066,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.flushPendingReviewComments() } + public async showMemory(sessionID?: string): Promise { + await this.memory.show(sessionID ?? this.currentSession?.id) + } + + public async toggleMemory(sessionID?: string): Promise { + try { + const operation = await this.memory.toggle(sessionID ?? this.currentSession?.id) + if (operation) { + void vscode.window.showInformationMessage(`Project memory ${operation === "enable" ? "enabled" : "disabled"}.`) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to toggle memory:", error) + void vscode.window.showErrorMessage(getErrorMessage(error) || "Failed to toggle memory") + } + } + private flushPendingReviewComments(): void { if (!this.webview || !this.isWebviewReady || this.pendingReviewComments.length === 0) return @@ -4298,6 +4373,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibleTaskStreams.clear() this.streams.dispose() this.isWebviewReady = false + // Release any waitForReady() awaiters so their callers don't hang after disposal. + this.readyResolvers.splice(0).forEach((r) => r()) this.promptRecoveryQueued = false clearNetworkWaits(this.trackedSessionIds) this.trackedSessionIds.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e906a76607c..837af5ae746 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1804,6 +1804,60 @@ export class AgentManagerProvider implements Disposable { return this.panel?.active === true } + private async waitForPanel(panel: PanelContext, promise: Promise): Promise { + const done = promise.then(() => true) + let sub: Disposable | undefined + const disposed = new Promise((resolve) => { + sub = panel.onDidDispose(() => { + sub?.dispose() + resolve(false) + }) + }) + void done.finally(() => sub?.dispose()) + const ok = await Promise.race([done, disposed]) + return ok && this.panel === panel + } + + private waitForPanelReady(panel: PanelContext): Promise { + return this.waitForPanel(panel, panel.waitForReady()) + } + + private waitForPanelActive(panel: PanelContext): Promise { + return this.waitForPanel(panel, panel.waitForActive()) + } + + public async showMemory(): Promise { + const panel = this.panel + const sid = this.activeSessionId + if (!panel || !sid) { + this.host.showError("No active Agent Manager session") + return + } + if (!(await this.waitForPanelReady(panel))) return + if (this.activeSessionId !== sid) return + try { + await panel.sessions.showMemory(sid) + } catch (error) { + this.host.showError(getErrorMessage(error) || "Failed to show memory") + } + } + + public async toggleMemory(): Promise { + const panel = this.panel + const sid = this.activeSessionId + if (!panel || !sid) { + this.host.showError("No active Agent Manager session") + return + } + if (!(await this.waitForPanelReady(panel))) return + if (this.activeSessionId !== sid) return + try { + await panel.sessions.toggleMemory(sid) + } catch (error) { + this.host.showError(getErrorMessage(error) || "Failed to toggle memory") + } + } + /** Expose worktree session→directory mappings for the auto-approve toggle. */ public getSessionDirectories(): ReadonlyMap { return this.panel?.sessions.getSessionDirectories() ?? new Map() @@ -1869,7 +1923,7 @@ export class AgentManagerProvider implements Disposable { this.openPanel() const panel = this.panel if (!panel) return - await panel.waitForReady() + if (!(await this.waitForPanelReady(panel))) return await this.waitForStateReady("createFromSidebar") await this.onCreateWorktree(baseBranch, branchName) } @@ -1878,8 +1932,8 @@ export class AgentManagerProvider implements Disposable { this.openPanel() const panel = this.panel if (!panel) return - await panel.waitForActive() - await panel.waitForReady() + if (!(await this.waitForPanelActive(panel))) return + if (!(await this.waitForPanelReady(panel))) return await this.waitForStateReady("openAdvancedWorktree") queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" })) } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 2d9b4f6bf4c..fc5be00112f 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -44,6 +44,8 @@ export interface SessionProvider { * The callback receives the new session and its directory so the Agent Manager * can route it to the correct worktree instead of LOCAL. */ onFollowupAdopted(cb: (session: Session, directory: string) => void): void + showMemory(sessionID?: string): Promise + toggleMemory(sessionID?: string): Promise dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 79d88218790..24d96408772 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -119,6 +119,8 @@ export class VscodeHost implements Host { registerSession: (s) => provider.registerSession(s), recoverPendingPrompts: () => provider.recoverPendingPrompts(), onFollowupAdopted: (cb) => provider.onFollowupAdopted(cb), + showMemory: (id) => provider.showMemory(id), + toggleMemory: (id) => provider.toggleMemory(id), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 8539bf4d473..3e3aebd125c 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -398,6 +398,26 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.openIndexingSettings", () => { settingsEditorProvider.openPanel("settings", "indexing") }), + vscode.commands.registerCommand("kilo-code.new.showMemory", async () => { + if (agentManagerProvider.isActive()) { + await agentManagerProvider.showMemory() + return + } + const target = activeTabProvider() ?? provider + if (target === provider) await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await target.waitForReady() + await target.showMemory() + }), + vscode.commands.registerCommand("kilo-code.new.toggleMemory", async () => { + if (agentManagerProvider.isActive()) { + await agentManagerProvider.toggleMemory() + return + } + const target = activeTabProvider() ?? provider + if (target === provider) await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await target.waitForReady() + await target.toggleMemory() + }), // legacy-migration start vscode.commands.registerCommand("kilo-code.new.openMigrationWizard", () => { provider.postMessage({ type: "migrationState", needed: true, source: "legacy" }) diff --git a/packages/kilo-vscode/src/kilo-provider/memory.ts b/packages/kilo-vscode/src/kilo-provider/memory.ts new file mode 100644 index 00000000000..fc0988bd985 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/memory.ts @@ -0,0 +1,483 @@ +import * as vscode from "vscode" +import * as path from "node:path" +import { + isMemoryOperation, + isMemoryPromptOperation, + type MemoryOperation, + type MemoryPromptOperation, +} from "@kilocode/kilo-memory/commands" +import { MemorySchema } from "@kilocode/kilo-memory/schema" +import type { KiloClient, Session } from "@kilocode/sdk/v2/client" +import { retry } from "../services/cli-backend/retry" +import { getErrorMessage } from "../kilo-provider-utils" + +type MemorySourceFile = MemorySchema.Source +type MemoryApi = KiloClient["memory"] +const CACHE_LIMIT = 8 +const NO_PROJECT = "No active project for memory. Open a file in the target folder to manage its memory." + +export type KiloProviderMemoryMessage = { + operation: MemoryOperation + sessionID?: string + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + key?: string + file?: MemorySourceFile + section?: string +} + +export type KiloProviderMemoryInput = { + client(): KiloClient | undefined + session(): Session | undefined + /** Project directory for memory operations, or undefined when project scope is disabled. */ + dir(sessionID?: string): string | undefined + post(message: unknown): void +} + +function file(value: unknown): MemorySourceFile | undefined { + return MemorySchema.source(value) +} + +function operation(value: unknown): MemoryOperation | undefined { + return isMemoryOperation(value) ? value : undefined +} + +function mode(value: unknown) { + if (value === "status" || value === "on" || value === "off") return value + return undefined +} + +function memory(client: KiloClient | undefined): MemoryApi | undefined { + return (client as { memory?: MemoryApi } | undefined)?.memory +} + +function request(input: Record): { value: KiloProviderMemoryMessage } | { error: string } { + const op = operation(input.operation) + if (!op) return { error: "Unknown memory operation" } + const source = file(input.file) + if (input.file !== undefined && !source) return { error: "Invalid memory source file" } + return { + value: { + operation: op, + sessionID: typeof input.sessionID === "string" ? input.sessionID : undefined, + mode: mode(input.mode), + confirm: input.confirm === true, + text: typeof input.text === "string" ? input.text : undefined, + query: typeof input.query === "string" ? input.query : undefined, + key: typeof input.key === "string" ? input.key : undefined, + file: source, + section: typeof input.section === "string" ? input.section : undefined, + }, + } +} + +export class KiloProviderMemory { + private readonly cached = new Map() + private tail = Promise.resolve() + + constructor(private readonly input: KiloProviderMemoryInput) {} + + private cache(dir: string, msg: unknown) { + this.cached.delete(dir) + this.cached.set(dir, msg) + while (this.cached.size > CACHE_LIMIT) { + const key = this.cached.keys().next().value + if (typeof key !== "string") return + this.cached.delete(key) + } + } + + private serial(fn: () => Promise) { + // this.tail is always reassigned below to a never-rejecting promise, so it + // never settles rejected — a single fulfillment handler is sufficient. + const next = this.tail.then(fn) + this.tail = next.then( + () => undefined, + () => undefined, + ) + return next + } + + async handle(message: Record): Promise { + if (message.type === "requestMemory") { + this.fetch( + typeof message.sessionID === "string" ? message.sessionID : undefined, + message.includeSources === true, + ).catch((err: unknown) => console.error("[Kilo New] fetchAndSendMemory failed:", err)) + return true + } + if (message.type === "memoryShow") { + await this.show(typeof message.sessionID === "string" ? message.sessionID : undefined) + return true + } + if (message.type === "memoryOperation") { + const parsed = request(message) + if ("error" in parsed) { + this.input.post({ + type: "memoryOperationResult", + operation: typeof message.operation === "string" ? message.operation : "unknown", + sessionID: typeof message.sessionID === "string" ? message.sessionID : undefined, + ok: false, + error: parsed.error, + }) + return true + } + await this.run(parsed.value) + return true + } + if (message.type === "memoryPrompt") { + const op = isMemoryPromptOperation(message.operation) ? message.operation : undefined + if (!op) return true + await this.prompt(op, typeof message.sessionID === "string" ? message.sessionID : undefined) + return true + } + return false + } + + fetch(sessionID?: string, includeSources = false): Promise { + return this.serial(() => this.load(sessionID, includeSources)) + } + + /** Resolves once the serialized operation queue has drained. */ + idle(): Promise { + return this.tail + } + + private async load(sessionID?: string, includeSources = false): Promise { + try { + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + const client = this.input.client() + if (!client) { + const cached = directory ? this.cached.get(directory) : undefined + if (cached && typeof cached === "object" && !Array.isArray(cached)) this.input.post({ ...cached, sessionID }) + else this.input.post({ type: "memoryLoaded", sessionID, error: "Not connected to CLI backend" }) + return + } + + const api = memory(client) + if (!api) { + this.input.post({ type: "memoryLoaded", sessionID, error: "Memory unavailable in CLI backend" }) + return + } + + if (!directory) { + this.input.post({ type: "memoryLoaded", sessionID, error: NO_PROJECT }) + return + } + + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const show = includeSources + ? (await retry(() => api.show({ directory }, { throwOnError: true }))).data + : undefined + const msg = { + type: "memoryLoaded", + sessionID, + status, + ...(show ? { show } : {}), + } + this.cache(directory, msg) + this.input.post(msg) + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed to fetch memory:", err) + this.input.post({ + type: "memoryLoaded", + sessionID, + error: getErrorMessage(err) || "Failed to load memory", + }) + } + } + + async prompt(value: MemoryPromptOperation, sessionID?: string): Promise { + const title = value === "remember" ? "Remember in project memory" : "Forget project memory" + const placeHolder = value === "remember" ? "Project fact, command, or correction" : "Text to remove" + const text = await vscode.window.showInputBox({ title, placeHolder, ignoreFocusOut: true }) + if (!text?.trim()) { + // Clear the webview's pending state for this action when the input is dismissed. + this.input.post({ type: "memoryOperationResult", operation: value, sessionID, ok: true }) + return + } + await this.run({ + operation: value, + sessionID, + ...(value === "remember" ? { text: text.trim() } : { query: text.trim() }), + }) + } + + show(sessionID?: string): Promise { + return this.serial(() => this.doShow(sessionID)) + } + + private async doShow(sessionID?: string): Promise { + const client = this.input.client() + if (!client) { + this.input.post({ + type: "memoryLoaded", + sessionID, + error: "Not connected to CLI backend", + }) + return + } + + const api = memory(client) + + if (!api) { + this.input.post({ + type: "memoryLoaded", + sessionID, + error: "Memory unavailable in CLI backend", + }) + return + } + + try { + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + if (!directory) { + this.input.post({ type: "memoryLoaded", sessionID, error: NO_PROJECT }) + return + } + const { data: show } = await retry(() => api.show({ directory }, { throwOnError: true })) + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const current = sessionID ?? this.input.session()?.id + const startup = + current && status.state.stats.lastInjectedSessionID === current ? status.state.stats.lastInjectedTokens : 0 + const content = [ + "# Kilo Memory", + "", + `Root: ${show.root}`, + `Enabled: ${show.state.enabled ? "yes" : "no"}`, + `Auto-save: ${show.state.autoConsolidate ? "on" : "off"}`, + `Startup context: ${show.state.autoInject ? "on" : "off"}`, + `Stored index tokens: ${status.index.estimatedTokens}`, + `Startup context tokens for this session: ${startup}`, + `Last auto-save model usage: ${status.state.stats.lastConsolidationTokens} tokens`, + "", + "## project.md", + show.sources.project.trim(), + "", + "## environment.md", + show.sources.environment.trim(), + "", + "## corrections.md", + show.sources.corrections.trim(), + "", + "## index.kmem", + show.index.trim(), + "", + "## items", + show.items.trim(), + "", + "## changes", + show.changes.trim(), + "", + "## decisions.jsonl", + show.decisions.trim(), + "", + ].join("\n") + await vscode.workspace + .openTextDocument({ content, language: "markdown" }) + .then((doc) => vscode.window.showTextDocument(doc, { preview: true })) + const msg = { + type: "memoryLoaded", + sessionID, + status, + show, + } + this.cache(directory, msg) + this.input.post(msg) + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed to show memory:", err) + this.input.post({ + type: "memoryLoaded", + sessionID, + error: getErrorMessage(err) || "Failed to show memory", + }) + } + } + + run(message: KiloProviderMemoryMessage): Promise { + return this.serial(() => this.execute(message)) + } + + /** + * Serialized status read + enable/disable, so two rapid toggles can't both + * read the same pre-toggle state and apply the same operation twice. + * Returns the applied operation, or undefined when it failed (the failure is + * already posted to the webview by execute()). + */ + toggle(sessionID?: string): Promise { + return this.serial(async () => { + const client = this.input.client() + if (!client) throw new Error("Not connected to CLI backend") + const api = memory(client) + if (!api) throw new Error("Memory unavailable in CLI backend") + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + if (!directory) throw new Error(NO_PROJECT) + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const operation = status.state.enabled ? "disable" : "enable" + return (await this.execute({ operation, sessionID })) ? operation : undefined + }) + } + + private async execute(message: KiloProviderMemoryMessage): Promise { + const client = this.input.client() + if (!client) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: "Not connected to CLI backend", + }) + return false + } + + const api = memory(client) + if (!api) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: "Memory unavailable in CLI backend", + }) + return false + } + + try { + const directory = this.input.dir(message.sessionID ?? this.input.session()?.id) + if (!directory) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: NO_PROJECT, + }) + return false + } + const data = await this.action(api, directory, message) + const refreshed = await Promise.all([ + retry(() => api.status({ directory }, { throwOnError: true })), + retry(() => api.show({ directory }, { throwOnError: true })), + ]).catch((err: unknown) => { + console.warn("[Kilo New] Memory changed but refresh failed:", err) + return undefined + }) + const status = refreshed?.[0].data + const show = refreshed?.[1].data + const result = { + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: true, + ...(status ? { status } : {}), + ...(show ? { show } : {}), + result: data, + } + this.input.post(result) + if (status && show) { + const loaded = { + type: "memoryLoaded", + sessionID: message.sessionID, + status, + show, + } + this.cache(directory, loaded) + this.input.post(loaded) + } else { + // Mutation succeeded but the refresh failed: drop the now-stale cached + // entry so a later offline read doesn't report pre-mutation state. + this.cached.delete(directory) + } + return true + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed memory operation:", err) + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: getErrorMessage(err) || "Memory operation failed", + }) + return false + } + } + + private async action(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const op = message.operation + if (op === "enable") return (await api.enable({ directory }, { throwOnError: true })).data + if (op === "status") return (await api.status({ directory }, { throwOnError: true })).data + if (op === "edit") return this.edit(api, directory) + if (op === "disable") return (await api.disable({ directory }, { throwOnError: true })).data + if (op === "rebuild") return (await api.rebuild({ directory }, { throwOnError: true })).data + if (op === "purge") return this.purge(api, directory, message) + if (op === "auto") return this.auto(api, directory, message) + if (op === "remember") return this.remember(api, directory, message) + if (op === "correct") return this.correct(api, directory, message) + return this.forget(api, directory, message) + } + + private async remember(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const text = message.text?.trim() + if (!text) throw new Error("Memory text is required") + return ( + await api.remember( + { + directory, + text, + key: message.key, + file: message.file, + section: message.section, + sessionID: message.sessionID, + }, + { throwOnError: true }, + ) + ).data + } + + private async correct(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const text = message.text?.trim() + if (!text) throw new Error("Correction text is required") + return ( + await api.correct( + { + directory, + text, + key: message.key, + sessionID: message.sessionID, + }, + { throwOnError: true }, + ) + ).data + } + + private async forget(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const query = message.query?.trim() + if (!query) throw new Error("Forget query is required") + return (await api.forget({ directory, query, sessionID: message.sessionID }, { throwOnError: true })).data + } + + private async edit(api: MemoryApi, directory: string) { + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + if (!status.state.enabled) throw new Error("Memory is disabled. Run /memory on first.") + const uri = vscode.Uri.file(path.join(status.root, "project.md")) + const doc = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(doc, { preview: false }) + return status + } + + private async purge(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + if (message.confirm !== true) throw new Error("Memory purge requires confirmation") + return (await api.purge({ directory, confirm: true }, { throwOnError: true })).data + } + + private async auto(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + if (message.mode === "status") return (await retry(() => api.status({ directory }, { throwOnError: true }))).data + if (message.mode === "on" || message.mode === "off") { + return (await api.configure({ directory, autoConsolidate: message.mode === "on" }, { throwOnError: true })).data + } + throw new Error("Auto-save mode is required") + } +} diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 5cb1d097410..d85485821f4 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -50,8 +50,13 @@ const mockVscode = { notebookDocuments: [] as Array, onDidOpenTextDocument: () => ({ dispose: noop }), onDidChangeTextDocument: () => ({ dispose: noop }), + onDidSaveTextDocument: () => ({ dispose: noop }), onDidCloseTextDocument: () => ({ dispose: noop }), onDidChangeConfiguration: () => ({ dispose: noop }), + openTextDocument: async (input: { content?: string; language?: string }) => ({ + getText: () => input.content ?? "", + languageId: input.language ?? "plaintext", + }), getConfiguration: () => ({ get: (_key: string, value?: T) => value, update: async () => {}, @@ -81,6 +86,7 @@ const mockVscode = { tabGroups: { all: [] }, showTextDocument: async () => {}, showInformationMessage: async () => undefined, + showErrorMessage: async () => undefined, showWarningMessage: async () => undefined, createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }), createOutputChannel: () => ({ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts new file mode 100644 index 00000000000..1ac56160976 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, mock } from "bun:test" + +const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider") + +type Manager = { + host: { showError: (message: string) => void } + panel: + | { + waitForReady: () => Promise + onDidDispose: (cb: () => void) => { dispose: () => void } + sessions: { + showMemory: (sessionID?: string) => Promise + toggleMemory: (sessionID?: string) => Promise + } + } + | undefined + activeSessionId: string | undefined + showMemory: () => Promise + toggleMemory: () => Promise +} + +function deferred() { + let done: (() => void) | undefined + const promise = new Promise((resolve) => { + done = resolve + }) + return { + promise, + resolve: () => done?.(), + } +} + +function manager() { + const calls: unknown[] = [] + const disposers: Array<() => void> = [] + const item = Object.create(AgentManagerProvider.prototype) as Manager + item.host = { showError: mock((message) => calls.push(["error", message])) } + item.panel = { + waitForReady: mock(async () => calls.push("ready")), + onDidDispose: mock((cb) => { + disposers.push(cb) + return { dispose: mock(() => {}) } + }), + sessions: { + showMemory: mock(async (sessionID) => calls.push(["show", sessionID])), + toggleMemory: mock(async (sessionID) => calls.push(["toggle", sessionID])), + }, + } + item.activeSessionId = "ses_agent_manager" + return { + item, + calls, + dispose: () => { + for (const cb of disposers) cb() + }, + } +} + +describe("AgentManagerProvider memory commands", () => { + it("routes memory commands to the active Agent Manager session", async () => { + const ctx = manager() + + await ctx.item.showMemory() + await ctx.item.toggleMemory() + + expect(ctx.calls).toEqual(["ready", ["show", "ses_agent_manager"], "ready", ["toggle", "ses_agent_manager"]]) + }) + + it("does not fall back when Agent Manager has no active session", async () => { + const ctx = manager() + ctx.item.activeSessionId = undefined + + await ctx.item.showMemory() + + expect(ctx.calls).toEqual([["error", "No active Agent Manager session"]]) + }) + + it("does not hang when the panel is disposed before it is ready", async () => { + const ctx = manager() + const wait = deferred() + ctx.item.panel!.waitForReady = mock(() => wait.promise) + + const pending = ctx.item.showMemory() + ctx.dispose() + await pending + + expect(ctx.calls).toEqual([]) + }) + + it("does not target a stale session after readiness resolves", async () => { + const ctx = manager() + const wait = deferred() + ctx.item.panel!.waitForReady = mock(() => wait.promise) + + const pending = ctx.item.toggleMemory() + ctx.item.activeSessionId = "ses_other" + wait.resolve() + await pending + + expect(ctx.calls).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts new file mode 100644 index 00000000000..f4f417fad0a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type Internals = { + currentSession: { id: string } | null + trackedSessionIds: Set + webview: { postMessage(message: unknown): Promise } | null + handleEvent(event: unknown, directory?: string): void + memory: { idle(): Promise } +} + +function status(root: string) { + return { + root: `${root}/.kilo/memory`, + state: { + enabled: true, + autoConsolidate: true, + stats: { + lastInjectedSessionID: "", + lastInjectedTokens: 0, + lastOperationCount: 0, + }, + }, + index: { estimatedTokens: 0 }, + } +} + +function show(root: string) { + return { + root: `${root}/.kilo/memory`, + state: status(root).state, + sources: { project: "", environment: "", corrections: "" }, + index: "", + items: "", + changes: "", + decisions: "", + } +} + +describe("KiloProvider memory events", () => { + it("routes tracked background memory events to their session directory", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + item.trackedSessionIds.add("ses_bg") + provider.setSessionDirectory("ses_bg", "/worktree") + + item.handleEvent( + { + type: "memory.updated", + properties: { + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }, + }, + "/worktree", + ) + await item.memory.idle() + + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_bg" })) + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent", sessionID: "ses_active" })) + expect(calls).toEqual(["/worktree"]) + }) + + it("also refreshes the active session for same-directory memory events", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + item.trackedSessionIds.add("ses_bg") + provider.setSessionDirectory("ses_active", "/repo") + provider.setSessionDirectory("ses_bg", "/repo") + + item.handleEvent( + { + type: "memory.updated", + properties: { + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_active", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_bg" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo", "/repo"]) + }) + + it("uses the project directory when toggling memory", async () => { + const calls: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(["status", input.directory]) + return { data: status(input.directory) } + }, + disable: async (input: { directory: string }) => { + calls.push(["disable", input.directory]) + return { data: { root: `${input.directory}/.kilo/memory`, state: status(input.directory).state } } + }, + show: async (input: { directory: string }) => { + calls.push(["show", input.directory]) + return { data: show(input.directory) } + }, + }, + } as unknown as KiloClient + const posts: unknown[] = [] + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + undefined, + { projectDirectory: "/repo/project" }, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + + await provider.toggleMemory("ses_active") + + expect(calls).toEqual([ + ["status", "/repo/project"], + ["disable", "/repo/project"], + ["status", "/repo/project"], + ["show", "/repo/project"], + ]) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts new file mode 100644 index 00000000000..3630e807907 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { KiloProviderMemory } from "../../src/kilo-provider/memory" + +function subject(client: KiloClient | undefined) { + const posts: unknown[] = [] + const memory = new KiloProviderMemory({ + client: () => client, + session: () => undefined, + dir: () => "/repo", + post: (message) => posts.push(message), + }) + return { memory, posts } +} + +function status(root: string) { + return { + root: `${root}/.kilo/memory`, + state: { + enabled: true, + autoConsolidate: true, + stats: { + lastInjectedSessionID: "", + lastInjectedTokens: 0, + lastOperationCount: 0, + }, + }, + index: { estimatedTokens: 0 }, + } +} + +function show(root: string) { + return { + root: `${root}/.kilo/memory`, + state: status(root).state, + sources: { project: "", environment: "", corrections: "" }, + index: "", + items: "", + changes: "", + decisions: "", + } +} + +describe("KiloProviderMemory", () => { + it("handles clients without memory endpoints gracefully", async () => { + const item = subject({} as KiloClient) + + await item.memory.fetch("ses_memoryless") + await item.memory.show("ses_memoryless") + await item.memory.run({ operation: "enable", sessionID: "ses_memoryless" }) + + expect(item.posts).toEqual([ + { + type: "memoryLoaded", + sessionID: "ses_memoryless", + error: "Memory unavailable in CLI backend", + }, + { + type: "memoryLoaded", + sessionID: "ses_memoryless", + error: "Memory unavailable in CLI backend", + }, + { + type: "memoryOperationResult", + operation: "enable", + sessionID: "ses_memoryless", + ok: false, + error: "Memory unavailable in CLI backend", + }, + ]) + }) + + it("posts a load error when no client or cache exists", async () => { + const item = subject(undefined) + + await item.memory.fetch("ses_disconnected") + + expect(item.posts).toEqual([ + { + type: "memoryLoaded", + sessionID: "ses_disconnected", + error: "Not connected to CLI backend", + }, + ]) + }) + + it("evicts older cached memory payloads", async () => { + let client: KiloClient | undefined + const posts: unknown[] = [] + const item = new KiloProviderMemory({ + client: () => client, + session: () => undefined, + dir: (sid) => `/repo/${sid ?? "current"}`, + post: (message) => posts.push(message), + }) + client = { + memory: { + status: async (input: { directory: string }) => ({ data: status(input.directory) }), + show: async (input: { directory: string }) => ({ data: show(input.directory) }), + }, + } as unknown as KiloClient + + for (let i = 0; i < 9; i++) { + await item.show(`ses_${i}`) + } + + posts.length = 0 + client = undefined + await item.fetch("ses_0") + await item.fetch("ses_8") + + expect(posts[0]).toEqual({ + type: "memoryLoaded", + sessionID: "ses_0", + error: "Not connected to CLI backend", + }) + expect(posts[1]).toMatchObject({ + type: "memoryLoaded", + sessionID: "ses_8", + show: { root: "/repo/ses_8/.kilo/memory" }, + }) + }) + + it("does not send ignored placement fields to correction endpoint", async () => { + const calls: unknown[] = [] + const state = status("/repo") + const view = show("/repo") + const item = subject({ + memory: { + correct: async (input: unknown) => { + calls.push(input) + return { data: { operationCount: 1, added: 1, removed: 0, skipped: [], index: { tokens: 0 } } } + }, + status: async () => ({ data: state }), + show: async () => ({ data: view }), + }, + } as unknown as KiloClient) + + await item.memory.run({ + operation: "correct", + sessionID: "ses_correct", + text: "Prefer corrections.", + key: "correction_key", + file: "project.md", + section: "Facts", + }) + + expect(calls).toEqual([ + { + directory: "/repo", + text: "Prefer corrections.", + key: "correction_key", + sessionID: "ses_correct", + }, + ]) + }) + + it("routes status operations without mutating memory", async () => { + const calls: string[] = [] + const state = status("/repo") + const view = show("/repo") + const item = subject({ + memory: { + status: async () => { + calls.push("status") + return { data: state } + }, + show: async () => { + calls.push("show") + return { data: view } + }, + }, + } as unknown as KiloClient) + + await item.memory.run({ operation: "status", sessionID: "ses_memory" }) + + expect(calls).toEqual(["status", "status", "show"]) + expect(item.posts).toContainEqual( + expect.objectContaining({ type: "memoryOperationResult", operation: "status", ok: true }), + ) + }) + + it("routes auto-save and purge operations with explicit payloads", async () => { + const calls: unknown[] = [] + const state = status("/repo") + const view = show("/repo") + state.state.autoConsolidate = false + const item = subject({ + memory: { + configure: async (input: unknown) => { + calls.push(["configure", input]) + return { data: { root: "/repo/.kilo/memory", state: state.state } } + }, + purge: async (input: unknown) => { + calls.push(["purge", input]) + return { data: { root: "/repo/.kilo/memory", purged: true } } + }, + status: async () => ({ data: state }), + show: async () => ({ data: view }), + }, + } as unknown as KiloClient) + + await item.memory.run({ operation: "auto", mode: "off", sessionID: "ses_memory" }) + await item.memory.run({ operation: "purge", confirm: true, sessionID: "ses_memory" }) + + expect(calls).toEqual([ + ["configure", { directory: "/repo", autoConsolidate: false }], + ["purge", { directory: "/repo", confirm: true }], + ]) + expect(item.posts.filter((post) => (post as { type?: string }).type === "memoryOperationResult")).toHaveLength(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/memory-command.test.ts b/packages/kilo-vscode/tests/unit/memory-command.test.ts new file mode 100644 index 00000000000..1bd1df1e5d9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/memory-command.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { parseMemoryCommand, type ParsedMemoryCommand } from "../../webview-ui/src/utils/memory-command" + +type MemoryOperation = + | "enable" + | "status" + | "edit" + | "disable" + | "rebuild" + | "remember" + | "correct" + | "forget" + | "purge" + | "auto" +type Case = { + name: string + input: string + result: "none" | "help" | "show" | "operation" | "usage" + operation?: MemoryOperation + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + reason?: string +} + +const cases = (await Bun.file( + new URL("../../../kilo-memory/test/command-cases.json", import.meta.url), +).json()) as Case[] + +function expected(item: Case): ParsedMemoryCommand | undefined { + if (item.result === "none") return + if (item.result === "help") return { kind: "help" } + if (item.result === "show") return { kind: "show" } + if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" } + if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`) + if (item.operation === "remember" || item.operation === "correct") { + if (!item.text) throw new Error(`Missing text for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, text: item.text } + } + if (item.operation === "forget") { + if (!item.query) throw new Error(`Missing query for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, query: item.query } + } + if (item.operation === "auto") { + if (!item.mode) throw new Error(`Missing mode for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, mode: item.mode } + } + if (item.operation === "purge") { + if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, confirm: true } + } + return { kind: "operation", operation: item.operation } +} + +describe("parseMemoryCommand", () => { + it("matches shared command fixtures", () => { + for (const item of cases) { + const parsed = parseMemoryCommand(item.input) + if (item.result === "usage") { + expect(parsed?.kind, item.name).toBe("usage") + expect(parsed && "reason" in parsed ? parsed.reason : "", item.name).toContain(item.reason ?? "") + continue + } + expect(parsed, item.name).toEqual(expected(item)) + } + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a02d0e9dc4f..b85a96390d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -80,6 +80,7 @@ import { DisplayProvider } from "../src/context/display" import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-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 { WorktreeModeProvider } from "../src/context/worktree-mode" @@ -3140,13 +3141,15 @@ export const AgentManagerApp: Component = () => { - - - - - - - + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 358fc0cd8e4..64e4b94eb37 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -20,6 +20,7 @@ import { DisplayProvider } from "./context/display" 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 { LanguageBridge } from "./context/language-bridge" import { ChatView } from "./components/chat" @@ -418,11 +419,13 @@ const App: Component = () => { - - - - - + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index adb44b60718..b87ce38294b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -11,6 +11,7 @@ import { Component, For, Show, createMemo } from "solid-js" import { Dynamic } from "solid-js/web" import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part" import type { MessageFeedbackControls } from "@kilocode/kilo-ui/message-part" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, @@ -22,9 +23,11 @@ import { useSession } from "../../context/session" import { useDisplay } from "../../context/display" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" +import { useMemory } from "../../context/memory" import { useServer } from "../../context/server" import { snapshotProgress } from "../../context/session-utils" import { planDisplayPath } from "../../utils/plan-path" +import { MemoryMarkerMeta } from "@kilocode/kilo-memory/marker-meta" import { QuestionDock } from "./QuestionDock" import { SuggestBar } from "./SuggestBar" @@ -131,6 +134,8 @@ type ToolStateProps = { status?: string } +type MemoryItem = MemoryMarkerMeta.Decoded + function TodoToolCard(props: { part: ToolPart }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps @@ -183,6 +188,8 @@ export const AssistantMessage: Component = (props) => { const data = useData() const session = useSession() const display = useDisplay() + const mem = useMemory() + const language = useLanguage() const { config } = useConfig() const open = createMemo(() => config().terminal_command_display !== "collapsed") const edit = createMemo(() => config().code_edit_display === "expanded") @@ -198,6 +205,54 @@ export const AssistantMessage: Component = (props) => { return !!matchToolRequest(part, "question", session.questions()) }) }) + const meta = createMemo(() => + MemoryMarkerMeta.fromParts((props.parts ?? data.store.part?.[props.message.id] ?? []) as MemoryMarkerMeta.Part[]), + ) + const fmt = (value: number) => value.toLocaleString(language.locale()) + const count = (item: MemoryItem) => fmt(item.count) + const tokens = (item: MemoryItem) => fmt(item.tokens) + const label = (item: MemoryItem) => + item.type === "startup" ? language.t("chat.memory.badge.injected") : language.t("chat.memory.badge.recalled") + const detail = (item: MemoryItem) => + item.type === "startup" + ? language.t("chat.memory.badge.startupCtx") + : language.t("chat.memory.badge.items", { count: count(item) }) + const tip = (item: MemoryItem) => { + const err = mem.error() + if (err) return {err} + const status = mem.status() + if (!status) return {language.t("chat.memory.status.loading")} + const ops = status.state.stats.lastOperationCount + const total = mem.totalTokens().toLocaleString(language.locale()) + return ( +
+
+ {item.type === "startup" + ? language.t("chat.memory.session.tokens", { tokens: tokens(item) }) + : language.t("chat.memory.badge.recalledDetail", { count: count(item), tokens: tokens(item) })} +
+
{language.t("chat.memory.total.tokens", { tokens: total })}
+
+ {!status.state.enabled + ? language.t("chat.memory.project.disabled") + : language.t("chat.memory.project.enabled")} +
+ 0}> +
+ {language.t("chat.memory.savedOperations", { + count: ops.toLocaleString(language.locale()), + })} +
+
+ +
{mem.show()!.changes.split("\n").filter(Boolean).slice(-1)[0]}
+
+ 0}> +
{language.t("chat.memory.badge.files", { files: item.files.join(", ") })}
+
+
+ ) + } return ( <> @@ -290,6 +345,15 @@ export const AssistantMessage: Component = (props) => { ) }} + + {(item) => ( + +
+ {label(item())} · {detail(item())} · {language.t("chat.memory.badge.tokens", { tokens: tokens(item()) })} +
+
+ )} +
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx index b066d05982e..69a311a59a9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx @@ -13,12 +13,7 @@ import { Component, createMemo, Show } from "solid-js" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" import { useProvider } from "../../context/provider" - -function fmt(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return String(n) -} +import { formatCompactCount as fmt } from "../../utils/format" export const ContextProgress: Component = () => { const session = useSession() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index d9c346cb4b8..1593dadf0c2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -61,6 +61,8 @@ import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store" import { ReviewComments } from "./ReviewComments" import { partReview, reviewBody } from "../../../../src/shared/review-comments" import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" +import { MEMORY_USAGE, parseMemoryCommand } from "../../utils/memory-command" +import { useMemory } from "../../context/memory" const scrolls = new Map() function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] { @@ -91,6 +93,7 @@ export const PromptInput: Component = (props) => { const provider = useProvider() const language = useLanguage() const vscode = useVSCode() + const projectMemory = useMemory() const sid = () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID() ?? undefined const ctx = () => { const id = props.boxId @@ -936,9 +939,61 @@ export const PromptInput: Component = (props) => { transcribeAndSend() } + const runMemory = (memory: NonNullable>) => { + if (memory.kind === "usage") { + showToast({ variant: "error", title: language.t("chat.memory.command.failed"), description: memory.reason }) + return false + } + if (memory.kind === "help") { + showToast({ variant: "default", title: "/memory", description: MEMORY_USAGE }) + return true + } + if (isDisabled() || speech.active() || terminal.pending() || git.pending() || props.blocked?.()) return false + const status = projectMemory.status() + if ( + memory.kind === "operation" && + (memory.operation === "remember" || memory.operation === "correct" || memory.operation === "forget") && + status && + !status.state.enabled + ) { + showToast({ variant: "error", title: language.t("chat.memory.project.disabled") }) + return false + } + if (memory.kind === "show") vscode.postMessage({ type: "memoryShow", sessionID: sid() }) + if (memory.kind === "operation") { + vscode.postMessage({ + type: "memoryOperation", + operation: memory.operation, + sessionID: sid(), + ...(memory.operation === "auto" ? { mode: memory.mode } : {}), + ...(memory.operation === "purge" ? { confirm: memory.confirm } : {}), + ...(memory.operation === "remember" || memory.operation === "correct" ? { text: memory.text } : {}), + ...(memory.operation === "forget" ? { query: memory.query } : {}), + }) + } + return true + } + const handleSend = async () => { const draft = text().trim() + const memory = parseMemoryCommand(draft) + if (memory) { + if (!runMemory(memory)) return + history.append(draft) + setText("") + clearReviewComments() + imageAttach.clear() + mention.closeMention() + slash.close() + drafts.delete(draftKey()) + reviewDrafts.delete(draftKey()) + imageDrafts.delete(draftKey()) + scrolls.delete(draftKey()) + if (textareaRef) textareaRef.style.height = "auto" + return + } + // Detect slash command (hoisted for both client and server command checks). // Prioritize exact name matches over hint/alias matches so that a server // command named e.g. "continue" is not hijacked by a client alias. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index 96ac022f93f..468fe45d5ae 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -14,6 +14,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Icon } from "@kilocode/kilo-ui/icon" import { Checkbox } from "@kilocode/kilo-ui/checkbox" import { useSession } from "../../context/session" +import { useMemory } from "../../context/memory" import { calcTokenUsage, collapseCostBreakdown } from "../../context/session-utils" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" @@ -24,6 +25,7 @@ import { hasModelUsage, tokenSummary } from "../../context/model-usage" import { SessionRenameEditor } from "../shared/SessionRenameEditor" import { target as todoTarget } from "../../context/todo-revert" import type { Part, TodoItem, ExtensionMessage } from "../../types/messages" +import { formatCompactCount } from "../../utils/format" interface TaskHeaderProps { readonly?: boolean @@ -31,6 +33,7 @@ interface TaskHeaderProps { export const TaskHeader: Component = (props) => { const session = useSession() + const memory = useMemory() const language = useLanguage() const title = createMemo(() => session.currentSession()?.title ?? language.t("command.session.new")) @@ -83,6 +86,17 @@ export const TaskHeader: Component = (props) => { return false }) + const memoryLabel = createMemo(() => { + const count = memory.sessionTokens() + return count > 0 + ? language.t("chat.memory.label", { tokens: formatCompactCount(count) }) + : language.t("chat.memory.on") + }) + + const memoryTooltip = createMemo(() => + language.t("chat.memory.session.tokens", { tokens: formatCompactCount(memory.sessionTokens()) }), + ) + const vscode = useVSCode() const [expanded, setExpanded] = createSignal(true) @@ -235,6 +249,69 @@ export const TaskHeader: Component = (props) => { {(tk) => }
+ +
+ + + + {memoryLabel()} + + + + + memory.showMemory()} + aria-label={language.t("chat.memory.inspect")} + /> + + + memory.remember()} + aria-label={language.t("chat.memory.remember")} + /> + + + memory.forget()} + aria-label={language.t("chat.memory.forget")} + /> + + + memory.rebuild()} + aria-label={language.t("chat.memory.rebuild")} + /> + + {/* Strip only mounts when enabled, so this is always the disable action; re-enable lives in Settings > Context. */} + + memory.disable()} + aria-label={language.t("chat.memory.disable")} + /> + + +
+
+ memory.rebuild()} + aria-label={language.t("settings.context.memory.rebuild")} + /> +
+ + + {(err) => ( +
+ {err()} +
+ )} +
+ + {/* Compaction settings */} +

+ {language.t("settings.context.compaction.title")} +

+ show: Accessor + loading: Accessor + pending: Accessor + error: Accessor + enabled: Accessor + sessionTokens: Accessor + totalTokens: Accessor + refresh: (includeSources?: boolean) => void + showMemory: () => void + enable: () => void + disable: () => void + auto: (mode: "on" | "off") => void + rebuild: () => void + remember: () => void + forget: () => void +} + +export const MemoryContext = createContext() +const EVENT_DEDUPE_MS = 1000 + +export const MemoryProvider: ParentComponent = (props) => { + const vscode = useVSCode() + const server = useServer() + const session = useSession() + const language = useLanguage() + const [status, setStatus] = createSignal() + const [show, setShow] = createSignal() + const [loading, setLoading] = createSignal(false) + const [pending, setPending] = createSignal() + const [error, setError] = createSignal() + + const id = () => session.currentSessionID() + const key = (sid?: string) => sid ?? "" + const current = (sid?: string) => { + if (!sid) return true + // A response can be addressed to a draft session that hasn't been promoted to + // currentSessionID yet (PromptInput posts with the draft id), so match both. + return sid === id() || sid === session.draftSessionID() + } + let last: { key: string; time: number } | undefined + let scope = "" + + const clear = () => { + setStatus(undefined) + setShow(undefined) + setError(undefined) + setPending(undefined) + last = undefined + } + + const refresh = (includeSources = false) => { + if (!server.isConnected()) return + setLoading(true) + setError(undefined) + vscode.postMessage({ type: "requestMemory", sessionID: id(), includeSources }) + } + + const operation = (op: "enable" | "disable" | "rebuild") => { + if (!server.isConnected()) return + setPending(key(id())) + setError(undefined) + vscode.postMessage({ type: "memoryOperation", operation: op, sessionID: id() }) + } + + const auto = (mode: "on" | "off") => { + if (!server.isConnected()) return + setPending(key(id())) + setError(undefined) + vscode.postMessage({ type: "memoryOperation", operation: "auto", mode, sessionID: id() }) + } + + const prompt = (op: "remember" | "forget") => { + if (!server.isConnected()) return + setPending(key(id())) + setError(undefined) + vscode.postMessage({ type: "memoryPrompt", operation: op, sessionID: id() }) + } + + const showMemory = () => { + if (!server.isConnected()) return + setLoading(true) + setError(undefined) + vscode.postMessage({ type: "memoryShow", sessionID: id() }) + } + + const event = (message: Extract) => { + if (!current(message.sessionID)) return + if (message.detail.type === "skipped") return + if (!message.detail.message) return + const dedupeKey = `${message.sessionID ?? ""}:${message.detail.type ?? ""}:${message.detail.message}` + const now = Date.now() + if (last?.key === dedupeKey && now - last.time < EVENT_DEDUPE_MS) return + last = { key: dedupeKey, time: now } + showToast({ + ...(message.detail.type === "saved" + ? { variant: "success" as const } + : message.detail.type === "error" + ? { variant: "error" as const } + : {}), + title: message.detail.message, + }) + } + + const loaded = (message: Extract) => { + if (!current(message.sessionID)) return + setLoading(false) + if (message.error) { + setError(message.error) + setStatus(undefined) + setShow(undefined) + return + } + if (message.status) setStatus(message.status) + if (message.show) setShow(message.show) + setError(undefined) + } + + const done = (message: Extract) => { + if (pending() === key(message.sessionID)) setPending(undefined) + if (!current(message.sessionID)) return + setLoading(false) + if (!message.ok) { + const err = message.error ?? language.t("chat.memory.command.failed") + setError(err) + showToast({ variant: "error", title: err }) + return + } + if (message.status) setStatus(message.status) + if (message.show) setShow(message.show) + setError(undefined) + } + + const receive = (message: ExtensionMessage) => { + if (message.type === "memoryEvent") { + event(message) + return + } + if (message.type === "memoryLoaded") { + loaded(message) + return + } + if (message.type === "memoryOperationResult") { + done(message) + return + } + if (message.type === "extensionDataReady" && server.isConnected() && !status()) refresh(false) + } + + const unsubscribe = vscode.onMessage(receive) + + onCleanup(unsubscribe) + + createEffect(() => { + const sid = id() + const dir = server.workspaceDirectory() + const connected = server.isConnected() + const next = `${connected ? "1" : "0"}:${sid ?? ""}:${dir ?? ""}` + if (scope !== next) { + scope = next + clear() + } + if (!connected) { + setLoading(false) + return + } + refresh(false) + }) + + const sessionTokens = (snapshot?: MemoryStatusResponse) => { + const sid = id() + if (!snapshot?.state.enabled) return 0 + if (!sid || snapshot.state.stats.lastInjectedSessionID !== sid) return 0 + return snapshot.state.stats.lastInjectedTokens + } + + const total = createMemo(() => status()?.index.estimatedTokens ?? 0) + + const sessionTotal = createMemo(() => sessionTokens(status())) + + const value: MemoryContextValue = { + status, + show, + loading, + pending: createMemo(() => pending() === key(id())), + error, + enabled: createMemo(() => status()?.state.enabled ?? false), + sessionTokens: sessionTotal, + totalTokens: total, + refresh, + showMemory, + enable: () => operation("enable"), + disable: () => operation("disable"), + auto, + rebuild: () => operation("rebuild"), + remember: () => prompt("remember"), + forget: () => prompt("forget"), + } + + return {props.children} +} + +export function useMemory(): MemoryContextValue { + const context = useContext(MemoryContext) + if (!context) { + throw new Error("useMemory must be used within a MemoryProvider") + } + return context +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 5a66075adcb..9dd3f6eb34b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1590,6 +1590,7 @@ export const dict = { "settings.checkpoints.enable.description": "إنشاء نقاط فحص قبل تحرير الملفات", "settings.context.autoCompaction.title": "ضغط تلقائي", "settings.context.autoCompaction.description": "ضغط السياق تلقائياً قبل أن يصل إلى الحد", + "settings.context.compaction.title": "الضغط", "settings.context.compactionLimit.title": "حد الضغط التلقائي", "settings.context.compactionLimit.description": "اضغط عندما يصل السياق إلى هذه النسبة المئوية من نافذة النموذج. اتركه فارغاً لاستخدام هامش الأمان فقط.", @@ -1598,6 +1599,42 @@ export const dict = { "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.index.title": "فهرس الذاكرة", + "settings.context.memory.status.notLoaded": "غير محمّلة", + "settings.context.memory.status.disabled": "معطّلة", + "settings.context.memory.status.enabledTokensOps": + "مفعّلة - ~{{session}} رموز سياق بدء التشغيل في هذه الجلسة - ~{{tokens}} رموز في الفهرس المخزّن - آخر عملية {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "فعّل الذاكرة لإنشاء ملفات ذاكرة المشروع.", + "settings.context.memory.inspect": "فحص", + "settings.context.memory.rebuild": "إعادة بناء فهرس الذاكرة", + "chat.memory.on": "الذاكرة مفعّلة", + "chat.memory.label": "الذاكرة · {{tokens}} رمز", + "chat.memory.status.loading": "جارٍ تحميل حالة الذاكرة", + "chat.memory.session.tokens": "سياق بدء التشغيل في هذه الجلسة: {{tokens}} رموز", + "chat.memory.total.tokens": "الفهرس المخزّن: {{tokens}} رموز", + "chat.memory.project.enabled": "ذاكرة المشروع مفعّلة", + "chat.memory.project.disabled": "ذاكرة المشروع معطّلة", + "chat.memory.command.failed": "فشل أمر الذاكرة", + "chat.memory.savedOperations": "آخر عملية ذاكرة: {{count}} عمليات", + "chat.memory.inspect": "فحص الذاكرة", + "chat.memory.remember": "تذكّر", + "chat.memory.forget": "انسَ", + "chat.memory.rebuild": "إعادة بناء الفهرس", + "chat.memory.disable": "تعطيل الذاكرة", + "chat.memory.badge.injected": "تم حقن الذاكرة", + "chat.memory.badge.recalled": "تم استدعاء الذاكرة", + "chat.memory.badge.startupCtx": "سياق البدء", + "chat.memory.badge.items": "{{count}} عناصر", + "chat.memory.badge.tokens": "{{tokens}} رموز", + "chat.memory.badge.recalledDetail": "تم استدعاء الذاكرة: {{count}} عناصر - {{tokens}} رموز", + "chat.memory.badge.files": "ملفات الذاكرة: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "استخدام prompt مخصص", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index f738a64eaed..8f7c0867cd1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1638,6 +1638,7 @@ export const dict = { "settings.checkpoints.enable.description": "Criar pontos de verificação antes de editar arquivos", "settings.context.autoCompaction.title": "Compactação automática", "settings.context.autoCompaction.description": "Compactar automaticamente o contexto antes que atinja o limite", + "settings.context.compaction.title": "Compactação", "settings.context.compactionLimit.title": "Limite de compactação automática", "settings.context.compactionLimit.description": "Compacte quando o contexto atingir esta porcentagem da janela do modelo. Deixe em branco para usar apenas a margem de segurança.", @@ -1646,6 +1647,42 @@ export const dict = { "settings.context.watcherPatterns": "Padrões de ignorar do observador", "settings.context.watcherPatterns.description": "Padrões glob para arquivos que o observador deve ignorar", + "settings.context.memory.title": "Memória", + "settings.context.memory.project.title": "Memória do projeto", + "settings.context.memory.autoSave.title": "Salvar memória do projeto automaticamente", + "settings.context.memory.autoSave.description": + "Salva automaticamente fatos duradouros do projeto a partir de turnos concluídos quando a memória está ativada.", + "settings.context.memory.index.title": "Índice da memória", + "settings.context.memory.status.notLoaded": "Não carregada", + "settings.context.memory.status.disabled": "Desativada", + "settings.context.memory.status.enabledTokensOps": + "Ativada - ~{{session}} tokens de contexto inicial nesta sessão - ~{{tokens}} tokens no índice armazenado - última op. {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Ative a memória para criar arquivos de memória do projeto.", + "settings.context.memory.inspect": "Inspecionar", + "settings.context.memory.rebuild": "Reconstruir índice da memória", + "chat.memory.on": "Memória ligada", + "chat.memory.label": "Memória · {{tokens}} tokens", + "chat.memory.status.loading": "Carregando status da memória", + "chat.memory.session.tokens": "Contexto inicial desta sessão: {{tokens}} tokens", + "chat.memory.total.tokens": "Índice armazenado: {{tokens}} tokens", + "chat.memory.project.enabled": "Memória do projeto ativada", + "chat.memory.project.disabled": "Memória do projeto desativada", + "chat.memory.command.failed": "Comando de memória falhou", + "chat.memory.savedOperations": "Última operação de memória: {{count}} ops", + "chat.memory.inspect": "Inspecionar memória", + "chat.memory.remember": "Lembrar", + "chat.memory.forget": "Esquecer", + "chat.memory.rebuild": "Reconstruir índice", + "chat.memory.disable": "Desativar memória", + "chat.memory.badge.injected": "Memória injetada", + "chat.memory.badge.recalled": "Memória recuperada", + "chat.memory.badge.startupCtx": "ctx inicial", + "chat.memory.badge.items": "{{count}} itens", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Memória recuperada: {{count}} itens - {{tokens}} tokens", + "chat.memory.badge.files": "Arquivos de memória: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Usar prompt personalizado", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 13674b2f11b..96d098fd3e8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1631,6 +1631,7 @@ export const dict = { "settings.checkpoints.enable.description": "Kreiraj kontrolne točke prije uređivanja datoteka", "settings.context.autoCompaction.title": "Automatska kompresija", "settings.context.autoCompaction.description": "Automatski komprimiraj kontekst prije nego dostigne limit", + "settings.context.compaction.title": "Kompresija", "settings.context.compactionLimit.title": "Limit automatske kompresije", "settings.context.compactionLimit.description": "Komprimiraj kada kontekst dostigne ovaj procenat prozora modela. Ostavite prazno da koristite samo sigurnosnu rezervu.", @@ -1639,6 +1640,42 @@ export const dict = { "settings.context.watcherPatterns": "Uzorci ignoriranja za promatrač datoteka", "settings.context.watcherPatterns.description": "Glob uzorci za datoteke koje promatrač treba ignorirati", + "settings.context.memory.title": "Memorija", + "settings.context.memory.project.title": "Memorija projekta", + "settings.context.memory.autoSave.title": "Automatsko spremanje memorije projekta", + "settings.context.memory.autoSave.description": + "Automatski sprema trajne činjenice projekta iz završenih koraka kada je memorija uključena.", + "settings.context.memory.index.title": "Indeks memorije", + "settings.context.memory.status.notLoaded": "Nije učitana", + "settings.context.memory.status.disabled": "Onemogućena", + "settings.context.memory.status.enabledTokensOps": + "Omogućena - ~{{session}} tokena početnog konteksta u ovoj sesiji - ~{{tokens}} tokena spremljenog indeksa - zadnja op. {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Omogućite memoriju za kreiranje datoteka memorije projekta.", + "settings.context.memory.inspect": "Pregledaj", + "settings.context.memory.rebuild": "Ponovo izgradi indeks memorije", + "chat.memory.on": "Memorija uključena", + "chat.memory.label": "Memorija · {{tokens}} tokena", + "chat.memory.status.loading": "Učitavanje statusa memorije", + "chat.memory.session.tokens": "Početni kontekst ove sesije: {{tokens}} tokena", + "chat.memory.total.tokens": "Spremljeni indeks: {{tokens}} tokena", + "chat.memory.project.enabled": "Memorija projekta omogućena", + "chat.memory.project.disabled": "Memorija projekta onemogućena", + "chat.memory.command.failed": "Komanda memorije nije uspjela", + "chat.memory.savedOperations": "Zadnja operacija memorije: {{count}} op.", + "chat.memory.inspect": "Pregledaj memoriju", + "chat.memory.remember": "Zapamti", + "chat.memory.forget": "Zaboravi", + "chat.memory.rebuild": "Ponovo izgradi indeks", + "chat.memory.disable": "Onemogući memoriju", + "chat.memory.badge.injected": "Memorija ubačena", + "chat.memory.badge.recalled": "Memorija opozvana", + "chat.memory.badge.startupCtx": "početni ctx", + "chat.memory.badge.items": "{{count}} stavki", + "chat.memory.badge.tokens": "{{tokens}} tokena", + "chat.memory.badge.recalledDetail": "Memorija opozvana: {{count}} stavki - {{tokens}} tokena", + "chat.memory.badge.files": "Datoteke memorije: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Koristi prilagođeni prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index c68095f7b69..c9ac7fe483e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1621,6 +1621,7 @@ export const dict = { "settings.checkpoints.enable.description": "Opret kontrolpunkter før filredigeringer", "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimér automatisk kontekst, før den når grænsen", + "settings.context.compaction.title": "Komprimering", "settings.context.compactionLimit.title": "Grænse for automatisk komprimering", "settings.context.compactionLimit.description": "Komprimér, når konteksten når denne procentdel af modelvinduet. Lad feltet være tomt for kun at bruge sikkerhedsbufferen.", @@ -1629,6 +1630,42 @@ export const dict = { "settings.context.watcherPatterns": "Filvagt-ignormønstre", "settings.context.watcherPatterns.description": "Glob-mønstre for filer, som vagten skal ignorere", + "settings.context.memory.title": "Hukommelse", + "settings.context.memory.project.title": "Projekthukommelse", + "settings.context.memory.autoSave.title": "Gem projekthukommelse automatisk", + "settings.context.memory.autoSave.description": + "Gemmer automatisk varige projektfakta fra afsluttede ture, når hukommelse er aktiveret.", + "settings.context.memory.index.title": "Hukommelsesindeks", + "settings.context.memory.status.notLoaded": "Ikke indlæst", + "settings.context.memory.status.disabled": "Deaktiveret", + "settings.context.memory.status.enabledTokensOps": + "Aktiveret - ~{{session}} opstartstokens i denne session - ~{{tokens}} tokens i gemt indeks - seneste handling {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Aktivér hukommelse for at oprette projektets hukommelsesfiler.", + "settings.context.memory.inspect": "Inspicér", + "settings.context.memory.rebuild": "Genopbyg hukommelsesindeks", + "chat.memory.on": "Hukommelse til", + "chat.memory.label": "Hukommelse · {{tokens}} tokens", + "chat.memory.status.loading": "Indlæser hukommelsesstatus", + "chat.memory.session.tokens": "Opstartskontekst i denne session: {{tokens}} tokens", + "chat.memory.total.tokens": "Gemt indeks: {{tokens}} tokens", + "chat.memory.project.enabled": "Projekthukommelse aktiveret", + "chat.memory.project.disabled": "Projekthukommelse deaktiveret", + "chat.memory.command.failed": "Hukommelseskommando mislykkedes", + "chat.memory.savedOperations": "Seneste hukommelseshandling: {{count}} handlinger", + "chat.memory.inspect": "Inspicér hukommelse", + "chat.memory.remember": "Husk", + "chat.memory.forget": "Glem", + "chat.memory.rebuild": "Genopbyg indeks", + "chat.memory.disable": "Deaktivér hukommelse", + "chat.memory.badge.injected": "Hukommelse injiceret", + "chat.memory.badge.recalled": "Hukommelse genkaldt", + "chat.memory.badge.startupCtx": "opstartsctx", + "chat.memory.badge.items": "{{count}} elementer", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Hukommelse genkaldt: {{count}} elementer - {{tokens}} tokens", + "chat.memory.badge.files": "Hukommelsesfiler: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Brug brugerdefineret prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 5748b0116fa..7a22c29c893 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1657,6 +1657,7 @@ export const dict = { "Prüfpunkte vor Dateibearbeitungen erstellen, um vorherige Zustände wiederherstellen zu können", "settings.context.autoCompaction.title": "Automatische Komprimierung", "settings.context.autoCompaction.description": "Kontext automatisch komprimieren, bevor er das Limit erreicht", + "settings.context.compaction.title": "Komprimierung", "settings.context.compactionLimit.title": "Limit für automatische Komprimierung", "settings.context.compactionLimit.description": "Komprimieren, wenn der Kontext diesen Prozentsatz des Modellfensters erreicht. Leer lassen, um nur den Sicherheitspuffer zu verwenden.", @@ -1665,6 +1666,42 @@ export const dict = { "settings.context.watcherPatterns": "Datei-Watcher-Ignorierungsmuster", "settings.context.watcherPatterns.description": "Glob-Muster für Dateien, die der Watcher ignorieren soll", + "settings.context.memory.title": "Speicher", + "settings.context.memory.project.title": "Projektspeicher", + "settings.context.memory.autoSave.title": "Projektspeicher automatisch speichern", + "settings.context.memory.autoSave.description": + "Dauerhafte Projektfakten aus abgeschlossenen Durchläufen automatisch speichern, wenn Speicher aktiviert ist.", + "settings.context.memory.index.title": "Speicherindex", + "settings.context.memory.status.notLoaded": "Nicht geladen", + "settings.context.memory.status.disabled": "Deaktiviert", + "settings.context.memory.status.enabledTokensOps": + "Aktiviert - ~{{session}} Startkontext-Tokens in dieser Sitzung - ~{{tokens}} Tokens im gespeicherten Index - letzte Operation {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Aktivieren Sie den Speicher, um Projektspeicherdateien zu erstellen.", + "settings.context.memory.inspect": "Prüfen", + "settings.context.memory.rebuild": "Speicherindex neu erstellen", + "chat.memory.on": "Speicher an", + "chat.memory.label": "Speicher · {{tokens}} Tokens", + "chat.memory.status.loading": "Speicherstatus wird geladen", + "chat.memory.session.tokens": "Startkontext dieser Sitzung: {{tokens}} Tokens", + "chat.memory.total.tokens": "Gespeicherter Index: {{tokens}} Tokens", + "chat.memory.project.enabled": "Projektspeicher aktiviert", + "chat.memory.project.disabled": "Projektspeicher deaktiviert", + "chat.memory.command.failed": "Speicherbefehl fehlgeschlagen", + "chat.memory.savedOperations": "Letzte Speicheroperation: {{count}} Vorgänge", + "chat.memory.inspect": "Speicher prüfen", + "chat.memory.remember": "Merken", + "chat.memory.forget": "Vergessen", + "chat.memory.rebuild": "Index neu erstellen", + "chat.memory.disable": "Speicher deaktivieren", + "chat.memory.badge.injected": "Speicher eingefügt", + "chat.memory.badge.recalled": "Speicher abgerufen", + "chat.memory.badge.startupCtx": "Startkontext", + "chat.memory.badge.items": "{{count}} Elemente", + "chat.memory.badge.tokens": "{{tokens}} Tokens", + "chat.memory.badge.recalledDetail": "Speicher abgerufen: {{count}} Elemente - {{tokens}} Tokens", + "chat.memory.badge.files": "Speicherdateien: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Benutzerdefinierten prompt verwenden", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index c379dd5c761..ae7a17800b0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1602,6 +1602,7 @@ export const dict = { "settings.context.autoCompaction.title": "Auto Compaction", "settings.context.autoCompaction.description": "Automatically compact context before it reaches the limit", + "settings.context.compaction.title": "Compaction", "settings.context.compactionLimit.title": "Auto Compaction Limit", "settings.context.compactionLimit.description": "Compact when context reaches this percentage of the model window. Leave blank to use the safety buffer only.", @@ -1609,6 +1610,41 @@ export const dict = { "settings.context.prune.description": "Remove old tool outputs during compaction", "settings.context.watcherPatterns": "File Watcher Ignore Patterns", "settings.context.watcherPatterns.description": "Glob patterns for files the watcher should ignore", + "settings.context.memory.title": "Memory", + "settings.context.memory.project.title": "Project memory", + "settings.context.memory.autoSave.title": "Auto-save project memory", + "settings.context.memory.autoSave.description": + "Automatically save durable project facts from completed turns when memory is enabled.", + "settings.context.memory.index.title": "Memory index", + "settings.context.memory.status.notLoaded": "Not loaded", + "settings.context.memory.status.disabled": "Disabled", + "settings.context.memory.status.enabledTokensOps": + "Enabled - ~{{session}} startup tokens this session - ~{{tokens}} stored index tokens - last op {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Enable memory to create project memory files.", + "settings.context.memory.inspect": "Inspect", + "settings.context.memory.rebuild": "Rebuild memory index", + "chat.memory.on": "Memory on", + "chat.memory.label": "Memory · {{tokens}} tokens", + "chat.memory.status.loading": "Memory status loading", + "chat.memory.session.tokens": "Startup context this session: {{tokens}} tokens", + "chat.memory.total.tokens": "Stored index: {{tokens}} tokens", + "chat.memory.project.enabled": "Project memory enabled", + "chat.memory.project.disabled": "Project memory disabled", + "chat.memory.command.failed": "Memory command failed", + "chat.memory.savedOperations": "Last memory operation: {{count}} ops", + "chat.memory.inspect": "Inspect memory", + "chat.memory.remember": "Remember", + "chat.memory.forget": "Forget", + "chat.memory.rebuild": "Rebuild index", + "chat.memory.disable": "Disable memory", + "chat.memory.badge.injected": "Memory injected", + "chat.memory.badge.recalled": "Memory recalled", + "chat.memory.badge.startupCtx": "startup ctx", + "chat.memory.badge.items": "{{count}} items", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Memory recalled: {{count}} items - {{tokens}} tokens", + "chat.memory.badge.files": "Memory files: {{files}}", "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Use Custom Prompt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 51885214336..bdeaf5939a7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1646,6 +1646,7 @@ export const dict = { "settings.checkpoints.enable.description": "Crear puntos de control antes de editar archivos", "settings.context.autoCompaction.title": "Compactación automática", "settings.context.autoCompaction.description": "Compactar automáticamente el contexto antes de que alcance el límite", + "settings.context.compaction.title": "Compactación", "settings.context.compactionLimit.title": "Límite de compactación automática", "settings.context.compactionLimit.description": "Compactar cuando el contexto alcance este porcentaje de la ventana del modelo. Déjalo en blanco para usar solo el búfer de seguridad.", @@ -1654,6 +1655,42 @@ export const dict = { "settings.context.watcherPatterns": "Patrones de ignorar del observador", "settings.context.watcherPatterns.description": "Patrones glob para archivos que el observador debe ignorar", + "settings.context.memory.title": "Memoria", + "settings.context.memory.project.title": "Memoria del proyecto", + "settings.context.memory.autoSave.title": "Guardado automático de memoria del proyecto", + "settings.context.memory.autoSave.description": + "Guardar automáticamente hechos duraderos del proyecto desde turnos completados cuando la memoria está activada.", + "settings.context.memory.index.title": "Índice de memoria", + "settings.context.memory.status.notLoaded": "No cargada", + "settings.context.memory.status.disabled": "Desactivada", + "settings.context.memory.status.enabledTokensOps": + "Activada - ~{{session}} tokens de contexto inicial en esta sesión - ~{{tokens}} tokens del índice almacenado - última op. {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Activa la memoria para crear archivos de memoria del proyecto.", + "settings.context.memory.inspect": "Inspeccionar", + "settings.context.memory.rebuild": "Reconstruir índice de memoria", + "chat.memory.on": "Memoria activada", + "chat.memory.label": "Memoria · {{tokens}} tokens", + "chat.memory.status.loading": "Cargando estado de la memoria", + "chat.memory.session.tokens": "Contexto inicial de esta sesión: {{tokens}} tokens", + "chat.memory.total.tokens": "Índice almacenado: {{tokens}} tokens", + "chat.memory.project.enabled": "Memoria del proyecto activada", + "chat.memory.project.disabled": "Memoria del proyecto desactivada", + "chat.memory.command.failed": "Error en el comando de memoria", + "chat.memory.savedOperations": "Última operación de memoria: {{count}} ops", + "chat.memory.inspect": "Inspeccionar memoria", + "chat.memory.remember": "Recordar", + "chat.memory.forget": "Olvidar", + "chat.memory.rebuild": "Reconstruir índice", + "chat.memory.disable": "Desactivar memoria", + "chat.memory.badge.injected": "Memoria inyectada", + "chat.memory.badge.recalled": "Memoria recuperada", + "chat.memory.badge.startupCtx": "ctx inicial", + "chat.memory.badge.items": "{{count}} elementos", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Memoria recuperada: {{count}} elementos - {{tokens}} tokens", + "chat.memory.badge.files": "Archivos de memoria: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Usar prompt personalizado", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index f05e4c68fbd..95fff423cee 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1666,6 +1666,7 @@ export const dict = { "settings.context.autoCompaction.title": "Compaction automatique", "settings.context.autoCompaction.description": "Compacter automatiquement le contexte avant qu'il n'atteigne la limite", + "settings.context.compaction.title": "Compactage", "settings.context.compactionLimit.title": "Limite de compactage automatique", "settings.context.compactionLimit.description": "Compacter lorsque le contexte atteint ce pourcentage de la fenêtre du modèle. Laissez vide pour utiliser uniquement la marge de sécurité.", @@ -1674,6 +1675,42 @@ export const dict = { "settings.context.watcherPatterns": "Motifs d'ignorance de l'observateur", "settings.context.watcherPatterns.description": "Motifs glob pour les fichiers que l'observateur doit ignorer", + "settings.context.memory.title": "Mémoire", + "settings.context.memory.project.title": "Mémoire du projet", + "settings.context.memory.autoSave.title": "Enregistrement automatique de la mémoire du projet", + "settings.context.memory.autoSave.description": + "Enregistrer automatiquement les faits durables du projet à partir des tours terminés lorsque la mémoire est activée.", + "settings.context.memory.index.title": "Index de mémoire", + "settings.context.memory.status.notLoaded": "Non chargée", + "settings.context.memory.status.disabled": "Désactivée", + "settings.context.memory.status.enabledTokensOps": + "Activée - ~{{session}} tokens de contexte initial dans cette session - ~{{tokens}} tokens dans l’index stocké - dernière opération {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Activez la mémoire pour créer les fichiers de mémoire du projet.", + "settings.context.memory.inspect": "Inspecter", + "settings.context.memory.rebuild": "Reconstruire l’index de mémoire", + "chat.memory.on": "Mémoire activée", + "chat.memory.label": "Mémoire · {{tokens}} tokens", + "chat.memory.status.loading": "Chargement de l’état de la mémoire", + "chat.memory.session.tokens": "Contexte initial de cette session : {{tokens}} tokens", + "chat.memory.total.tokens": "Index stocké : {{tokens}} tokens", + "chat.memory.project.enabled": "Mémoire du projet activée", + "chat.memory.project.disabled": "Mémoire du projet désactivée", + "chat.memory.command.failed": "La commande de mémoire a échoué", + "chat.memory.savedOperations": "Dernière opération de mémoire : {{count}} ops", + "chat.memory.inspect": "Inspecter la mémoire", + "chat.memory.remember": "Mémoriser", + "chat.memory.forget": "Oublier", + "chat.memory.rebuild": "Reconstruire l’index", + "chat.memory.disable": "Désactiver la mémoire", + "chat.memory.badge.injected": "Mémoire injectée", + "chat.memory.badge.recalled": "Mémoire rappelée", + "chat.memory.badge.startupCtx": "ctx initial", + "chat.memory.badge.items": "{{count}} éléments", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Mémoire rappelée : {{count}} éléments - {{tokens}} tokens", + "chat.memory.badge.files": "Fichiers mémoire : {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Utiliser un prompt personnalisé", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index d37b19b83c8..9291b14d1b6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1428,10 +1428,47 @@ export const dict = { "Crea checkpoint prima delle modifiche ai file così puoi ripristinare stati precedenti", "settings.context.autoCompaction.title": "Compattazione automatica", "settings.context.autoCompaction.description": "Compatta automaticamente il contesto quando è pieno", + "settings.context.compaction.title": "Compattazione", "settings.context.prune.title": "Riduci output vecchi", "settings.context.prune.description": "Rimuovi output tool vecchi durante la compattazione", "settings.context.watcherPatterns": "Pattern ignore file watcher", "settings.context.watcherPatterns.description": "Pattern glob per file che il watcher deve ignorare", + "settings.context.memory.title": "Memoria", + "settings.context.memory.project.title": "Memoria del progetto", + "settings.context.memory.autoSave.title": "Salvataggio automatico memoria progetto", + "settings.context.memory.autoSave.description": + "Salva automaticamente fatti durevoli del progetto dai turni completati quando la memoria è attiva.", + "settings.context.memory.index.title": "Indice della memoria", + "settings.context.memory.status.notLoaded": "Non caricata", + "settings.context.memory.status.disabled": "Disattivata", + "settings.context.memory.status.enabledTokensOps": + "Attivata - ~{{session}} token di contesto iniziale in questa sessione - ~{{tokens}} token nell’indice salvato - ultima operazione {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Attiva la memoria per creare i file di memoria del progetto.", + "settings.context.memory.inspect": "Ispeziona", + "settings.context.memory.rebuild": "Ricostruisci indice della memoria", + "chat.memory.on": "Memoria attivata", + "chat.memory.label": "Memoria · {{tokens}} token", + "chat.memory.status.loading": "Caricamento stato memoria", + "chat.memory.session.tokens": "Contesto iniziale di questa sessione: {{tokens}} token", + "chat.memory.total.tokens": "Indice salvato: {{tokens}} token", + "chat.memory.project.enabled": "Memoria del progetto attivata", + "chat.memory.project.disabled": "Memoria del progetto disattivata", + "chat.memory.command.failed": "Comando memoria non riuscito", + "chat.memory.savedOperations": "Ultima operazione di memoria: {{count}} ops", + "chat.memory.inspect": "Ispeziona memoria", + "chat.memory.remember": "Ricorda", + "chat.memory.forget": "Dimentica", + "chat.memory.rebuild": "Ricostruisci indice", + "chat.memory.disable": "Disattiva memoria", + "chat.memory.badge.injected": "Memoria iniettata", + "chat.memory.badge.recalled": "Memoria richiamata", + "chat.memory.badge.startupCtx": "ctx iniziale", + "chat.memory.badge.items": "{{count}} elementi", + "chat.memory.badge.tokens": "{{tokens}} token", + "chat.memory.badge.recalledDetail": "Memoria richiamata: {{count}} elementi - {{tokens}} token", + "chat.memory.badge.files": "File di memoria: {{files}}", + "settings.commitMessage.title": "Messaggio commit", "settings.commitMessage.override.title": "Usa prompt personalizzato", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 9b807d5d002..bc8570701a8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1617,6 +1617,7 @@ export const dict = { "settings.checkpoints.enable.description": "ファイル編集前にチェックポイントを作成して以前の状態を復元可能にする", "settings.context.autoCompaction.title": "自動圧縮", "settings.context.autoCompaction.description": "コンテキストが上限に達する前に自動的に圧縮", + "settings.context.compaction.title": "圧縮", "settings.context.compactionLimit.title": "自動圧縮の上限", "settings.context.compactionLimit.description": "コンテキストがモデルウィンドウのこの割合に達したら圧縮します。安全バッファーのみを使用するには空欄のままにしてください。", @@ -1625,6 +1626,42 @@ export const dict = { "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.index.title": "メモリインデックス", + "settings.context.memory.status.notLoaded": "未読み込み", + "settings.context.memory.status.disabled": "無効", + "settings.context.memory.status.enabledTokensOps": + "有効 - このセッションの起動コンテキスト ~{{session}} トークン - 保存済みインデックス ~{{tokens}} トークン - 最後の操作 {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "メモリを有効にしてプロジェクトメモリファイルを作成します。", + "settings.context.memory.inspect": "検査", + "settings.context.memory.rebuild": "メモリインデックスを再構築", + "chat.memory.on": "メモリ オン", + "chat.memory.label": "メモリ · {{tokens}} トークン", + "chat.memory.status.loading": "メモリ状態を読み込み中", + "chat.memory.session.tokens": "このセッションの起動コンテキスト: {{tokens}} トークン", + "chat.memory.total.tokens": "保存済みインデックス: {{tokens}} トークン", + "chat.memory.project.enabled": "プロジェクトメモリが有効です", + "chat.memory.project.disabled": "プロジェクトメモリが無効です", + "chat.memory.command.failed": "メモリコマンドに失敗しました", + "chat.memory.savedOperations": "最後のメモリ操作: {{count}} 件", + "chat.memory.inspect": "メモリを検査", + "chat.memory.remember": "記憶", + "chat.memory.forget": "忘れる", + "chat.memory.rebuild": "インデックスを再構築", + "chat.memory.disable": "メモリを無効化", + "chat.memory.badge.injected": "メモリを注入しました", + "chat.memory.badge.recalled": "メモリを呼び出しました", + "chat.memory.badge.startupCtx": "起動ctx", + "chat.memory.badge.items": "{{count}} 件", + "chat.memory.badge.tokens": "{{tokens}} トークン", + "chat.memory.badge.recalledDetail": "メモリを呼び出しました: {{count}} 件 - {{tokens}} トークン", + "chat.memory.badge.files": "メモリファイル: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "カスタム prompt を使用", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 203f58d32b5..92c95acdab6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1600,6 +1600,7 @@ export const dict = { "settings.checkpoints.enable.description": "파일 편집 전 체크포인트를 생성하여 이전 상태를 복원할 수 있습니다", "settings.context.autoCompaction.title": "자동 압축", "settings.context.autoCompaction.description": "컨텍스트가 한도에 도달하기 전에 자동으로 압축", + "settings.context.compaction.title": "압축", "settings.context.compactionLimit.title": "자동 압축 한도", "settings.context.compactionLimit.description": "컨텍스트가 모델 창의 이 비율에 도달하면 압축합니다. 안전 버퍼만 사용하려면 비워 두세요.", @@ -1608,6 +1609,42 @@ export const dict = { "settings.context.watcherPatterns": "파일 감시자 무시 패턴", "settings.context.watcherPatterns.description": "감시자가 무시해야 할 파일의 글로브 패턴", + "settings.context.memory.title": "메모리", + "settings.context.memory.project.title": "프로젝트 메모리", + "settings.context.memory.autoSave.title": "프로젝트 메모리 자동 저장", + "settings.context.memory.autoSave.description": + "메모리가 활성화되면 완료된 턴에서 지속적인 프로젝트 사실을 자동으로 저장합니다.", + "settings.context.memory.index.title": "메모리 인덱스", + "settings.context.memory.status.notLoaded": "로드되지 않음", + "settings.context.memory.status.disabled": "비활성화됨", + "settings.context.memory.status.enabledTokensOps": + "활성화됨 - 이 세션의 시작 컨텍스트 ~{{session}} 토큰 - 저장된 인덱스 ~{{tokens}} 토큰 - 마지막 작업 {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "메모리를 활성화하여 프로젝트 메모리 파일을 만듭니다.", + "settings.context.memory.inspect": "검사", + "settings.context.memory.rebuild": "메모리 인덱스 다시 빌드", + "chat.memory.on": "메모리 켜짐", + "chat.memory.label": "메모리 · {{tokens}} 토큰", + "chat.memory.status.loading": "메모리 상태 로드 중", + "chat.memory.session.tokens": "이 세션의 시작 컨텍스트: {{tokens}} 토큰", + "chat.memory.total.tokens": "저장된 인덱스: {{tokens}} 토큰", + "chat.memory.project.enabled": "프로젝트 메모리 활성화됨", + "chat.memory.project.disabled": "프로젝트 메모리 비활성화됨", + "chat.memory.command.failed": "메모리 명령 실패", + "chat.memory.savedOperations": "마지막 메모리 작업: {{count}}개 작업", + "chat.memory.inspect": "메모리 검사", + "chat.memory.remember": "기억", + "chat.memory.forget": "잊기", + "chat.memory.rebuild": "인덱스 다시 빌드", + "chat.memory.disable": "메모리 비활성화", + "chat.memory.badge.injected": "메모리 주입됨", + "chat.memory.badge.recalled": "메모리 불러옴", + "chat.memory.badge.startupCtx": "시작 ctx", + "chat.memory.badge.items": "{{count}}개 항목", + "chat.memory.badge.tokens": "{{tokens}} 토큰", + "chat.memory.badge.recalledDetail": "메모리 불러옴: {{count}}개 항목 - {{tokens}} 토큰", + "chat.memory.badge.files": "메모리 파일: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "사용자 지정 prompt 사용", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 136df47605a..5f93bd1575a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1598,6 +1598,7 @@ export const dict = { "settings.context.autoCompaction.title": "Automatische Compactie", "settings.context.autoCompaction.description": "Context automatisch compacteren voordat deze de limiet bereikt", + "settings.context.compaction.title": "Compactie", "settings.context.compactionLimit.title": "Limiet voor automatisch compacteren", "settings.context.compactionLimit.description": "Compacteer wanneer de context dit percentage van het modelvenster bereikt. Laat leeg om alleen de veiligheidsbuffer te gebruiken.", @@ -1606,6 +1607,42 @@ export const dict = { "settings.context.watcherPatterns": "File Watcher Negeer Patronen", "settings.context.watcherPatterns.description": "Glob-patronen voor bestanden die de watcher moet negeren", + "settings.context.memory.title": "Geheugen", + "settings.context.memory.project.title": "Projectgeheugen", + "settings.context.memory.autoSave.title": "Projectgeheugen automatisch opslaan", + "settings.context.memory.autoSave.description": + "Sla duurzame projectfeiten automatisch op uit voltooide beurten wanneer geheugen is ingeschakeld.", + "settings.context.memory.index.title": "Geheugenindex", + "settings.context.memory.status.notLoaded": "Niet geladen", + "settings.context.memory.status.disabled": "Uitgeschakeld", + "settings.context.memory.status.enabledTokensOps": + "Ingeschakeld - ~{{session}} startcontexttokens in deze sessie - ~{{tokens}} tokens in opgeslagen index - laatste bewerking {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Schakel geheugen in om projectgeheugenbestanden te maken.", + "settings.context.memory.inspect": "Inspecteren", + "settings.context.memory.rebuild": "Geheugenindex opnieuw opbouwen", + "chat.memory.on": "Geheugen aan", + "chat.memory.label": "Geheugen · {{tokens}} tokens", + "chat.memory.status.loading": "Geheugenstatus laden", + "chat.memory.session.tokens": "Startcontext deze sessie: {{tokens}} tokens", + "chat.memory.total.tokens": "Opgeslagen index: {{tokens}} tokens", + "chat.memory.project.enabled": "Projectgeheugen ingeschakeld", + "chat.memory.project.disabled": "Projectgeheugen uitgeschakeld", + "chat.memory.command.failed": "Geheugenopdracht mislukt", + "chat.memory.savedOperations": "Laatste geheugenbewerking: {{count}} bewerkingen", + "chat.memory.inspect": "Geheugen inspecteren", + "chat.memory.remember": "Onthouden", + "chat.memory.forget": "Vergeten", + "chat.memory.rebuild": "Index opnieuw opbouwen", + "chat.memory.disable": "Geheugen uitschakelen", + "chat.memory.badge.injected": "Geheugen geïnjecteerd", + "chat.memory.badge.recalled": "Geheugen opgehaald", + "chat.memory.badge.startupCtx": "startctx", + "chat.memory.badge.items": "{{count}} items", + "chat.memory.badge.tokens": "{{tokens}} tokens", + "chat.memory.badge.recalledDetail": "Geheugen opgehaald: {{count}} items - {{tokens}} tokens", + "chat.memory.badge.files": "Geheugenbestanden: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Aangepaste prompt gebruiken", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f568a6f3d2a..6e5c43cdc51 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1620,6 +1620,7 @@ export const dict = { "settings.checkpoints.enable.description": "Opprett kontrollpunkter før filredigeringer", "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimer automatisk kontekst før den når grensen", + "settings.context.compaction.title": "Komprimering", "settings.context.compactionLimit.title": "Grense for automatisk komprimering", "settings.context.compactionLimit.description": "Komprimer når konteksten når denne prosentandelen av modellvinduet. La stå tomt for å bare bruke sikkerhetsbufferen.", @@ -1628,6 +1629,42 @@ export const dict = { "settings.context.watcherPatterns": "Filvakt-ignormønstre", "settings.context.watcherPatterns.description": "Glob-mønstre for filer som vakten skal ignorere", + "settings.context.memory.title": "Minne", + "settings.context.memory.project.title": "Prosjektminne", + "settings.context.memory.autoSave.title": "Lagre prosjektminne automatisk", + "settings.context.memory.autoSave.description": + "Lagrer varige prosjektfakta automatisk fra fullførte turer når minne er aktivert.", + "settings.context.memory.index.title": "Minneindeks", + "settings.context.memory.status.notLoaded": "Ikke lastet", + "settings.context.memory.status.disabled": "Deaktivert", + "settings.context.memory.status.enabledTokensOps": + "Aktivert - ~{{session}} oppstartstokener i denne økten - ~{{tokens}} tokener i lagret indeks - siste operasjon {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Aktiver minne for å opprette prosjektets minnefiler.", + "settings.context.memory.inspect": "Inspiser", + "settings.context.memory.rebuild": "Bygg minneindeks på nytt", + "chat.memory.on": "Minne på", + "chat.memory.label": "Minne · {{tokens}} tokens", + "chat.memory.status.loading": "Laster minnestatus", + "chat.memory.session.tokens": "Oppstartskontekst denne økten: {{tokens}} tokener", + "chat.memory.total.tokens": "Lagret indeks: {{tokens}} tokener", + "chat.memory.project.enabled": "Prosjektminne aktivert", + "chat.memory.project.disabled": "Prosjektminne deaktivert", + "chat.memory.command.failed": "Minnekommando mislyktes", + "chat.memory.savedOperations": "Siste minneoperasjon: {{count}} operasjoner", + "chat.memory.inspect": "Inspiser minne", + "chat.memory.remember": "Husk", + "chat.memory.forget": "Glem", + "chat.memory.rebuild": "Bygg indeks på nytt", + "chat.memory.disable": "Deaktiver minne", + "chat.memory.badge.injected": "Minne injisert", + "chat.memory.badge.recalled": "Minne hentet", + "chat.memory.badge.startupCtx": "oppstartsctx", + "chat.memory.badge.items": "{{count}} elementer", + "chat.memory.badge.tokens": "{{tokens}} tokener", + "chat.memory.badge.recalledDetail": "Minne hentet: {{count}} elementer - {{tokens}} tokener", + "chat.memory.badge.files": "Minnefiler: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Bruk egendefinert prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index cac6e6766e1..f7513a620ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1629,6 +1629,7 @@ export const dict = { "settings.checkpoints.enable.description": "Twórz punkty kontrolne przed edycją plików", "settings.context.autoCompaction.title": "Automatyczna kompakcja", "settings.context.autoCompaction.description": "Automatycznie kompaktuj kontekst, zanim osiągnie limit", + "settings.context.compaction.title": "Kompaktowanie", "settings.context.compactionLimit.title": "Limit automatycznego kompaktowania", "settings.context.compactionLimit.description": "Kompaktuj, gdy kontekst osiągnie ten procent okna modelu. Pozostaw puste, aby używać tylko bufora bezpieczeństwa.", @@ -1637,6 +1638,42 @@ export const dict = { "settings.context.watcherPatterns": "Wzorce ignorowania obserwatora plików", "settings.context.watcherPatterns.description": "Wzorce glob dla plików do ignorowania", + "settings.context.memory.title": "Pamięć", + "settings.context.memory.project.title": "Pamięć projektu", + "settings.context.memory.autoSave.title": "Automatycznie zapisuj pamięć projektu", + "settings.context.memory.autoSave.description": + "Automatycznie zapisuje trwałe fakty projektu z zakończonych tur, gdy pamięć jest włączona.", + "settings.context.memory.index.title": "Indeks pamięci", + "settings.context.memory.status.notLoaded": "Nie wczytano", + "settings.context.memory.status.disabled": "Wyłączona", + "settings.context.memory.status.enabledTokensOps": + "Włączona - ~{{session}} tokenów kontekstu startowego w tej sesji - ~{{tokens}} tokenów w zapisanym indeksie - ostatnia operacja {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Włącz pamięć, aby utworzyć pliki pamięci projektu.", + "settings.context.memory.inspect": "Sprawdź", + "settings.context.memory.rebuild": "Odbuduj indeks pamięci", + "chat.memory.on": "Pamięć włączona", + "chat.memory.label": "Pamięć · {{tokens}} tokenów", + "chat.memory.status.loading": "Ładowanie stanu pamięci", + "chat.memory.session.tokens": "Kontekst startowy tej sesji: {{tokens}} tokenów", + "chat.memory.total.tokens": "Zapisany indeks: {{tokens}} tokenów", + "chat.memory.project.enabled": "Pamięć projektu włączona", + "chat.memory.project.disabled": "Pamięć projektu wyłączona", + "chat.memory.command.failed": "Polecenie pamięci nie powiodło się", + "chat.memory.savedOperations": "Ostatnia operacja pamięci: {{count}} operacji", + "chat.memory.inspect": "Sprawdź pamięć", + "chat.memory.remember": "Zapamiętaj", + "chat.memory.forget": "Zapomnij", + "chat.memory.rebuild": "Odbuduj indeks", + "chat.memory.disable": "Wyłącz pamięć", + "chat.memory.badge.injected": "Pamięć wstrzyknięta", + "chat.memory.badge.recalled": "Pamięć przywołana", + "chat.memory.badge.startupCtx": "ctx startowy", + "chat.memory.badge.items": "{{count}} elementów", + "chat.memory.badge.tokens": "{{tokens}} tokenów", + "chat.memory.badge.recalledDetail": "Pamięć przywołana: {{count}} elementów - {{tokens}} tokenów", + "chat.memory.badge.files": "Pliki pamięci: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Użyj niestandardowego prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 09820f33d47..60876560858 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1628,6 +1628,7 @@ export const dict = { "settings.checkpoints.enable.description": "Создавать контрольные точки перед редактированием файлов", "settings.context.autoCompaction.title": "Автоматическое сжатие", "settings.context.autoCompaction.description": "Автоматически сжимать контекст до достижения лимита", + "settings.context.compaction.title": "Сжатие", "settings.context.compactionLimit.title": "Лимит автоматического сжатия", "settings.context.compactionLimit.description": "Сжимать, когда контекст достигает этого процента окна модели. Оставьте пустым, чтобы использовать только буфер безопасности.", @@ -1636,6 +1637,42 @@ export const dict = { "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.index.title": "Индекс памяти", + "settings.context.memory.status.notLoaded": "Не загружена", + "settings.context.memory.status.disabled": "Отключена", + "settings.context.memory.status.enabledTokensOps": + "Включена - ~{{session}} токенов начального контекста в этой сессии - ~{{tokens}} токенов в сохранённом индексе - последняя операция {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Включите память, чтобы создать файлы памяти проекта.", + "settings.context.memory.inspect": "Проверить", + "settings.context.memory.rebuild": "Перестроить индекс памяти", + "chat.memory.on": "Память включена", + "chat.memory.label": "Память · {{tokens}} токенов", + "chat.memory.status.loading": "Загрузка состояния памяти", + "chat.memory.session.tokens": "Начальный контекст этой сессии: {{tokens}} токенов", + "chat.memory.total.tokens": "Сохранённый индекс: {{tokens}} токенов", + "chat.memory.project.enabled": "Память проекта включена", + "chat.memory.project.disabled": "Память проекта отключена", + "chat.memory.command.failed": "Команда памяти не выполнена", + "chat.memory.savedOperations": "Последняя операция памяти: {{count}} операций", + "chat.memory.inspect": "Проверить память", + "chat.memory.remember": "Запомнить", + "chat.memory.forget": "Забыть", + "chat.memory.rebuild": "Перестроить индекс", + "chat.memory.disable": "Отключить память", + "chat.memory.badge.injected": "Память внедрена", + "chat.memory.badge.recalled": "Память извлечена", + "chat.memory.badge.startupCtx": "начальный ctx", + "chat.memory.badge.items": "{{count}} элементов", + "chat.memory.badge.tokens": "{{tokens}} токенов", + "chat.memory.badge.recalledDetail": "Память извлечена: {{count}} элементов - {{tokens}} токенов", + "chat.memory.badge.files": "Файлы памяти: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Использовать пользовательский prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 1bda1a2b38c..75cbec15a19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1598,6 +1598,7 @@ export const dict = { "settings.checkpoints.enable.description": "สร้างจุดตรวจก่อนแก้ไขไฟล์", "settings.context.autoCompaction.title": "การบีบอัดอัตโนมัติ", "settings.context.autoCompaction.description": "บีบอัดบริบทอัตโนมัติก่อนถึงขีดจำกัด", + "settings.context.compaction.title": "การบีบอัด", "settings.context.compactionLimit.title": "ขีดจำกัดการบีบอัดอัตโนมัติ", "settings.context.compactionLimit.description": "บีบอัดเมื่อบริบทถึงเปอร์เซ็นต์นี้ของหน้าต่างโมเดล เว้นว่างไว้เพื่อใช้เฉพาะบัฟเฟอร์ความปลอดภัย", @@ -1606,6 +1607,42 @@ export const dict = { "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.index.title": "ดัชนีความจำ", + "settings.context.memory.status.notLoaded": "ยังไม่ได้โหลด", + "settings.context.memory.status.disabled": "ปิดใช้งาน", + "settings.context.memory.status.enabledTokensOps": + "เปิดใช้งาน - ~{{session}} โทเค็นบริบทเริ่มต้นในเซสชันนี้ - ~{{tokens}} โทเค็นในดัชนีที่บันทึกไว้ - การทำงานล่าสุด {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "เปิดใช้ความจำเพื่อสร้างไฟล์ความจำของโปรเจกต์", + "settings.context.memory.inspect": "ตรวจสอบ", + "settings.context.memory.rebuild": "สร้างดัชนีความจำใหม่", + "chat.memory.on": "เปิดความจำ", + "chat.memory.label": "ความจำ · {{tokens}} โทเค็น", + "chat.memory.status.loading": "กำลังโหลดสถานะความจำ", + "chat.memory.session.tokens": "บริบทเริ่มต้นของเซสชันนี้: {{tokens}} โทเค็น", + "chat.memory.total.tokens": "ดัชนีที่บันทึกไว้: {{tokens}} โทเค็น", + "chat.memory.project.enabled": "เปิดใช้ความจำของโปรเจกต์แล้ว", + "chat.memory.project.disabled": "ปิดใช้ความจำของโปรเจกต์แล้ว", + "chat.memory.command.failed": "คำสั่งความจำล้มเหลว", + "chat.memory.savedOperations": "การทำงานความจำล่าสุด: {{count}} รายการ", + "chat.memory.inspect": "ตรวจสอบความจำ", + "chat.memory.remember": "จำ", + "chat.memory.forget": "ลืม", + "chat.memory.rebuild": "สร้างดัชนีใหม่", + "chat.memory.disable": "ปิดใช้ความจำ", + "chat.memory.badge.injected": "แทรกความจำแล้ว", + "chat.memory.badge.recalled": "เรียกคืนความจำแล้ว", + "chat.memory.badge.startupCtx": "ctx เริ่มต้น", + "chat.memory.badge.items": "{{count}} รายการ", + "chat.memory.badge.tokens": "{{tokens}} โทเค็น", + "chat.memory.badge.recalledDetail": "เรียกคืนความจำแล้ว: {{count}} รายการ - {{tokens}} โทเค็น", + "chat.memory.badge.files": "ไฟล์ความจำ: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "ใช้ prompt แบบกำหนดเอง", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 4ea74089144..0b9bb8900be 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1585,6 +1585,7 @@ export const dict = { "settings.context.autoCompaction.title": "Otomatik Sıkıştırma", "settings.context.autoCompaction.description": "Bağlam sınıra ulaşmadan önce otomatik olarak sıkıştır", + "settings.context.compaction.title": "Sıkıştırma", "settings.context.compactionLimit.title": "Otomatik sıkıştırma sınırı", "settings.context.compactionLimit.description": "Bağlam model penceresinin bu yüzdesine ulaştığında sıkıştır. Yalnızca güvenlik tamponunu kullanmak için boş bırakın.", @@ -1593,6 +1594,42 @@ export const dict = { "settings.context.watcherPatterns": "Dosya İzleyici Yok Sayma Kalıpları", "settings.context.watcherPatterns.description": "İzleyicinin yok sayması gereken dosyalar için glob kalıpları", + "settings.context.memory.title": "Bellek", + "settings.context.memory.project.title": "Proje belleği", + "settings.context.memory.autoSave.title": "Proje belleğini otomatik kaydet", + "settings.context.memory.autoSave.description": + "Bellek açıkken tamamlanan turlardan kalıcı proje gerçeklerini otomatik olarak kaydeder.", + "settings.context.memory.index.title": "Bellek indeksi", + "settings.context.memory.status.notLoaded": "Yüklenmedi", + "settings.context.memory.status.disabled": "Devre dışı", + "settings.context.memory.status.enabledTokensOps": + "Etkin - bu oturumda ~{{session}} başlangıç bağlamı tokenı - depolanan indekste ~{{tokens}} token - son işlem {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Proje belleği dosyalarını oluşturmak için belleği etkinleştirin.", + "settings.context.memory.inspect": "İncele", + "settings.context.memory.rebuild": "Bellek indeksini yeniden oluştur", + "chat.memory.on": "Bellek açık", + "chat.memory.label": "Bellek · {{tokens}} token", + "chat.memory.status.loading": "Bellek durumu yükleniyor", + "chat.memory.session.tokens": "Bu oturumdaki başlangıç bağlamı: {{tokens}} token", + "chat.memory.total.tokens": "Depolanan indeks: {{tokens}} token", + "chat.memory.project.enabled": "Proje belleği etkin", + "chat.memory.project.disabled": "Proje belleği devre dışı", + "chat.memory.command.failed": "Bellek komutu başarısız oldu", + "chat.memory.savedOperations": "Son bellek işlemi: {{count}} işlem", + "chat.memory.inspect": "Belleği incele", + "chat.memory.remember": "Hatırla", + "chat.memory.forget": "Unut", + "chat.memory.rebuild": "İndeksi yeniden oluştur", + "chat.memory.disable": "Belleği devre dışı bırak", + "chat.memory.badge.injected": "Bellek eklendi", + "chat.memory.badge.recalled": "Bellek geri çağrıldı", + "chat.memory.badge.startupCtx": "başlangıç ctx", + "chat.memory.badge.items": "{{count}} öğe", + "chat.memory.badge.tokens": "{{tokens}} token", + "chat.memory.badge.recalledDetail": "Bellek geri çağrıldı: {{count}} öğe - {{tokens}} token", + "chat.memory.badge.files": "Bellek dosyaları: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Özel prompt Kullan", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index d59d2b1d576..cfb0d8e6460 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1582,6 +1582,7 @@ export const dict = { "settings.context.autoCompaction.title": "Автоматичне стиснення", "settings.context.autoCompaction.description": "Автоматично стискати контекст до досягнення ліміту", + "settings.context.compaction.title": "Стискання", "settings.context.compactionLimit.title": "Ліміт автоматичного стискання", "settings.context.compactionLimit.description": "Стискати, коли контекст досягає цього відсотка вікна моделі. Залиште порожнім, щоб використовувати лише буфер безпеки.", @@ -1590,6 +1591,42 @@ export const dict = { "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.index.title": "Індекс пам’яті", + "settings.context.memory.status.notLoaded": "Не завантажено", + "settings.context.memory.status.disabled": "Вимкнено", + "settings.context.memory.status.enabledTokensOps": + "Увімкнено - ~{{session}} токенів стартового контексту в цій сесії - ~{{tokens}} токенів у збереженому індексі - остання операція {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "Увімкніть пам’ять, щоб створити файли пам’яті проєкту.", + "settings.context.memory.inspect": "Перевірити", + "settings.context.memory.rebuild": "Перебудувати індекс пам’яті", + "chat.memory.on": "Пам’ять увімкнено", + "chat.memory.label": "Пам’ять · {{tokens}} токенів", + "chat.memory.status.loading": "Завантаження стану пам’яті", + "chat.memory.session.tokens": "Стартовий контекст цієї сесії: {{tokens}} токенів", + "chat.memory.total.tokens": "Збережений індекс: {{tokens}} токенів", + "chat.memory.project.enabled": "Пам’ять проєкту увімкнено", + "chat.memory.project.disabled": "Пам’ять проєкту вимкнено", + "chat.memory.command.failed": "Команду пам’яті не виконано", + "chat.memory.savedOperations": "Остання операція пам’яті: {{count}} операцій", + "chat.memory.inspect": "Перевірити пам’ять", + "chat.memory.remember": "Запам’ятати", + "chat.memory.forget": "Забути", + "chat.memory.rebuild": "Перебудувати індекс", + "chat.memory.disable": "Вимкнути пам’ять", + "chat.memory.badge.injected": "Пам’ять додано", + "chat.memory.badge.recalled": "Пам’ять відновлено", + "chat.memory.badge.startupCtx": "стартовий ctx", + "chat.memory.badge.items": "{{count}} елементів", + "chat.memory.badge.tokens": "{{tokens}} токенів", + "chat.memory.badge.recalledDetail": "Пам’ять відновлено: {{count}} елементів - {{tokens}} токенів", + "chat.memory.badge.files": "Файли пам’яті: {{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "Використовувати власний prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 987d2f2ccb5..1d07cf0ed7c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1557,6 +1557,7 @@ export const dict = { "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": "修剪旧输出", @@ -1564,6 +1565,41 @@ export const dict = { "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.index.title": "记忆索引", + "settings.context.memory.status.notLoaded": "未加载", + "settings.context.memory.status.disabled": "已禁用", + "settings.context.memory.status.enabledTokensOps": + "已启用 - 本会话启动上下文约 {{session}} 个 token - 已存储索引约 {{tokens}} 个 token - 上次操作 {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "启用记忆以创建项目记忆文件。", + "settings.context.memory.inspect": "检查", + "settings.context.memory.rebuild": "重建记忆索引", + "chat.memory.on": "记忆开启", + "chat.memory.label": "记忆 · {{tokens}} 个 token", + "chat.memory.status.loading": "正在加载记忆状态", + "chat.memory.session.tokens": "本会话启动上下文:{{tokens}} 个 token", + "chat.memory.total.tokens": "已存储索引:{{tokens}} 个 token", + "chat.memory.project.enabled": "项目记忆已启用", + "chat.memory.project.disabled": "项目记忆已禁用", + "chat.memory.command.failed": "记忆命令失败", + "chat.memory.savedOperations": "上次记忆操作:{{count}} 次操作", + "chat.memory.inspect": "检查记忆", + "chat.memory.remember": "记住", + "chat.memory.forget": "忘记", + "chat.memory.rebuild": "重建索引", + "chat.memory.disable": "禁用记忆", + "chat.memory.badge.injected": "已注入记忆", + "chat.memory.badge.recalled": "已召回记忆", + "chat.memory.badge.startupCtx": "启动 ctx", + "chat.memory.badge.items": "{{count}} 项", + "chat.memory.badge.tokens": "{{tokens}} 个 token", + "chat.memory.badge.recalledDetail": "已召回记忆:{{count}} 项 - {{tokens}} 个 token", + "chat.memory.badge.files": "记忆文件:{{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "使用自定义 prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index f596429f966..e5d49f7fbe8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1524,6 +1524,7 @@ export const dict = { "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": "修剪舊輸出", @@ -1531,6 +1532,41 @@ export const dict = { "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.index.title": "記憶索引", + "settings.context.memory.status.notLoaded": "未載入", + "settings.context.memory.status.disabled": "已停用", + "settings.context.memory.status.enabledTokensOps": + "已啟用 - 此工作階段啟動內容約 {{session}} 個 token - 已儲存索引約 {{tokens}} 個 token - 上次操作 {{ops}}", + "settings.context.memory.index.path": "{{path}}/index.kmem", + "settings.context.memory.index.enable": "啟用記憶以建立專案記憶檔案。", + "settings.context.memory.inspect": "檢查", + "settings.context.memory.rebuild": "重建記憶索引", + "chat.memory.on": "記憶開啟", + "chat.memory.label": "記憶 · {{tokens}} 個 token", + "chat.memory.status.loading": "正在載入記憶狀態", + "chat.memory.session.tokens": "此工作階段啟動內容:{{tokens}} 個 token", + "chat.memory.total.tokens": "已儲存索引:{{tokens}} 個 token", + "chat.memory.project.enabled": "專案記憶已啟用", + "chat.memory.project.disabled": "專案記憶已停用", + "chat.memory.command.failed": "記憶命令失敗", + "chat.memory.savedOperations": "上次記憶操作:{{count}} 次操作", + "chat.memory.inspect": "檢查記憶", + "chat.memory.remember": "記住", + "chat.memory.forget": "忘記", + "chat.memory.rebuild": "重建索引", + "chat.memory.disable": "停用記憶", + "chat.memory.badge.injected": "已注入記憶", + "chat.memory.badge.recalled": "已召回記憶", + "chat.memory.badge.startupCtx": "啟動 ctx", + "chat.memory.badge.items": "{{count}} 項", + "chat.memory.badge.tokens": "{{tokens}} 個 token", + "chat.memory.badge.recalledDetail": "已召回記憶:{{count}} 項 - {{tokens}} 個 token", + "chat.memory.badge.files": "記憶檔案:{{files}}", + "settings.commitMessage.title": "Commit Message", "settings.commitMessage.override.title": "使用自訂 prompt", "settings.commitMessage.override.description": diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 71bd4db4f3c..03dfa684a6f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -34,6 +34,7 @@ import { NotificationsContext } from "../context/notifications" import { LanguageContext } from "../context/language" import { IndexingProvider } from "../context/indexing" import { KiloEmbeddingModelsProvider } from "../context/kilo-embedding-models" +import { MemoryProvider } from "../context/memory" import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en" import { dict as appEn } from "../i18n/en" import { dict as amEn } from "../../agent-manager/i18n/en" @@ -439,30 +440,32 @@ export const StoryProviders: ParentComponent = (props) => { - - - - - - - - {props.noPadding ? ( - props.children - ) : ( -
{props.children}
- )} -
-
-
-
-
-
-
+ + + + + + + + + {props.noPadding ? ( + props.children + ) : ( +
{props.children}
+ )} +
+
+
+
+
+
+
+
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 7d5e6705e9a..4ede4db1399 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -20,6 +20,7 @@ import { MessageList } from "../components/chat/MessageList" import { VscodeUserMessage } from "../components/chat/VscodeUserMessage" import { TurnOutcome } from "../components/shared/TurnOutcome" import { SessionContext } from "../context/session" +import { MemoryContext, type MemoryContextValue } from "../context/memory" import { ProviderContext } from "../context/provider" import { ServerContext } from "../context/server" import { WorktreeModeProvider } from "../context/worktree-mode" @@ -988,6 +989,52 @@ export const TaskHeaderWithTodosAllDone: Story = { }, } +const mockMemory: MemoryContextValue = { + status: () => ({}) as any, + show: () => undefined, + loading: () => false, + pending: () => false, + error: () => undefined, + enabled: () => true, + sessionTokens: () => 533, + totalTokens: () => 12_400, + refresh: () => {}, + showMemory: () => {}, + enable: () => {}, + disable: () => {}, + auto: () => {}, + rebuild: () => {}, + remember: () => {}, + forget: () => {}, +} + +export const TaskHeaderWithMemory: Story = { + name: "TaskHeader — with memory enabled", + render: () => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages: () => [{ id: "msg-001" }] as any[], + currentSession: () => ({ + id: SESSION_ID, + title: "Integrate project memory", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + } + return ( + + + +
+ +
+
+
+
+ ) + }, +} + const usageTokens = { input: 25_900_000, output: 52_000, reasoning: 4_100, cache: { read: 10_500_000, write: 80_000 } } const usageData = { sessionIDs: [SESSION_ID, "story-subagent-001"], diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index dcb17a5a6a2..670cbd07e6f 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -228,6 +228,14 @@ width: 100%; } +[data-component="assistant-memory-badge"] { + align-self: flex-start; + color: var(--vscode-descriptionForeground); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-normal); +} + .vscode-session-turn-diffs { width: 100%; } diff --git a/packages/kilo-vscode/webview-ui/src/styles/task-header.css b/packages/kilo-vscode/webview-ui/src/styles/task-header.css index 61151d7f5f6..c64587fe087 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/task-header.css +++ b/packages/kilo-vscode/webview-ui/src/styles/task-header.css @@ -353,6 +353,42 @@ font-variant-numeric: tabular-nums; } +[data-slot="task-header-memory"] { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + font-size: var(--kilo-font-size-11); + color: var(--vscode-descriptionForeground); + min-width: 0; +} + +/* Passed to the shared Tooltip via its `class` prop (no data-slot API), so the trigger stretches. */ +.task-header-memory-tooltip { + display: inline-flex; + flex: 1; + min-width: 0; +} + +[data-slot="task-header-memory-status"] { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); +} + +[data-slot="task-header-memory-actions"] { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + /* Expand toggle */ [data-slot="task-header-expand"] { all: unset; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 75aa20b8aaa..8dee79be451 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -47,6 +47,7 @@ import type { MigrationSessionProgressMessage, MigrationStateMessage, } from "./migration" +import type { MemoryEventMessage, MemoryLoadedMessage, MemoryOperationResultMessage } from "./memory" // ============================================ // Messages FROM extension TO webview @@ -1218,3 +1219,6 @@ export type ExtensionMessage = | TelemetryStateMessage | RemoteStatusMessage | ValidateFilesResultMessage + | MemoryLoadedMessage + | MemoryEventMessage + | MemoryOperationResultMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/index.ts b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts index f74b20db685..0a8a6bcfe7f 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/index.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts @@ -13,5 +13,6 @@ export * from "./config" export * from "./profile" export * from "./agent-manager" export * from "./migration" +export * from "./memory" export * from "./extension-messages" export * from "./webview-messages" diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/memory.ts b/packages/kilo-vscode/webview-ui/src/types/messages/memory.ts new file mode 100644 index 00000000000..022dd73c083 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/memory.ts @@ -0,0 +1,103 @@ +import type { + MemoryCorrectResponse, + MemoryConfigureResponse, + MemoryDisableResponse, + MemoryEnableResponse, + MemoryForgetResponse, + MemoryPurgeResponse, + MemoryRememberResponse, + MemoryRebuildResponse, + MemoryShowResponse, + MemoryStatusResponse, +} from "@kilocode/sdk/v2" +import type { + MemoryOperation as SharedMemoryOperation, + MemoryPromptOperation as SharedMemoryPromptOperation, +} from "@kilocode/kilo-memory/commands" +import type { MemorySchema } from "@kilocode/kilo-memory/schema" + +export type MemorySourceFile = MemorySchema.Source + +export type MemoryOperation = SharedMemoryOperation + +export type MemoryResultOperation = MemoryOperation + +export type MemoryPromptOperation = SharedMemoryPromptOperation + +export type MemoryOperationResponse = + | MemoryEnableResponse + | MemoryConfigureResponse + | MemoryDisableResponse + | MemoryStatusResponse + | MemoryRebuildResponse + | MemoryRememberResponse + | MemoryCorrectResponse + | MemoryForgetResponse + | MemoryPurgeResponse + +export interface MemoryLoadedMessage { + type: "memoryLoaded" + sessionID?: string + status?: MemoryStatusResponse + show?: MemoryShowResponse + error?: string +} + +export interface MemoryEventDetail { + type?: "saved" | "skipped" | "recalled" | "error" + message?: string + reason?: string + duplicateOf?: string + tokens?: number + operationCount?: number + skippedCount?: number + sources?: string[] + files?: string[] +} + +export interface MemoryEventMessage { + type: "memoryEvent" + sessionID?: string + detail: MemoryEventDetail +} + +export interface MemoryOperationResultMessage { + type: "memoryOperationResult" + operation: MemoryResultOperation + sessionID?: string + ok: boolean + status?: MemoryStatusResponse + show?: MemoryShowResponse + result?: MemoryOperationResponse + error?: string +} + +export interface RequestMemoryMessage { + type: "requestMemory" + sessionID?: string + includeSources?: boolean +} + +export interface MemoryShowMessage { + type: "memoryShow" + sessionID?: string +} + +export interface MemoryOperationMessage { + type: "memoryOperation" + operation: MemoryOperation + sessionID?: string + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + key?: string + file?: MemorySourceFile + section?: string +} + +export interface MemoryPromptMessage { + type: "memoryPrompt" + operation: MemoryPromptOperation + sessionID?: string +} 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 5652ca59fb9..3946fff8df1 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 @@ -15,6 +15,7 @@ import type { SkipLegacyMigrationMessage, StartMigrationMessage, } from "./migration" +import type { MemoryShowMessage, MemoryOperationMessage, MemoryPromptMessage, RequestMemoryMessage } from "./memory" // ============================================ // Messages FROM webview TO extension @@ -1379,6 +1380,10 @@ export type WebviewMessage = | SetRemoteEnabledMessage | RequestRemoteStatusMessage | ContinueInWorktreeRequest + | RequestMemoryMessage + | MemoryShowMessage + | MemoryOperationMessage + | MemoryPromptMessage | CreateSectionRequest | RenameSectionRequest | DeleteSectionRequest diff --git a/packages/kilo-vscode/webview-ui/src/utils/format.ts b/packages/kilo-vscode/webview-ui/src/utils/format.ts new file mode 100644 index 00000000000..38d1f1cb3dc --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/format.ts @@ -0,0 +1,8 @@ +/** Compact K/M formatting for token counts, e.g. 1234 -> "1.2K". */ +export function formatCompactCount(n: number): string { + // Thresholds account for toFixed(1) rounding up at the unit boundary, so e.g. + // 999_950 renders "1.0M" rather than "1000.0K". + if (n >= 999_950) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} diff --git a/packages/kilo-vscode/webview-ui/src/utils/memory-command.ts b/packages/kilo-vscode/webview-ui/src/utils/memory-command.ts new file mode 100644 index 00000000000..68c18f50bde --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/memory-command.ts @@ -0,0 +1,11 @@ +import { MEMORY_USAGE, parseMemoryCommand as parse } from "@kilocode/kilo-memory/commands" +import type { ParsedMemoryCommand as SharedMemoryCommand } from "@kilocode/kilo-memory/commands" + +export type ParsedMemoryCommand = SharedMemoryCommand +export { MEMORY_USAGE } + +export function parseMemoryCommand(input: string): ParsedMemoryCommand | undefined { + const parsed = parse(input) + if (parsed?.kind !== "usage") return parsed + return { ...parsed, reason: `${parsed.reason}\n${MEMORY_USAGE}` } +} From 251cc9fe567a58080e5e1ce0789993907d48f88e Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Jul 2026 13:48:06 +0000 Subject: [PATCH 069/107] release(jetbrains): v7.0.2-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 24 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 082d91d37dc..d438d7269d2 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -152,6 +152,30 @@ ## [Unreleased] +## [7.0.2-rc.1] - 2026-07-07 + +### Added +- feat(cli): add vim modal editing to the prompt input by @drye in https://github.com/Kilo-Org/kilocode/pull/11428 +- feat(sandbox): add configurable writable paths option by @trim21 in https://github.com/Kilo-Org/kilocode/pull/11995 +- feat(vscode): prewarm microphone capture before showing voice input as recording by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12001 +- feat: add /reload action to reboot the instance from disk by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12004 +- feat(vscode): render read-tool images inline in chat view by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12010 +- feat(jetbrains): download pinned Kilo Core releases at runtime by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11975 +- feat(vscode): integrate project memory into the extension by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/11954 + +### Fixed +- fix(cli): import cloud sessions before validation by @maphew in https://github.com/Kilo-Org/kilocode/pull/11223 +- fix(vscode): show routed model name for auto-routed free sessions by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12000 +- fix(cli): block file/env references in untrusted project config by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11886 +- fix(vscode): prevent selector popover clipping in new worktree dialog by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12007 + +### Changed +- release(jetbrains): v7.0.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11971 +- CLI - Show Remote Badge In TUI Prompt by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11976 +- docs: add deprecation notice to App Builder page by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11997 +- Show actually used models when using fable by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/11969 + + ## [7.0.1] - 2026-07-06 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 437f5511486..23aa5539360 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.1 +kilo.jetbrains.version=7.0.2-rc.1 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m From 497819684494e1608054b8b5ee55cadf87a7b2ed Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Tue, 7 Jul 2026 09:55:32 -0400 Subject: [PATCH 070/107] docs(jetbrains): edit changelog for v7.0.2-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d438d7269d2..dd47ea69710 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -155,26 +155,7 @@ ## [7.0.2-rc.1] - 2026-07-07 ### Added -- feat(cli): add vim modal editing to the prompt input by @drye in https://github.com/Kilo-Org/kilocode/pull/11428 -- feat(sandbox): add configurable writable paths option by @trim21 in https://github.com/Kilo-Org/kilocode/pull/11995 -- feat(vscode): prewarm microphone capture before showing voice input as recording by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12001 -- feat: add /reload action to reboot the instance from disk by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12004 -- feat(vscode): render read-tool images inline in chat view by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12010 -- feat(jetbrains): download pinned Kilo Core releases at runtime by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11975 -- feat(vscode): integrate project memory into the extension by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/11954 - -### Fixed -- fix(cli): import cloud sessions before validation by @maphew in https://github.com/Kilo-Org/kilocode/pull/11223 -- fix(vscode): show routed model name for auto-routed free sessions by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12000 -- fix(cli): block file/env references in untrusted project config by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11886 -- fix(vscode): prevent selector popover clipping in new worktree dialog by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12007 - -### Changed -- release(jetbrains): v7.0.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11971 -- CLI - Show Remote Badge In TUI Prompt by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11976 -- docs: add deprecation notice to App Builder page by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11997 -- Show actually used models when using fable by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/11969 - +- Download the pinned Kilo Core release at runtime instead of bundling every CLI binary in the JetBrains plugin, keeping the Marketplace package smaller while still verifying downloaded artifacts. ## [7.0.1] - 2026-07-06 From f3d4e493e80d313300ac3458d19027cc4e376617 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Jul 2026 14:01:04 +0000 Subject: [PATCH 071/107] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 6790015e90e..387012efc67 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-jTNTUFR9PuOqU3HVmA9tWR6E07O+LgQyubZOvQOxZ5I=", - "aarch64-linux": "sha256-afSECQ+kAsWirSpUAPe8sVcsal4Cu37MBWG/klc5I4Q=", - "aarch64-darwin": "sha256-z2LNeKVnKu95c6W2vl1x0qHTmZK9nwJlneurDibLjYw=", - "x86_64-darwin": "sha256-9dRFffouzJNfewnMm9Hm0FgIbYeUMBgv9mJUEwlSZS0=" + "x86_64-linux": "sha256-5/NgXEBhaNEEqIz9GVCXxQaLJwkqWXAAZOPoa6GyHTI=", + "aarch64-linux": "sha256-qt7PKn0SAhDCcYtXRY9+1TBp9x3Far7kK24jhUzoMf8=", + "aarch64-darwin": "sha256-3FN/i3chx01S52lAv0tjxVtRpUlPkIfInphpq+69uWA=", + "x86_64-darwin": "sha256-+rOacJLx+qOCRGW4F+AjvFcc9XUE2biB0BRSy3dTWGI=" } } From 837c61570bb3eb087508da508d772de239685590 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Jul 2026 16:02:34 +0200 Subject: [PATCH 072/107] fix(vscode): show sandbox tooltip immediately on hover --- .../webview-ui/src/components/shared/SandboxButton.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/SandboxButton.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/SandboxButton.tsx index a8de4cfed29..2e307cb8c34 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/SandboxButton.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/SandboxButton.tsx @@ -69,7 +69,12 @@ export const SandboxButtonBase: Component = (props) => { language.t(props.enabled ? "prompt.action.sandbox.enabled" : "prompt.action.sandbox.disabled")) return ( - + - + = (props) => { props.start() } - onCleanup(() => { - if (props.speech.active()) props.speech.cancel() - }) + onCleanup(() => cleanupSpeechCapture(props.speech)) return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts new file mode 100644 index 00000000000..fa97233f3e9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts @@ -0,0 +1,5 @@ +import type { SpeechToText } from "./useSpeechToText" + +export function cleanupSpeechCapture(speech: Pick) { + if (speech.active()) speech.cancel() +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9b3c279feac..f14577ce029 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1738,11 +1738,8 @@ function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: A const { theme } = useTheme() const info = useContext(RoutedModelMeta.Context) const routed = createMemo(() => { - if (props.message.providerID !== "kilo") return undefined - if (!props.message.modelID.startsWith("kilo-auto/")) return undefined const model = props.part.model - if (!model) return undefined - if (model.providerID === props.message.providerID && model.modelID === props.message.modelID) return undefined + if (!RoutedModelMeta.routed(model, props.message)) return undefined return RoutedModelMeta.label(ctx.providers(), model) }) const consumed = createMemo(() => info().consumed.has(props.part.id)) 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 2b91606c86c..ad1d0b58d93 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 @@ -299,7 +299,7 @@ function resolveMotion(text: string, pos: number, key: string, count: number, aw case "$": { let t = pos for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1)) - return { target: lineEnd(text, t), inclusive: true, linewise: false } + return { target: Math.max(lineStart(text, t), lineEnd(text, t) - 1), inclusive: true, linewise: false } } case "G": { let target = 0 diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx index 61b964b2707..12179aba523 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx @@ -39,10 +39,10 @@ export namespace RoutedModelMeta { return KiloRoutedModel.displayName(text) } - function routed(model: StepFinishPart["model"], message: Message) { + export function routed(model: StepFinishPart["model"], message: Message) { if (!model) return undefined if (message.providerID !== "kilo") return undefined - if (!message.modelID.startsWith("kilo-auto/")) return undefined + if (!KiloRoutedModel.routedSelection(message.modelID)) return undefined if (model.providerID === message.providerID && model.modelID === message.modelID) return undefined return model } diff --git a/packages/opencode/src/kilocode/session/routed-model.ts b/packages/opencode/src/kilocode/session/routed-model.ts index eace803049a..ba474dad40f 100644 --- a/packages/opencode/src/kilocode/session/routed-model.ts +++ b/packages/opencode/src/kilocode/session/routed-model.ts @@ -47,10 +47,15 @@ export namespace KiloRoutedModel { input: { providerID: ProviderID; modelID: string; selected?: string }, ) { if (input.providerID !== ProviderID.kilo) return undefined - if (!input.modelID.startsWith("kilo-auto/") && !input.modelID.includes("fable")) return undefined + if (!routedSelection(input.modelID)) return undefined const model = read(meta, input.providerID) if (!model) return undefined if (model.modelID === input.modelID || model.modelID === input.selected) return undefined return model } + + export function routedSelection(modelID: string) { + const id = modelID.trim() + return id.startsWith("kilo-auto/") || /(?:^|[/.])claude-fable(?:[-.:/]|$)/.test(id) + } } diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts index 43a2330d618..878ffed9537 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts @@ -199,6 +199,28 @@ describe("vim edits", () => { expect(doc.text).toBe("bar baz") }) + test("d$ deletes line content without consuming the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "d$") + expect(doc.text).toBe("\ndef") + }) + + test("c$ changes line content without consuming the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "c$") + expect(doc.text).toBe("\ndef") + expect(state.mode).toBe("insert") + }) + + test("y$ yanks line content without the newline", () => { + const doc = new MockDoc("abc\ndef", 0) + const state = createVimState("normal") + feed(doc, state, "y$") + expect(state.register).toEqual({ text: "abc", linewise: false }) + }) + test("dd deletes the current line", () => { const doc = new MockDoc("one\ntwo\nthree", 4) const state = createVimState("normal") diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 467282a3463..b8a41edcc6f 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -243,6 +243,51 @@ describe("kilocode indexing config", () => { }) describe("kilocode sandbox writable paths config", () => { + test("honors sandbox_writable_paths from global config", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + experimental: { sandbox_writable_paths: ["/tmp/global"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"]) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops sandbox_writable_paths from project config", async () => { + await using tmp = await tmpdir({ git: true }) + + await writeConfig(tmp.path, { + experimental: { sandbox_writable_paths: ["/tmp/project"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox_writable_paths).toBeUndefined() + }, + }) + }) + test("honors sandbox_writable_paths from global config only, ignoring project config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/kilocode/session-routed-model.test.ts b/packages/opencode/test/kilocode/session-routed-model.test.ts index 64981bad902..de86c5af4bc 100644 --- a/packages/opencode/test/kilocode/session-routed-model.test.ts +++ b/packages/opencode/test/kilocode/session-routed-model.test.ts @@ -103,7 +103,7 @@ describe("session routed model", () => { expect(KiloRoutedModel.displayName("o3")).toBe("o3") }) - test("shows compact labels only for Kilo auto selections", () => { + test("shows compact labels only for routed Kilo selections", () => { const model = { providerID: "openai", modelID: "gpt-5.5" } const parts = [reason, finish(model)] @@ -115,6 +115,13 @@ describe("session routed model", () => { expect(routed.footer).toBe("gpt-5.5") expect(routed.consumed.has("finish")).toBe(true) + const fable = RoutedModelMeta.info(undefined, parts, false, { + providerID: "kilo", + modelID: "anthropic/claude-fable-5", + }) + expect(fable.labels.get("reasoning")).toBe("gpt-5.5") + expect(fable.footer).toBe("gpt-5.5") + const explicit = RoutedModelMeta.info(undefined, parts, false, { providerID: "openai", modelID: "gpt-5.5", @@ -169,7 +176,7 @@ describe("session routed model", () => { expect(routed.consumed.has("last")).toBe(false) }) - test("reads routed model only for selected Kilo auto models", () => { + test("reads routed model only for selected Kilo routed models", () => { const meta = { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } } expect( @@ -182,6 +189,22 @@ describe("session routed model", () => { modelID: ModelID.make("openai/gpt-5.5-20260423"), }) + expect( + KiloRoutedModel.readAuto(meta, { + providerID: ProviderID.kilo, + modelID: "anthropic.claude-fable-5", + }), + ).toEqual({ + providerID: ProviderID.kilo, + modelID: ModelID.make("openai/gpt-5.5-20260423"), + }) + + expect( + KiloRoutedModel.readAuto(meta, { + providerID: ProviderID.kilo, + modelID: "openai/affable-model", + }), + ).toBeUndefined() expect( KiloRoutedModel.readAuto(meta, { providerID: ProviderID.kilo, From cb329c6bdb4be807f25206079e915400cae976b6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 14:13:35 -0400 Subject: [PATCH 093/107] chore: drop unrelated review fixes --- .../code-with-ai/agents/custom-models.md | 4 +- .../pages/code-with-ai/platforms/cli.md | 4 +- .../tests/unit/config-scope.test.ts | 15 ------- .../tests/unit/speech-to-text-button.test.ts | 30 ------------- .../src/components/chat/PromptInput.tsx | 2 +- .../speech-to-text/SpeechToTextButton.tsx | 5 ++- .../speech-to-text/speech-cleanup.ts | 5 --- .../src/cli/cmd/tui/routes/session/index.tsx | 5 ++- .../cli/cmd/tui/component/prompt/vim.ts | 2 +- .../tui/routes/session/routed-model-meta.tsx | 4 +- .../src/kilocode/session/routed-model.ts | 7 +-- .../kilocode/cli/cmd/tui/prompt/vim.test.ts | 22 --------- .../test/kilocode/config/config.test.ts | 45 ------------------- .../kilocode/session-routed-model.test.ts | 27 +---------- 14 files changed, 18 insertions(+), 159 deletions(-) delete mode 100644 packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts delete mode 100644 packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 065672280e6..d489dc63ebc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -390,8 +390,8 @@ You can also set options that apply to all models from a provider: | `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | -{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %} -`{env:VAR}` references in `apiKey` (or any option) resolve **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. +{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} +`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. {% /callout %} ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index be2bafc75d4..d785539288e 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -465,8 +465,8 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab } ``` -{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %} -`{env:VAR}` references resolve **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. +{% callout type="warning" title="Only works in trusted config" %} +`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. {% /callout %} For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts index aee2c488ce7..eca588a5d1b 100644 --- a/packages/kilo-vscode/tests/unit/config-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -47,19 +47,4 @@ describe("splitConfigByScope", () => { }) expect(split.project).toEqual({}) }) - - it("writes sandbox writable paths to global config", () => { - const split = splitConfigByScope({ - experimental: { - sandbox_writable_paths: ["/tmp/kilo"], - }, - }) - - expect(split.global).toEqual({ - experimental: { - sandbox_writable_paths: ["/tmp/kilo"], - }, - }) - expect(split.project).toEqual({}) - }) }) diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts deleted file mode 100644 index 56db776d55f..00000000000 --- a/packages/kilo-vscode/tests/unit/speech-to-text-button.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { cleanupSpeechCapture } from "../../webview-ui/src/components/speech-to-text/speech-cleanup" - -describe("speech-to-text button", () => { - it("cancels active capture on component cleanup", () => { - let cancels = 0 - - cleanupSpeechCapture({ - active: () => true, - cancel: () => { - cancels += 1 - }, - }) - - expect(cancels).toBe(1) - }) - - it("does not cancel idle capture on component cleanup", () => { - let cancels = 0 - - cleanupSpeechCapture({ - active: () => false, - cancel: () => { - cancels += 1 - }, - }) - - expect(cancels).toBe(0) - }) -}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 0298535f8a4..1593dadf0c2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1394,7 +1394,7 @@ export const PromptInput: Component = (props) => { - + = (props) => { props.start() } - onCleanup(() => cleanupSpeechCapture(props.speech)) + onCleanup(() => { + if (props.speech.active()) props.speech.cancel() + }) return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts b/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts deleted file mode 100644 index fa97233f3e9..00000000000 --- a/packages/kilo-vscode/webview-ui/src/components/speech-to-text/speech-cleanup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { SpeechToText } from "./useSpeechToText" - -export function cleanupSpeechCapture(speech: Pick) { - if (speech.active()) speech.cancel() -} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f14577ce029..9b3c279feac 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1738,8 +1738,11 @@ function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: A const { theme } = useTheme() const info = useContext(RoutedModelMeta.Context) const routed = createMemo(() => { + if (props.message.providerID !== "kilo") return undefined + if (!props.message.modelID.startsWith("kilo-auto/")) return undefined const model = props.part.model - if (!RoutedModelMeta.routed(model, props.message)) return undefined + if (!model) return undefined + if (model.providerID === props.message.providerID && model.modelID === props.message.modelID) return undefined return RoutedModelMeta.label(ctx.providers(), model) }) const consumed = createMemo(() => info().consumed.has(props.part.id)) 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 ad1d0b58d93..2b91606c86c 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 @@ -299,7 +299,7 @@ function resolveMotion(text: string, pos: number, key: string, count: number, aw case "$": { let t = pos for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1)) - return { target: Math.max(lineStart(text, t), lineEnd(text, t) - 1), inclusive: true, linewise: false } + return { target: lineEnd(text, t), inclusive: true, linewise: false } } case "G": { let target = 0 diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx index 12179aba523..61b964b2707 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/routes/session/routed-model-meta.tsx @@ -39,10 +39,10 @@ export namespace RoutedModelMeta { return KiloRoutedModel.displayName(text) } - export function routed(model: StepFinishPart["model"], message: Message) { + function routed(model: StepFinishPart["model"], message: Message) { if (!model) return undefined if (message.providerID !== "kilo") return undefined - if (!KiloRoutedModel.routedSelection(message.modelID)) return undefined + if (!message.modelID.startsWith("kilo-auto/")) return undefined if (model.providerID === message.providerID && model.modelID === message.modelID) return undefined return model } diff --git a/packages/opencode/src/kilocode/session/routed-model.ts b/packages/opencode/src/kilocode/session/routed-model.ts index ba474dad40f..eace803049a 100644 --- a/packages/opencode/src/kilocode/session/routed-model.ts +++ b/packages/opencode/src/kilocode/session/routed-model.ts @@ -47,15 +47,10 @@ export namespace KiloRoutedModel { input: { providerID: ProviderID; modelID: string; selected?: string }, ) { if (input.providerID !== ProviderID.kilo) return undefined - if (!routedSelection(input.modelID)) return undefined + if (!input.modelID.startsWith("kilo-auto/") && !input.modelID.includes("fable")) return undefined const model = read(meta, input.providerID) if (!model) return undefined if (model.modelID === input.modelID || model.modelID === input.selected) return undefined return model } - - export function routedSelection(modelID: string) { - const id = modelID.trim() - return id.startsWith("kilo-auto/") || /(?:^|[/.])claude-fable(?:[-.:/]|$)/.test(id) - } } diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts index 878ffed9537..43a2330d618 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/prompt/vim.test.ts @@ -199,28 +199,6 @@ describe("vim edits", () => { expect(doc.text).toBe("bar baz") }) - test("d$ deletes line content without consuming the newline", () => { - const doc = new MockDoc("abc\ndef", 0) - const state = createVimState("normal") - feed(doc, state, "d$") - expect(doc.text).toBe("\ndef") - }) - - test("c$ changes line content without consuming the newline", () => { - const doc = new MockDoc("abc\ndef", 0) - const state = createVimState("normal") - feed(doc, state, "c$") - expect(doc.text).toBe("\ndef") - expect(state.mode).toBe("insert") - }) - - test("y$ yanks line content without the newline", () => { - const doc = new MockDoc("abc\ndef", 0) - const state = createVimState("normal") - feed(doc, state, "y$") - expect(state.register).toEqual({ text: "abc", linewise: false }) - }) - test("dd deletes the current line", () => { const doc = new MockDoc("one\ntwo\nthree", 4) const state = createVimState("normal") diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index b8a41edcc6f..467282a3463 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -243,51 +243,6 @@ describe("kilocode indexing config", () => { }) describe("kilocode sandbox writable paths config", () => { - test("honors sandbox_writable_paths from global config", async () => { - await using globalTmp = await tmpdir() - await using tmp = await tmpdir({ git: true }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - await clear() - await disposeAllInstances() - - try { - await writeConfig(globalTmp.path, { - $schema: "https://app.kilo.ai/config.json", - experimental: { sandbox_writable_paths: ["/tmp/global"] }, - }) - - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"]) - }, - }) - } finally { - ;(Global.Path as { config: string }).config = prev - await clear() - await disposeAllInstances() - } - }) - - test("drops sandbox_writable_paths from project config", async () => { - await using tmp = await tmpdir({ git: true }) - - await writeConfig(tmp.path, { - experimental: { sandbox_writable_paths: ["/tmp/project"] }, - }) - - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.experimental?.sandbox_writable_paths).toBeUndefined() - }, - }) - }) - test("honors sandbox_writable_paths from global config only, ignoring project config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/kilocode/session-routed-model.test.ts b/packages/opencode/test/kilocode/session-routed-model.test.ts index de86c5af4bc..64981bad902 100644 --- a/packages/opencode/test/kilocode/session-routed-model.test.ts +++ b/packages/opencode/test/kilocode/session-routed-model.test.ts @@ -103,7 +103,7 @@ describe("session routed model", () => { expect(KiloRoutedModel.displayName("o3")).toBe("o3") }) - test("shows compact labels only for routed Kilo selections", () => { + test("shows compact labels only for Kilo auto selections", () => { const model = { providerID: "openai", modelID: "gpt-5.5" } const parts = [reason, finish(model)] @@ -115,13 +115,6 @@ describe("session routed model", () => { expect(routed.footer).toBe("gpt-5.5") expect(routed.consumed.has("finish")).toBe(true) - const fable = RoutedModelMeta.info(undefined, parts, false, { - providerID: "kilo", - modelID: "anthropic/claude-fable-5", - }) - expect(fable.labels.get("reasoning")).toBe("gpt-5.5") - expect(fable.footer).toBe("gpt-5.5") - const explicit = RoutedModelMeta.info(undefined, parts, false, { providerID: "openai", modelID: "gpt-5.5", @@ -176,7 +169,7 @@ describe("session routed model", () => { expect(routed.consumed.has("last")).toBe(false) }) - test("reads routed model only for selected Kilo routed models", () => { + test("reads routed model only for selected Kilo auto models", () => { const meta = { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } } expect( @@ -189,22 +182,6 @@ describe("session routed model", () => { modelID: ModelID.make("openai/gpt-5.5-20260423"), }) - expect( - KiloRoutedModel.readAuto(meta, { - providerID: ProviderID.kilo, - modelID: "anthropic.claude-fable-5", - }), - ).toEqual({ - providerID: ProviderID.kilo, - modelID: ModelID.make("openai/gpt-5.5-20260423"), - }) - - expect( - KiloRoutedModel.readAuto(meta, { - providerID: ProviderID.kilo, - modelID: "openai/affable-model", - }), - ).toBeUndefined() expect( KiloRoutedModel.readAuto(meta, { providerID: ProviderID.kilo, From 23bba412ff3623a2b69e69cf2af62d8e65451e7f Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Jul 2026 14:29:37 -0400 Subject: [PATCH 094/107] chore: drop speech capture change --- packages/kilo-vscode/src/speech-to-text/capture.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index 761d01dc59e..6250c0822c1 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -239,7 +239,6 @@ async function resolveFFmpeg(): Promise { if (cached) { const bin = await cached if (!path.isAbsolute(bin) || existsSync(bin)) return bin - if (ffmpeg === cached) ffmpeg = undefined } const task = findFFmpeg() From 0aa3574b44bf4a030f14adcc12df66cf0b13b894 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Jul 2026 18:58:54 +0000 Subject: [PATCH 095/107] release(jetbrains): v7.0.2-rc.2 --- packages/kilo-jetbrains/CHANGELOG.md | 17 +++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index dd47ea69710..023076fae91 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -152,6 +152,23 @@ ## [Unreleased] +## [7.0.2-rc.2] - 2026-07-07 + +### Added +- feat(sandbox): widen writable-paths settings input and add coverage by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12008 +- feat(jetbrains): polish chat UI and bundled CLI runtime flow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11978 + +### Fixed +- fix(vscode): show sandbox tooltip immediately on hover by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12012 +- fix(vscode): preserve mode on first send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12009 +- fix(vscode): allow shrinking prompt mention selections by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/11936 +- fix(vscode): handle multiline bidi prompt input by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12006 +- fix: honor Kilo cloud account preferences by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11999 + +### Changed +- release(jetbrains): v7.0.2-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12011 + + ## [7.0.2-rc.1] - 2026-07-07 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 23aa5539360..c93a93d6ea4 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.2-rc.1 +kilo.jetbrains.version=7.0.2-rc.2 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m From af9f15c98c01c3bc591979eea0d729c1946a309b Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Tue, 7 Jul 2026 15:17:28 -0400 Subject: [PATCH 096/107] docs(jetbrains): edit changelog for v7.0.2-rc.2 --- packages/kilo-jetbrains/CHANGELOG.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 023076fae91..1a48e5dda1c 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -155,19 +155,17 @@ ## [7.0.2-rc.2] - 2026-07-07 ### Added -- feat(sandbox): widen writable-paths settings input and add coverage by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12008 -- feat(jetbrains): polish chat UI and bundled CLI runtime flow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11978 +- Show compact previews for collapsed reasoning blocks so long assistant reasoning stays readable without taking over the transcript. +- Add clearer Kilo Core runtime information and diagnostics for release download failures. ### Fixed -- fix(vscode): show sandbox tooltip immediately on hover by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12012 -- fix(vscode): preserve mode on first send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12009 -- fix(vscode): allow shrinking prompt mention selections by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/11936 -- fix(vscode): handle multiline bidi prompt input by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12006 -- fix: honor Kilo cloud account preferences by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11999 +- Resolve the CLI executable using the user's shell environment so custom PATH setups work when sessions start from JetBrains. +- Keep retry and offline status visible in the session footer while preserving transcript context. +- Prevent oversized header popups by capping preview content. ### Changed -- release(jetbrains): v7.0.2-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12011 - +- Download the required Kilo Core release at runtime and prune stale cached runtime binaries automatically. +- Polish JetBrains chat spacing, prompt input behavior, question/todo layout, history scrolling, code block padding, and session background colors. ## [7.0.2-rc.1] - 2026-07-07 From 4dc994d93bc589798293cc848a64e89fc8cfed60 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Tue, 7 Jul 2026 23:21:33 +0330 Subject: [PATCH 097/107] fix(vscode): support agent manager prompt bidi (#12015) --- .changeset/fix-agent-manager-prompt-bidi.md | 5 +++++ .../tests/unit/prompt-input-bidirectional.test.ts | 8 ++++++++ .../webview-ui/agent-manager/NewWorktreeDialog.tsx | 1 + 3 files changed, 14 insertions(+) create mode 100644 .changeset/fix-agent-manager-prompt-bidi.md diff --git a/.changeset/fix-agent-manager-prompt-bidi.md b/.changeset/fix-agent-manager-prompt-bidi.md new file mode 100644 index 00000000000..6da36fd109b --- /dev/null +++ b/.changeset/fix-agent-manager-prompt-bidi.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support bidirectional text in the Agent Manager new worktree prompt. diff --git a/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts index c736e8fd57a..cea96c4bc35 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts @@ -6,6 +6,8 @@ const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "cha const src = readFileSync(path, "utf8") const cssPath = join(__dirname, "..", "..", "webview-ui", "src", "styles", "prompt-input.css") const css = readFileSync(cssPath, "utf8") +const dialogPath = join(__dirname, "..", "..", "webview-ui", "agent-manager", "NewWorktreeDialog.tsx") +const dialog = readFileSync(dialogPath, "utf8") describe("PromptInput bidirectional text support", () => { it("lets the textarea and visible overlay resolve text direction automatically", () => { @@ -18,4 +20,10 @@ describe("PromptInput bidirectional text support", () => { expect(input).toContain('class="prompt-input"') expect(input).toContain('dir="auto"') }) + + it("lets the Agent Manager worktree prompt resolve bidirectional text automatically", () => { + const input = dialog.match(//)?.[0] + + expect(input).toContain('dir="auto"') + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index d3f59a0d420..52639c60a30 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -468,6 +468,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran }} onPaste={(e) => imageAttach.handlePaste(e)} rows={3} + dir="auto" /> From ed729b23f0229e0a604591c7be04139107f639d4 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Jul 2026 20:11:29 +0000 Subject: [PATCH 098/107] release(jetbrains): v7.0.2 --- packages/kilo-jetbrains/CHANGELOG.md | 37 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 1a48e5dda1c..c8e9849b6fa 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -152,6 +152,43 @@ ## [Unreleased] +## [7.0.2] - 2026-07-07 + +### Added +- feat(cli): add vim modal editing to the prompt input by @drye in https://github.com/Kilo-Org/kilocode/pull/11428 +- feat(sandbox): add configurable writable paths option by @trim21 in https://github.com/Kilo-Org/kilocode/pull/11995 +- feat(vscode): prewarm microphone capture before showing voice input as recording by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12001 +- feat: add /reload action to reboot the instance from disk by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12004 +- feat(vscode): render read-tool images inline in chat view by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12010 +- feat(jetbrains): download pinned Kilo Core releases at runtime by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11975 +- feat(vscode): integrate project memory into the extension by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/11954 +- feat(sandbox): widen writable-paths settings input and add coverage by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12008 +- feat(jetbrains): polish chat UI and bundled CLI runtime flow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11978 + +### Fixed +- fix(cli): import cloud sessions before validation by @maphew in https://github.com/Kilo-Org/kilocode/pull/11223 +- fix(vscode): show routed model name for auto-routed free sessions by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12000 +- fix(cli): block file/env references in untrusted project config by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11886 +- fix(vscode): prevent selector popover clipping in new worktree dialog by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12007 +- fix(vscode): show sandbox tooltip immediately on hover by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12012 +- fix(vscode): preserve mode on first send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12009 +- fix(vscode): allow shrinking prompt mention selections by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/11936 +- fix(vscode): handle multiline bidi prompt input by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12006 +- fix: honor Kilo cloud account preferences by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11999 +- fix(vscode): support agent manager prompt bidi by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12015 + +### Changed +- release(jetbrains): v7.0.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11971 +- CLI - Show Remote Badge In TUI Prompt by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11976 +- docs: add deprecation notice to App Builder page by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11997 +- Show actually used models when using fable by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/11969 +- release(jetbrains): v7.0.2-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12011 +- docs(kilo-docs): improve navigation structure by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11991 +- docs(kilo-docs): add documentation style guide by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11992 +- release(jetbrains): v7.0.2-rc.2 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12018 +- docs(kilo-docs): improve onboarding flow by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11988 + + ## [7.0.2-rc.2] - 2026-07-07 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index c93a93d6ea4..a2c507d77f5 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.2-rc.2 +kilo.jetbrains.version=7.0.2 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m From 93661886ee7c75cef64e1a3d9df6fe4c3f4fc027 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Tue, 7 Jul 2026 16:18:36 -0400 Subject: [PATCH 099/107] docs(jetbrains): edit changelog for v7.0.2 --- packages/kilo-jetbrains/CHANGELOG.md | 36 +++++++--------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index c8e9849b6fa..77d9d224532 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -155,39 +155,19 @@ ## [7.0.2] - 2026-07-07 ### Added -- feat(cli): add vim modal editing to the prompt input by @drye in https://github.com/Kilo-Org/kilocode/pull/11428 -- feat(sandbox): add configurable writable paths option by @trim21 in https://github.com/Kilo-Org/kilocode/pull/11995 -- feat(vscode): prewarm microphone capture before showing voice input as recording by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12001 -- feat: add /reload action to reboot the instance from disk by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12004 -- feat(vscode): render read-tool images inline in chat view by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12010 -- feat(jetbrains): download pinned Kilo Core releases at runtime by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11975 -- feat(vscode): integrate project memory into the extension by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/11954 -- feat(sandbox): widen writable-paths settings input and add coverage by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12008 -- feat(jetbrains): polish chat UI and bundled CLI runtime flow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11978 + +- First GA release of the native Kilo extension for JetBrains IDEs. +- Download the pinned Kilo Core release at runtime instead of bundling CLI binaries, keeping the JetBrains plugin smaller while verifying downloaded archives before use. +- Show Kilo Core runtime details from the JetBrains plugin so users can see which Core release is active. ### Fixed -- fix(cli): import cloud sessions before validation by @maphew in https://github.com/Kilo-Org/kilocode/pull/11223 -- fix(vscode): show routed model name for auto-routed free sessions by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12000 -- fix(cli): block file/env references in untrusted project config by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11886 -- fix(vscode): prevent selector popover clipping in new worktree dialog by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12007 -- fix(vscode): show sandbox tooltip immediately on hover by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12012 -- fix(vscode): preserve mode on first send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12009 -- fix(vscode): allow shrinking prompt mention selections by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/11936 -- fix(vscode): handle multiline bidi prompt input by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12006 -- fix: honor Kilo cloud account preferences by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11999 -- fix(vscode): support agent manager prompt bidi by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12015 + +- Improve JetBrains runtime CLI download reliability by pruning stale binaries, using the shell environment for PATH resolution, and surfacing exact release-resolution failures. ### Changed -- release(jetbrains): v7.0.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11971 -- CLI - Show Remote Badge In TUI Prompt by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11976 -- docs: add deprecation notice to App Builder page by @eshurakov in https://github.com/Kilo-Org/kilocode/pull/11997 -- Show actually used models when using fable by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/11969 -- release(jetbrains): v7.0.2-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12011 -- docs(kilo-docs): improve navigation structure by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11991 -- docs(kilo-docs): add documentation style guide by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11992 -- release(jetbrains): v7.0.2-rc.2 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12018 -- docs(kilo-docs): improve onboarding flow by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/11988 +- Polish JetBrains chat UI with auto-collapsing reasoning previews, clearer retry/offline footer state, and more balanced prompt, code, question, todo, history, and popup spacing. +- Show the active routed model name and remote status more consistently in CLI runtime surfaces. ## [7.0.2-rc.2] - 2026-07-07 From 2d724f158b2828eecf9eab60b790e071f8d05d20 Mon Sep 17 00:00:00 2001 From: sylwester-liljegren Date: Wed, 8 Jul 2026 09:35:55 +0200 Subject: [PATCH 100/107] feat(vscode): jump transcript to message on timeline bar click (#12025) * feat(vscode): jump transcript to message on timeline bar click * fix(vscode): jump to the exact chunk containing the clicked timeline part --------- Co-authored-by: Sylwester Liljegren --- .changeset/timeline-bar-jump-to-message.md | 5 ++++ .../src/components/chat/MessageList.tsx | 26 +++++++++++++++++++ .../src/components/chat/TaskTimeline.tsx | 21 +++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .changeset/timeline-bar-jump-to-message.md diff --git a/.changeset/timeline-bar-jump-to-message.md b/.changeset/timeline-bar-jump-to-message.md new file mode 100644 index 00000000000..51dcb43a74a --- /dev/null +++ b/.changeset/timeline-bar-jump-to-message.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Click or press Enter/Space on a bar in the task timeline to jump the transcript to that message. 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 5913ef4f366..a5c78cd5e1f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -157,6 +157,32 @@ export const MessageList: Component = (props) => { const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) const fingerprint = createMemo(() => rowFingerprint(keys())) + + // 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 + // threshold, which resumes auto-follow mid-animation and snaps back down. + const onScrollToMessage = (e: Event) => { + const detail = (e as CustomEvent<{ id: string; partId?: string }>).detail + if (!detail?.id) return + const matches = rows().filter((r) => r.type === "assistant" && r.message.id === detail.id) + // Long messages split into multiple rows (chunks); land on the chunk that + // 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" }) + } + window.addEventListener("scrollToMessage", onScrollToMessage) + onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + const measurement = createMemo(() => { const id = session.currentSessionID() const token = layout() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx index aa2975d8b61..5bcb351de14 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -17,6 +17,8 @@ export interface TimelineBar { width: number height: number idx: number + msgId: string + partId: string } function collect(messages: Message[], parts: Record): TimelineBar[] { @@ -39,6 +41,8 @@ function collect(messages: Message[], parts: Record): TimelineBa width: sz[i]!.width, height: sz[i]!.height, idx: i, + msgId: item.msg.id, + partId: item.part.id, })) } @@ -46,6 +50,7 @@ export const TaskTimeline: Component = () => { const session = useSession() let ref: HTMLDivElement | undefined let dragging = false + let dragMoved = false let startX = 0 let startScroll = 0 const [hover, setHover] = createSignal(-1) @@ -135,6 +140,7 @@ export const TaskTimeline: Component = () => { hideTip() if (!ref) return dragging = true + dragMoved = false startX = e.clientX startScroll = ref.scrollLeft ref.setPointerCapture(e.pointerId) @@ -142,6 +148,13 @@ export const TaskTimeline: Component = () => { ref.style.userSelect = "none" } + const jumpToMessage = (idx: number) => { + const bar = bars()[idx] + if (!bar) return + setActive(idx) + window.dispatchEvent(new CustomEvent("scrollToMessage", { detail: { id: bar.msgId, partId: bar.partId } })) + } + const onPointerMove = (e: PointerEvent) => { if (!ref) return if (!dragging) { @@ -150,15 +163,18 @@ export const TaskTimeline: Component = () => { if (idx < 0) return hideTip() return showTip(idx) } + if (Math.abs(e.clientX - startX) > 3) dragMoved = true ref.scrollLeft = startScroll - (e.clientX - startX) } const onPointerUp = (e: PointerEvent) => { if (!ref) return + const wasDragging = dragging dragging = false if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId) ref.style.cursor = "grab" ref.style.userSelect = "" + if (wasDragging && !dragMoved) jumpToMessage(pointerIndex(e)) } const onWheel = (e: WheelEvent) => { @@ -169,6 +185,11 @@ export const TaskTimeline: Component = () => { } const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + jumpToMessage(selected()) + return + } if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return e.preventDefault() const idx = navigate(selected(), bars().length, e.key) From eefd891c62fb064275a4ec815c320422ca7e70ac Mon Sep 17 00:00:00 2001 From: IOLOII Date: Wed, 8 Jul 2026 16:30:07 +0800 Subject: [PATCH 101/107] feat(commit-message): Generate commit messages in the user's selected UI language instead of always using English. (#11994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(commit-message): add language parameter to commit message generation Add support for generating commit messages in the user's selected UI language. The language parameter flows through the entire stack: VSCode extension passes the selected locale, the HTTP API accepts the new field, and the generation service appends language instructions to the system prompt for non-English locales. - Update CommitMessageRequest types to include optional language field - Modify VSCode service to pass selected locale to the API - Append language requirements to system prompt when language is not English - Update OpenAPI specification and regenerate SDK types - Add test coverage for language instruction generation * feat(commit-message): add configurable language setting for AI-generated commit messages Add a new `kilo-code.new.languageCommitMessage` VS Code setting that allows users to choose a specific language for AI-generated commit messages, independent of the UI language. When set to "sync" (default), the commit message language follows the Kilo Code UI language. - Add language selector dropdown to CommitMessageTab settings UI - Add getCommitMessageLanguage() helper in i18n service - Pass languageCommitMessage setting through KiloProvider to webview - Add translation strings for all 20 supported locales * refactor(locale): standardize commit message terminology across locale files Replace literal translations of "commit" with the loanword form in Brazilian Portuguese and Spanish locale dictionaries, correct a Korean typo (커AI → 커밋), translate an untranslated English label in Norwegian, and align terminology with conventional usage across all four affected language packs. * style(i18n): fix prettier formatting on commit-message language strings --------- Co-authored-by: marius-kilocode --- .changeset/short-geckos-fry.md | 6 ++ packages/kilo-vscode/package.json | 51 ++++++++++++++++ packages/kilo-vscode/src/KiloProvider.ts | 16 +++-- .../src/services/commit-message/index.ts | 3 +- .../kilo-vscode/src/services/i18n/index.ts | 7 +++ .../components/settings/CommitMessageTab.tsx | 59 ++++++++++++++++--- .../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 | 5 ++ .../kilo-vscode/webview-ui/src/i18n/de.ts | 5 ++ .../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 | 5 ++ .../kilo-vscode/webview-ui/src/i18n/it.ts | 5 ++ .../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 | 5 ++ .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/pl.ts | 5 ++ .../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 | 5 ++ .../kilo-vscode/webview-ui/src/i18n/uk.ts | 5 ++ .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 ++ .../src/kilocode/commit-message/generate.ts | 7 ++- .../src/kilocode/commit-message/types.ts | 2 + .../server/httpapi/groups/commit-message.ts | 3 + .../server/httpapi/handlers/commit-message.ts | 1 + .../kilocode/commit-message/generate.test.ts | 37 ++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 + 34 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 .changeset/short-geckos-fry.md diff --git a/.changeset/short-geckos-fry.md b/.changeset/short-geckos-fry.md new file mode 100644 index 00000000000..f7733130c82 --- /dev/null +++ b/.changeset/short-geckos-fry.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Generate commit messages in the user's selected UI language instead of always using English. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index b24e7463067..4cc942b5a37 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -880,6 +880,57 @@ "Italiano" ] }, + "kilo-code.new.languageCommitMessage": { + "type": "string", + "default": "sync", + "description": "Language used for AI-generated commit messages. Choose 'sync' to follow the Kilo Code UI language, or select a specific language.", + "enum": [ + "sync", + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "da", + "ja", + "pl", + "ru", + "ar", + "no", + "br", + "th", + "bs", + "tr", + "nl", + "uk", + "it" + ], + "enumDescriptions": [ + "跟随界面语言 (Sync with UI language)", + "English", + "简体中文", + "繁體中文", + "한국어", + "Deutsch", + "Español", + "Français", + "Dansk", + "日本語", + "Polski", + "Русский", + "العربية", + "Norsk", + "Português (Brasil)", + "ภาษาไทย", + "Bosanski", + "Türkçe", + "Nederlands", + "Українська", + "Italiano" + ] + }, "kilo-code.new.model.providerID": { "type": "string", "default": "kilo", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index f9199c6e22f..21939cc65fa 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2438,7 +2438,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2534,7 +2534,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), } this.postMessage({ @@ -2542,7 +2542,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), }) } catch (error) { @@ -2924,7 +2924,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(merged), } this.postMessage({ @@ -2932,7 +2932,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(merged), }) this.requirements.clear() @@ -2954,7 +2954,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper type: "configUpdated", config: optimistic, globalConfig: this.cachedGlobalConfig ?? undefined, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: features ?? configFeatures(optimistic as Config), }) this.requirements.clear() @@ -3095,6 +3095,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.setMaxCost(vscode.workspace.getConfiguration("kilo-code.new").get("maxCost", 0)) } + private commitMessageLanguageSetting(): string { + return vscode.workspace.getConfiguration("kilo-code.new").get("languageCommitMessage", "sync") + } + private setMaxCost(value: unknown): number { maxCost = MaxCostNudge.normalizeLimit(typeof value === "number" ? value : Number(value)) ?? 0 this.costs.setLimit(maxCost) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index ecb8cd2e4a8..0c0c20fb63d 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend/connection-service" import { getErrorMessage } from "../../kilo-provider-utils" +import { getCommitMessageLanguage } from "../i18n" let lastGeneratedMessage: string | undefined let lastWorkspacePath: string | undefined @@ -94,7 +95,7 @@ export function registerCommitMessageService( try { const { data } = await client.commitMessage.generate( - { path, selectedFiles: undefined, previousMessage }, + { path, selectedFiles: undefined, previousMessage, language: getCommitMessageLanguage(vscode) }, { throwOnError: true, signal: controller.signal }, ) const message = data.message diff --git a/packages/kilo-vscode/src/services/i18n/index.ts b/packages/kilo-vscode/src/services/i18n/index.ts index 0082c2f9026..4da8dda2921 100644 --- a/packages/kilo-vscode/src/services/i18n/index.ts +++ b/packages/kilo-vscode/src/services/i18n/index.ts @@ -66,6 +66,13 @@ export function selectedLocale(vscode: typeof import("vscode")): string { return resolveLocale(lang || vscode.env.language) } +export function getCommitMessageLanguage(vscode: typeof import("vscode")): string { + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + const commitLang = cfg.get("languageCommitMessage") ?? "sync" + if (commitLang === "sync") return selectedLocale(vscode) + return resolveLocale(commitLang) +} + export function translate( locale: string, key: keyof typeof enDict | string, diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CommitMessageTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CommitMessageTab.tsx index ca463c17ff9..b641a7ad8ef 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CommitMessageTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CommitMessageTab.tsx @@ -1,15 +1,23 @@ -import { Component, Show, createSignal } from "solid-js" +import { Component, Show, createSignal, createMemo } from "solid-js" import { Switch } from "@kilocode/kilo-ui/switch" import { TextField } from "@kilocode/kilo-ui/text-field" import { Card } from "@kilocode/kilo-ui/card" +import { Select } from "@kilocode/kilo-ui/select" import { useConfig } from "../../context/config" -import { useLanguage } from "../../context/language" +import { useLanguage, LOCALES, LOCALE_LABELS } from "../../context/language" +import type { Locale } from "../../context/language" import SettingsRow from "./SettingsRow" +const SYNC = "sync" +const opts = [SYNC, ...LOCALES] as const +type Option = typeof SYNC | Locale + const CommitMessageTab: Component = () => { - const { config, updateConfig } = useConfig() + const { config, updateConfig, settings, updateSetting } = useConfig() const language = useLanguage() + const langValue = () => settings().languageCommitMessage ?? SYNC + const [expanded, setExpanded] = createSignal(Boolean(config().commit_message?.prompt)) const toggle = (checked: boolean) => { @@ -19,9 +27,46 @@ const CommitMessageTab: Component = () => { } } + const label = (opt: Option) => + opt === SYNC ? language.t("settings.commitMessage.language.sync") : LOCALE_LABELS[opt] + + const value = (opt: Option) => opt + + const onSelect = (opt: Option | undefined) => { + if (opt !== undefined) updateSetting("languageCommitMessage", opt) + } + + const currentLabel = createMemo(() => label(langValue() as Option)) + return ( -
- + +
+

+ {language.t("settings.commitMessage.language.description")} +

+