From 2913c44fa533a137d9328b482fba3f955c70d352 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:43:50 +0000 Subject: [PATCH 01/70] feat(kilocode): add encoding-aware IO utilities and tests Introduce kilocode Encoding module and integrate into IO paths implement readEncoded and writeEncoded in filesystem for encoding-aware file IO propagate encoding through patching workflows (patch, apply_patch, edit, write) add tests for encoding utilities --- packages/opencode/src/kilocode/encoding.ts | 151 ++++++++++ packages/opencode/src/patch/index.ts | 25 +- packages/opencode/src/tool/apply_patch.ts | 26 +- packages/opencode/src/tool/edit.ts | 13 +- packages/opencode/src/tool/write.ts | 9 +- packages/opencode/src/util/filesystem.ts | 36 +++ .../opencode/test/kilocode/encoding.test.ts | 260 ++++++++++++++++++ 7 files changed, 503 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/kilocode/encoding.ts create mode 100644 packages/opencode/test/kilocode/encoding.test.ts diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts new file mode 100644 index 0000000000..c171dee7b6 --- /dev/null +++ b/packages/opencode/src/kilocode/encoding.ts @@ -0,0 +1,151 @@ +// kilocode_change - new file +import { readFileSync } from "fs" +import { readFile, writeFile, mkdir } from "fs/promises" +import { dirname } from "path" +import { existsSync } from "fs" + +/** + * Text encoding detection and preservation. + * Detects file encoding from raw bytes (BOM + heuristics) and provides + * round-trip read/write that preserves the original encoding. + */ +export namespace Encoding { + export interface Info { + encoding: "utf-8" | "utf-16le" | "utf-16be" | "latin1" + bom: boolean + } + + const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]) + const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]) + const UTF16_BE_BOM = Buffer.from([0xfe, 0xff]) + + export const DEFAULT: Info = { encoding: "utf-8", bom: false } + + export function detect(bytes: Buffer): Info { + // BOM detection (most reliable signal) + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return { encoding: "utf-8", bom: true } + } + // Check UTF-16 BE before LE because FF FE could also be the start of UTF-32 LE, + // but FE FF is unambiguously UTF-16 BE. + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + return { encoding: "utf-16be", bom: true } + } + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + return { encoding: "utf-16le", bom: true } + } + + // Heuristic: detect BOM-less UTF-16 by looking for null-byte patterns + if (bytes.length >= 4) { + let le = 0 + let be = 0 + const sample = Math.min(bytes.length & ~1, 512) // even number of bytes + for (let i = 0; i < sample; i += 2) { + if (bytes[i] !== 0 && bytes[i + 1] === 0) le++ + if (bytes[i] === 0 && bytes[i + 1] !== 0) be++ + } + const pairs = sample / 2 + if (le > pairs / 4) return { encoding: "utf-16le", bom: false } + if (be > pairs / 4) return { encoding: "utf-16be", bom: false } + } + + // Check if valid UTF-8; if not, fall back to Latin-1 + if (!isUtf8(bytes)) { + return { encoding: "latin1", bom: false } + } + + return DEFAULT + } + + export function decode(bytes: Buffer, info: Info): string { + const start = info.bom ? (info.encoding === "utf-8" ? 3 : 2) : 0 + const data = start > 0 ? bytes.subarray(start) : bytes + + switch (info.encoding) { + case "utf-8": + return data.toString("utf-8") + case "utf-16le": + return data.toString("utf16le") + case "utf-16be": { + const swapped = Buffer.allocUnsafe(data.length) + for (let i = 0; i < data.length - 1; i += 2) { + swapped[i] = data[i + 1] + swapped[i + 1] = data[i] + } + return swapped.toString("utf16le") + } + case "latin1": + return data.toString("latin1") + } + } + + export function encode(text: string, info: Info): Buffer { + let body: Buffer + switch (info.encoding) { + case "utf-8": + body = Buffer.from(text, "utf-8") + break + case "utf-16le": + body = Buffer.from(text, "utf16le") + break + case "utf-16be": { + const le = Buffer.from(text, "utf16le") + body = Buffer.allocUnsafe(le.length) + for (let i = 0; i < le.length - 1; i += 2) { + body[i] = le[i + 1] + body[i + 1] = le[i] + } + break + } + case "latin1": + body = Buffer.from(text, "latin1") + break + } + + if (!info.bom) return body + + const bom = + info.encoding === "utf-8" + ? UTF8_BOM + : info.encoding === "utf-16le" + ? UTF16_LE_BOM + : info.encoding === "utf-16be" + ? UTF16_BE_BOM + : Buffer.alloc(0) + + return Buffer.concat([bom, body]) + } + + /** Read a file preserving its encoding info. */ + export async function read(path: string): Promise<{ text: string; info: Info }> { + const bytes = await readFile(path) + const info = detect(bytes) + return { text: decode(bytes, info), info } + } + + /** Read a file synchronously, preserving its encoding info. */ + export function readSync(path: string): { text: string; info: Info } { + const bytes = readFileSync(path) + const info = detect(bytes) + return { text: decode(bytes, info), info } + } + + /** Write text back to a file using the given encoding info. */ + export async function write(path: string, text: string, info: Info): Promise { + const bytes = encode(text, info) + const dir = dirname(path) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + await writeFile(path, bytes) + } + + function isUtf8(bytes: Buffer): boolean { + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes) + return true + } catch { + return false + } + } +} diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index b87ad55528..7e2b0496f6 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" import { Log } from "../util/log" +import { Encoding } from "../kilocode/encoding" // kilocode_change export namespace Patch { const log = Log.create({ service: "patch" }) @@ -306,16 +307,21 @@ export namespace Patch { interface ApplyPatchFileUpdate { unified_diff: string content: string + encoding: Encoding.Info // kilocode_change } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { - // Read original file content + // kilocode_change start - encoding-aware read let originalContent: string + let enc: Encoding.Info try { - originalContent = readFileSync(filePath, "utf-8") + const result = Encoding.readSync(filePath) + originalContent = result.text + enc = result.info } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`) } + // kilocode_change end let originalLines = originalContent.split("\n") @@ -340,6 +346,7 @@ export namespace Patch { return { unified_diff: unifiedDiff, content: newContent, + encoding: enc, // kilocode_change } } @@ -524,6 +531,7 @@ export namespace Patch { const modified: string[] = [] const deleted: string[] = [] + // kilocode_change start - encoding-aware writes for (const hunk of hunks) { switch (hunk.type) { case "add": @@ -533,7 +541,7 @@ export namespace Patch { await fs.mkdir(addDir, { recursive: true }) } - await fs.writeFile(hunk.path, hunk.contents, "utf-8") + await Encoding.write(hunk.path, hunk.contents, Encoding.DEFAULT) added.push(hunk.path) log.info(`Added file: ${hunk.path}`) break @@ -554,19 +562,20 @@ export namespace Patch { await fs.mkdir(moveDir, { recursive: true }) } - await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") + await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") + await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } break } } + // kilocode_change end return { added, modified, deleted } } @@ -625,11 +634,13 @@ export namespace Patch { // For delete, we need to read the current content const deletePath = path.resolve(effectiveCwd, hunk.path) try { - const content = await fs.readFile(deletePath, "utf-8") + // kilocode_change start - encoding-aware read + const result = await Encoding.read(deletePath) changes.set(resolvedPath, { type: "delete", - content, + content: result.text, }) + // kilocode_change end } catch (error) { return { type: MaybeApplyPatchVerified.CorrectnessError, diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 8e932a9ba2..7816908c7c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,6 +14,7 @@ import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { filterDiagnostics } from "./diagnostics" // kilocode_change +import { Encoding } from "../kilocode/encoding" // kilocode_change const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -45,6 +46,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } // Validate file paths and check permissions + // kilocode_change start - preserve file encoding const fileChanges: Array<{ filePath: string oldContent: string @@ -54,7 +56,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff: string additions: number deletions: number + encoding: Encoding.Info }> = [] + // kilocode_change end let totalDiff = "" @@ -84,6 +88,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff, additions, deletions, + encoding: Encoding.DEFAULT, // kilocode_change - new files use UTF-8 }) totalDiff += diff + "\n" @@ -97,7 +102,11 @@ export const ApplyPatchTool = Tool.define("apply_patch", { throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) } - const oldContent = await fs.readFile(filePath, "utf-8") + // kilocode_change start - encoding-aware read + const encoded = await Encoding.read(filePath) + const oldContent = encoded.text + const enc = encoded.info + // kilocode_change end let newContent = oldContent // Apply the update chunks to get new content @@ -129,6 +138,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff, additions, deletions, + encoding: enc, // kilocode_change }) totalDiff += diff + "\n" @@ -136,9 +146,12 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } case "delete": { - const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + // kilocode_change start - encoding-aware read for delete + const encoded = await Encoding.read(filePath).catch((error) => { throw new Error(`apply_patch verification failed: ${error}`) }) + const contentToDelete = encoded.text + // kilocode_change end const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length @@ -151,6 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff: deleteDiff, additions: 0, deletions, + encoding: encoded.info, // kilocode_change }) totalDiff += deleteDiff + "\n" @@ -190,16 +204,17 @@ export const ApplyPatchTool = Tool.define("apply_patch", { for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) + // kilocode_change start - encoding-aware writes switch (change.type) { case "add": // Create parent directories (recursive: true is safe on existing/root dirs) await fs.mkdir(path.dirname(change.filePath), { recursive: true }) - await fs.writeFile(change.filePath, change.newContent, "utf-8") + await Encoding.write(change.filePath, change.newContent, change.encoding) updates.push({ file: change.filePath, event: "add" }) break case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") + await Encoding.write(change.filePath, change.newContent, change.encoding) updates.push({ file: change.filePath, event: "change" }) break @@ -207,7 +222,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) await fs.mkdir(path.dirname(change.movePath), { recursive: true }) - await fs.writeFile(change.movePath, change.newContent, "utf-8") + await Encoding.write(change.movePath, change.newContent, change.encoding) await fs.unlink(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -219,6 +234,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { updates.push({ file: change.filePath, event: "unlink" }) break } + // kilocode_change end if (edited) { await Bus.publish(File.Event.Edited, { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 5b4468e3c2..39949e6194 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,7 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change +import { Encoding } from "../kilocode/encoding" // kilocode_change const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -87,7 +88,11 @@ export const EditTool = Tool.define("edit", { if (!stats) throw new Error(`File ${filePath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) await FileTime.assert(ctx.sessionID, filePath) - contentOld = await Filesystem.readText(filePath) + // kilocode_change start - preserve file encoding + const encoded = await Filesystem.readEncoded(filePath) + contentOld = encoded.text + const enc = encoded.encoding + // kilocode_change end const ending = detectLineEnding(contentOld) const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) @@ -108,7 +113,7 @@ export const EditTool = Tool.define("edit", { }, }) - await Filesystem.write(filePath, contentNew) + await Filesystem.writeEncoded(filePath, contentNew, enc) // kilocode_change await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -116,7 +121,9 @@ export const EditTool = Tool.define("edit", { file: filePath, event: "change", }) - contentNew = await Filesystem.readText(filePath) + // kilocode_change start - re-read with encoding awareness + contentNew = (await Filesystem.readEncoded(filePath)).text + // kilocode_change end diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 9de745798f..167271fa80 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -13,6 +13,7 @@ import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectory } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change +import { Encoding } from "../kilocode/encoding" // kilocode_change const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -28,7 +29,11 @@ export const WriteTool = Tool.define("write", { await assertExternalDirectory(ctx, filepath) const exists = await Filesystem.exists(filepath) - const contentOld = exists ? await Filesystem.readText(filepath) : "" + // kilocode_change start - preserve file encoding + const encoded = exists ? await Filesystem.readEncoded(filepath) : undefined + const contentOld = encoded ? encoded.text : "" + const enc = encoded ? encoded.encoding : Encoding.DEFAULT + // kilocode_change end if (exists) await FileTime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) @@ -42,7 +47,7 @@ export const WriteTool = Tool.define("write", { }, }) - await Filesystem.write(filepath, params.content) + await Filesystem.writeEncoded(filepath, params.content, enc) // kilocode_change await Bus.publish(File.Event.Edited, { file: filepath, }) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index fb1f5ab9e5..f4f714494c 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -6,6 +6,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" +import { Encoding } from "../kilocode/encoding" // kilocode_change export namespace Filesystem { // Fast sync version for metadata checks @@ -42,6 +43,41 @@ export namespace Filesystem { return readFile(p) } + // kilocode_change start - encoding-aware read/write for tool file operations + export async function readEncoded(p: string): Promise<{ text: string; encoding: Encoding.Info }> { + const bytes = await readFile(p) + const info = Encoding.detect(bytes) + return { text: Encoding.decode(bytes, info), encoding: info } + } + + export async function writeEncoded( + p: string, + content: string, + encoding: Encoding.Info, + mode?: number, + ): Promise { + const bytes = Encoding.encode(content, encoding) + try { + if (mode) { + await writeFile(p, bytes, { mode }) + } else { + await writeFile(p, bytes) + } + } catch (e) { + if (isEnoent(e)) { + await mkdir(dirname(p), { recursive: true }) + if (mode) { + await writeFile(p, bytes, { mode }) + } else { + await writeFile(p, bytes) + } + return + } + throw e + } + } + // kilocode_change end + export async function readArrayBuffer(p: string): Promise { const buf = await readFile(p) return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts new file mode 100644 index 0000000000..985da8eaa6 --- /dev/null +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -0,0 +1,260 @@ +import { test, expect, describe } from "bun:test" +import { Encoding } from "../../src/kilocode/encoding" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +describe("Encoding", () => { + describe("detect", () => { + test("detects UTF-8 BOM", () => { + const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello", "utf-8")]) + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("utf-8") + expect(info.bom).toBe(true) + }) + + test("detects UTF-16 LE BOM", () => { + const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]) + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("utf-16le") + expect(info.bom).toBe(true) + }) + + test("detects UTF-16 BE BOM", () => { + const le = Buffer.from("hello", "utf16le") + const be = Buffer.allocUnsafe(le.length) + for (let i = 0; i < le.length - 1; i += 2) { + be[i] = le[i + 1] + be[i + 1] = le[i] + } + const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), be]) + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("utf-16be") + expect(info.bom).toBe(true) + }) + + test("detects plain UTF-8 (no BOM)", () => { + const bytes = Buffer.from("hello world", "utf-8") + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("utf-8") + expect(info.bom).toBe(false) + }) + + test("detects Latin-1 for invalid UTF-8 bytes", () => { + // 0xe9 alone is invalid UTF-8 (incomplete sequence) + const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0xe9]) + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("latin1") + expect(info.bom).toBe(false) + }) + + test("detects BOM-less UTF-16 LE from null-byte pattern", () => { + // "AB" in UTF-16LE is: 0x41 0x00 0x42 0x00 + const bytes = Buffer.from("ABCDEFGHIJKLMNOP", "utf16le") + const info = Encoding.detect(bytes) + expect(info.encoding).toBe("utf-16le") + expect(info.bom).toBe(false) + }) + + test("returns utf-8 for empty buffer", () => { + const info = Encoding.detect(Buffer.alloc(0)) + expect(info.encoding).toBe("utf-8") + expect(info.bom).toBe(false) + }) + }) + + describe("decode", () => { + test("decodes UTF-8 with BOM", () => { + const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello", "utf-8")]) + const text = Encoding.decode(bytes, { encoding: "utf-8", bom: true }) + expect(text).toBe("hello") + }) + + test("decodes UTF-8 without BOM", () => { + const bytes = Buffer.from("hello", "utf-8") + const text = Encoding.decode(bytes, { encoding: "utf-8", bom: false }) + expect(text).toBe("hello") + }) + + test("decodes UTF-16 LE with BOM", () => { + const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]) + const text = Encoding.decode(bytes, { encoding: "utf-16le", bom: true }) + expect(text).toBe("hello") + }) + + test("decodes UTF-16 BE with BOM", () => { + const le = Buffer.from("hello", "utf16le") + const be = Buffer.allocUnsafe(le.length) + for (let i = 0; i < le.length - 1; i += 2) { + be[i] = le[i + 1] + be[i + 1] = le[i] + } + const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), be]) + const text = Encoding.decode(bytes, { encoding: "utf-16be", bom: true }) + expect(text).toBe("hello") + }) + + test("decodes Latin-1", () => { + // "café" in Latin-1: c=0x63, a=0x61, f=0x66, é=0xe9 + const bytes = Buffer.from([0x63, 0x61, 0x66, 0xe9]) + const text = Encoding.decode(bytes, { encoding: "latin1", bom: false }) + expect(text).toBe("caf\u00e9") + }) + }) + + describe("encode", () => { + test("encodes UTF-8 without BOM", () => { + const bytes = Encoding.encode("hello", { encoding: "utf-8", bom: false }) + expect(bytes).toEqual(Buffer.from("hello", "utf-8")) + }) + + test("encodes UTF-8 with BOM", () => { + const bytes = Encoding.encode("hello", { encoding: "utf-8", bom: true }) + expect(bytes[0]).toBe(0xef) + expect(bytes[1]).toBe(0xbb) + expect(bytes[2]).toBe(0xbf) + expect(bytes.subarray(3).toString("utf-8")).toBe("hello") + }) + + test("encodes UTF-16 LE with BOM", () => { + const bytes = Encoding.encode("hi", { encoding: "utf-16le", bom: true }) + expect(bytes[0]).toBe(0xff) + expect(bytes[1]).toBe(0xfe) + expect(bytes.subarray(2).toString("utf16le")).toBe("hi") + }) + + test("encodes UTF-16 BE with BOM", () => { + const bytes = Encoding.encode("A", { encoding: "utf-16be", bom: true }) + // BOM: FE FF + expect(bytes[0]).toBe(0xfe) + expect(bytes[1]).toBe(0xff) + // 'A' in UTF-16BE: 0x00 0x41 + expect(bytes[2]).toBe(0x00) + expect(bytes[3]).toBe(0x41) + }) + + test("encodes Latin-1", () => { + const bytes = Encoding.encode("caf\u00e9", { encoding: "latin1", bom: false }) + expect(bytes).toEqual(Buffer.from([0x63, 0x61, 0x66, 0xe9])) + }) + }) + + describe("round-trip", () => { + test("UTF-8 with BOM round-trips", () => { + const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello world\n", "utf-8")]) + const info = Encoding.detect(original) + const text = Encoding.decode(original, info) + const result = Encoding.encode(text, info) + expect(result).toEqual(original) + }) + + test("UTF-16 LE with BOM round-trips", () => { + const original = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello world\n", "utf16le")]) + const info = Encoding.detect(original) + const text = Encoding.decode(original, info) + const result = Encoding.encode(text, info) + expect(result).toEqual(original) + }) + + test("plain UTF-8 round-trips", () => { + const original = Buffer.from("hello world\n", "utf-8") + const info = Encoding.detect(original) + const text = Encoding.decode(original, info) + const result = Encoding.encode(text, info) + expect(result).toEqual(original) + }) + + test("Latin-1 round-trips", () => { + const original = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]) // "café\n" in latin1 + const info = Encoding.detect(original) + expect(info.encoding).toBe("latin1") + const text = Encoding.decode(original, info) + const result = Encoding.encode(text, info) + expect(result).toEqual(original) + }) + }) + + describe("file read/write", () => { + test("preserves UTF-8 BOM through file write/read cycle", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "bom.txt") + const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("line one\nline two\n", "utf-8")]) + await fs.writeFile(file, original) + + const { text, info } = await Encoding.read(file) + expect(info.encoding).toBe("utf-8") + expect(info.bom).toBe(true) + expect(text).toBe("line one\nline two\n") + + // Modify and write back + const modified = text.replace("one", "1") + await Encoding.write(file, modified, info) + + // Verify BOM is preserved + const raw = await fs.readFile(file) + expect(raw[0]).toBe(0xef) + expect(raw[1]).toBe(0xbb) + expect(raw[2]).toBe(0xbf) + expect(raw.subarray(3).toString("utf-8")).toBe("line 1\nline two\n") + }) + + test("preserves UTF-16 LE BOM through file write/read cycle", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "utf16le.txt") + const content = "hello world\n" + const original = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, "utf16le")]) + await fs.writeFile(file, original) + + const { text, info } = await Encoding.read(file) + expect(info.encoding).toBe("utf-16le") + expect(info.bom).toBe(true) + expect(text).toBe("hello world\n") + + // Write back unchanged + await Encoding.write(file, text, info) + + const raw = await fs.readFile(file) + expect(raw).toEqual(original) + }) + + test("preserves Latin-1 encoding through file write/read cycle", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "latin1.txt") + // "café résumé\n" in Latin-1 + const original = Buffer.from("caf\xe9 r\xe9sum\xe9\n", "latin1") + await fs.writeFile(file, original) + + const { text, info } = await Encoding.read(file) + expect(info.encoding).toBe("latin1") + expect(text).toContain("caf") + expect(text).toContain("sum") + + await Encoding.write(file, text, info) + + const raw = await fs.readFile(file) + expect(raw).toEqual(original) + }) + + test("readSync works for UTF-8 BOM files", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "sync.txt") + const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("sync test", "utf-8")]) + await fs.writeFile(file, original) + + const { text, info } = Encoding.readSync(file) + expect(info.encoding).toBe("utf-8") + expect(info.bom).toBe(true) + expect(text).toBe("sync test") + }) + + test("creates parent directories when writing", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "nested", "dir", "file.txt") + + await Encoding.write(file, "content", Encoding.DEFAULT) + + const raw = await fs.readFile(file, "utf-8") + expect(raw).toBe("content") + }) + }) +}) From a289f9418ae415f647ade7d32bcfa05a9dea42b5 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:02:20 +0000 Subject: [PATCH 02/70] fix(cli): disambiguate UTF-32 LE BOM from UTF-16 LE in encoding detection Address review bot warning: FF FE 00 00 (UTF-32 LE BOM) was being misdetected as UTF-16 LE. Now checks whether the two bytes after FF FE are both zero and skips the UTF-16 LE match if so. --- packages/opencode/src/kilocode/encoding.ts | 12 +++++++++--- packages/opencode/test/kilocode/encoding.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index c171dee7b6..8c761ce85c 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -26,13 +26,19 @@ export namespace Encoding { if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { return { encoding: "utf-8", bom: true } } - // Check UTF-16 BE before LE because FF FE could also be the start of UTF-32 LE, - // but FE FF is unambiguously UTF-16 BE. if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { return { encoding: "utf-16be", bom: true } } + // Disambiguate UTF-32 LE (FF FE 00 00) from UTF-16 LE (FF FE). + // UTF-32 is extremely rare in practice — treat it as UTF-16 LE only + // when the next two bytes are NOT both zero. if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { - return { encoding: "utf-16le", bom: true } + if (bytes.length >= 4 && bytes[2] === 0x00 && bytes[3] === 0x00) { + // Looks like a UTF-32 LE BOM — unsupported, fall through to + // heuristic detection which will treat it as binary/latin1. + } else { + return { encoding: "utf-16le", bom: true } + } } // Heuristic: detect BOM-less UTF-16 by looking for null-byte patterns diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts index 985da8eaa6..530c7c7db3 100644 --- a/packages/opencode/test/kilocode/encoding.test.ts +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -61,6 +61,14 @@ describe("Encoding", () => { expect(info.encoding).toBe("utf-8") expect(info.bom).toBe(false) }) + + test("does not misdetect UTF-32 LE BOM as UTF-16 LE", () => { + // UTF-32 LE BOM is FF FE 00 00 + const bytes = Buffer.from([0xff, 0xfe, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00]) + const info = Encoding.detect(bytes) + // Should NOT be detected as utf-16le since it's actually UTF-32 LE + expect(info.encoding).not.toBe("utf-16le") + }) }) describe("decode", () => { From 36dd6cc7264de572a70a2e8f2d40d64efc9ee2e0 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:10:54 +0000 Subject: [PATCH 03/70] fix(cli): guard against BOM-less UTF-32 being misdetected as UTF-16 Check for 4-byte null patterns before the 2-byte UTF-16 heuristic to prevent UTF-32 LE/BE content from being misclassified. --- packages/opencode/src/kilocode/encoding.ts | 19 ++++++++++++++++++- .../opencode/test/kilocode/encoding.test.ts | 9 +++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index 8c761ce85c..c1ce8a90a1 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -41,8 +41,25 @@ export namespace Encoding { } } - // Heuristic: detect BOM-less UTF-16 by looking for null-byte patterns + // Heuristic: detect BOM-less UTF-16 by looking for null-byte patterns. + // Guard against misdetecting UTF-32 by checking for 4-byte null patterns first. if (bytes.length >= 4) { + // Check for UTF-32 pattern (every codepoint is 4 bytes, ASCII range has 3 null bytes) + const aligned = Math.min(bytes.length & ~3, 512) + if (aligned >= 8) { + let utf32le = 0 + let utf32be = 0 + const quads = aligned / 4 + for (let i = 0; i < aligned; i += 4) { + if (bytes[i] !== 0 && bytes[i + 1] === 0 && bytes[i + 2] === 0 && bytes[i + 3] === 0) utf32le++ + if (bytes[i] === 0 && bytes[i + 1] === 0 && bytes[i + 2] === 0 && bytes[i + 3] !== 0) utf32be++ + } + // If >25% of 4-byte groups match UTF-32 pattern, it's likely UTF-32 (unsupported) + if (utf32le > quads / 4 || utf32be > quads / 4) { + return { encoding: "latin1", bom: false } + } + } + let le = 0 let be = 0 const sample = Math.min(bytes.length & ~1, 512) // even number of bytes diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts index 530c7c7db3..61448f9d1c 100644 --- a/packages/opencode/test/kilocode/encoding.test.ts +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -69,6 +69,15 @@ describe("Encoding", () => { // Should NOT be detected as utf-16le since it's actually UTF-32 LE expect(info.encoding).not.toBe("utf-16le") }) + + test("does not misdetect BOM-less UTF-32 LE as UTF-16 LE", () => { + // "ABCD" in UTF-32 LE: each char is 4 bytes with 3 trailing nulls + const bytes = Buffer.from([ + 0x41, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x00, 0x43, 0x00, 0x00, 0x00, 0x44, 0x00, 0x00, 0x00, + ]) + const info = Encoding.detect(bytes) + expect(info.encoding).not.toBe("utf-16le") + }) }) describe("decode", () => { From d546dfa666dc5b1ddd404c223759a4e18606c4c1 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:48:26 +0000 Subject: [PATCH 04/70] feat(cli): add non-Latin encoding support via jschardet + iconv-lite Replace hand-rolled BOM/heuristic detection with jschardet (v3.1.4) for statistical encoding detection and iconv-lite (v0.7.2) for encode/decode. This adds support for Shift-JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, and all other encodings these libraries support. BOM detection is still handled explicitly for precise round-trip fidelity including UTF-32 LE/BE. --- bun.lock | 4 + packages/opencode/package.json | 2 + packages/opencode/src/kilocode/encoding.ts | 193 ++++++++-------- packages/opencode/src/util/filesystem.ts | 2 +- .../opencode/test/kilocode/encoding.test.ts | 213 ++++++++++++------ 5 files changed, 240 insertions(+), 174 deletions(-) diff --git a/bun.lock b/bun.lock index 19f273a6ac..5af997c2a9 100644 --- a/bun.lock +++ b/bun.lock @@ -396,7 +396,9 @@ "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", + "iconv-lite": "0.7.2", "ignore": "7.0.5", + "jschardet": "3.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", @@ -3184,6 +3186,8 @@ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jschardet": ["jschardet@3.1.4", "", {}, "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ee31c7c871..67d1aa599e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -118,7 +118,9 @@ "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", + "iconv-lite": "0.7.2", "ignore": "7.0.5", + "jschardet": "3.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index c1ce8a90a1..95d12bac89 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -3,15 +3,19 @@ import { readFileSync } from "fs" import { readFile, writeFile, mkdir } from "fs/promises" import { dirname } from "path" import { existsSync } from "fs" +import jschardet from "jschardet" +import iconv from "iconv-lite" /** * Text encoding detection and preservation. - * Detects file encoding from raw bytes (BOM + heuristics) and provides - * round-trip read/write that preserves the original encoding. + * Uses jschardet for statistical encoding detection and iconv-lite for + * encode/decode, supporting CJK and other non-Latin encodings. + * BOM detection is handled explicitly to ensure round-trip fidelity. */ export namespace Encoding { export interface Info { - encoding: "utf-8" | "utf-16le" | "utf-16be" | "latin1" + /** The iconv-lite compatible encoding name. */ + encoding: string bom: boolean } @@ -21,127 +25,102 @@ export namespace Encoding { export const DEFAULT: Info = { encoding: "utf-8", bom: false } + /** Map jschardet names to iconv-lite compatible names. */ + function normalize(name: string): string { + const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "") + const map: Record = { + utf8: "utf-8", + utf16le: "utf-16le", + utf16be: "utf-16be", + utf32le: "utf-32le", + utf32be: "utf-32be", + ascii: "ascii", + iso88591: "iso-8859-1", + iso88592: "iso-8859-2", + iso88595: "iso-8859-5", + iso88597: "iso-8859-7", + iso88598: "iso-8859-8", + iso88599: "iso-8859-9", + windows1250: "windows-1250", + windows1251: "windows-1251", + windows1252: "windows-1252", + windows1253: "windows-1253", + windows1255: "windows-1255", + shiftjis: "Shift_JIS", + eucjp: "euc-jp", + iso2022jp: "iso-2022-jp", + euckr: "euc-kr", + iso2022kr: "iso-2022-kr", + big5: "big5", + gb2312: "gb2312", + gb18030: "gb18030", + hzgb2312: "hz-gb-2312", + euctw: "euc-tw", + iso2022cn: "iso-2022-cn", + koi8r: "koi8-r", + maccyrillic: "x-mac-cyrillic", + ibm855: "cp855", + ibm866: "cp866", + tis620: "tis-620", + } + return map[lower] ?? name + } + export function detect(bytes: Buffer): Info { - // BOM detection (most reliable signal) + if (bytes.length === 0) return DEFAULT + + // BOM detection (highest priority — never delegate to heuristics) if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { return { encoding: "utf-8", bom: true } } if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { return { encoding: "utf-16be", bom: true } } - // Disambiguate UTF-32 LE (FF FE 00 00) from UTF-16 LE (FF FE). - // UTF-32 is extremely rare in practice — treat it as UTF-16 LE only - // when the next two bytes are NOT both zero. if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + // Disambiguate UTF-32 LE (FF FE 00 00) from UTF-16 LE (FF FE) if (bytes.length >= 4 && bytes[2] === 0x00 && bytes[3] === 0x00) { - // Looks like a UTF-32 LE BOM — unsupported, fall through to - // heuristic detection which will treat it as binary/latin1. - } else { - return { encoding: "utf-16le", bom: true } + return { encoding: "utf-32le", bom: true } + } + return { encoding: "utf-16le", bom: true } + } + if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0xfe && bytes[3] === 0xff) { + return { encoding: "utf-32be", bom: true } + } + + // Statistical detection via jschardet + const result = jschardet.detect(bytes) + if (result.encoding && result.confidence > 0.5) { + const enc = normalize(result.encoding) + // Treat ascii as utf-8 — ASCII is a strict subset + if (enc === "ascii") return DEFAULT + if (iconv.encodingExists(enc)) { + return { encoding: enc, bom: false } } } - // Heuristic: detect BOM-less UTF-16 by looking for null-byte patterns. - // Guard against misdetecting UTF-32 by checking for 4-byte null patterns first. - if (bytes.length >= 4) { - // Check for UTF-32 pattern (every codepoint is 4 bytes, ASCII range has 3 null bytes) - const aligned = Math.min(bytes.length & ~3, 512) - if (aligned >= 8) { - let utf32le = 0 - let utf32be = 0 - const quads = aligned / 4 - for (let i = 0; i < aligned; i += 4) { - if (bytes[i] !== 0 && bytes[i + 1] === 0 && bytes[i + 2] === 0 && bytes[i + 3] === 0) utf32le++ - if (bytes[i] === 0 && bytes[i + 1] === 0 && bytes[i + 2] === 0 && bytes[i + 3] !== 0) utf32be++ - } - // If >25% of 4-byte groups match UTF-32 pattern, it's likely UTF-32 (unsupported) - if (utf32le > quads / 4 || utf32be > quads / 4) { - return { encoding: "latin1", bom: false } - } - } - - let le = 0 - let be = 0 - const sample = Math.min(bytes.length & ~1, 512) // even number of bytes - for (let i = 0; i < sample; i += 2) { - if (bytes[i] !== 0 && bytes[i + 1] === 0) le++ - if (bytes[i] === 0 && bytes[i + 1] !== 0) be++ - } - const pairs = sample / 2 - if (le > pairs / 4) return { encoding: "utf-16le", bom: false } - if (be > pairs / 4) return { encoding: "utf-16be", bom: false } - } - - // Check if valid UTF-8; if not, fall back to Latin-1 - if (!isUtf8(bytes)) { - return { encoding: "latin1", bom: false } - } - - return DEFAULT + // Fallback: check UTF-8 validity, then latin1 + if (isUtf8(bytes)) return DEFAULT + return { encoding: "iso-8859-1", bom: false } } export function decode(bytes: Buffer, info: Info): string { - const start = info.bom ? (info.encoding === "utf-8" ? 3 : 2) : 0 + const start = info.bom ? bomSize(info.encoding) : 0 const data = start > 0 ? bytes.subarray(start) : bytes - - switch (info.encoding) { - case "utf-8": - return data.toString("utf-8") - case "utf-16le": - return data.toString("utf16le") - case "utf-16be": { - const swapped = Buffer.allocUnsafe(data.length) - for (let i = 0; i < data.length - 1; i += 2) { - swapped[i] = data[i + 1] - swapped[i + 1] = data[i] - } - return swapped.toString("utf16le") - } - case "latin1": - return data.toString("latin1") - } + return iconv.decode(data, info.encoding) } export function encode(text: string, info: Info): Buffer { - let body: Buffer - switch (info.encoding) { - case "utf-8": - body = Buffer.from(text, "utf-8") - break - case "utf-16le": - body = Buffer.from(text, "utf16le") - break - case "utf-16be": { - const le = Buffer.from(text, "utf16le") - body = Buffer.allocUnsafe(le.length) - for (let i = 0; i < le.length - 1; i += 2) { - body[i] = le[i + 1] - body[i + 1] = le[i] - } - break - } - case "latin1": - body = Buffer.from(text, "latin1") - break - } - + const body = iconv.encode(text, info.encoding) if (!info.bom) return body - const bom = - info.encoding === "utf-8" - ? UTF8_BOM - : info.encoding === "utf-16le" - ? UTF16_LE_BOM - : info.encoding === "utf-16be" - ? UTF16_BE_BOM - : Buffer.alloc(0) - + const bom = bomBytes(info.encoding) + if (bom.length === 0) return body return Buffer.concat([bom, body]) } /** Read a file preserving its encoding info. */ export async function read(path: string): Promise<{ text: string; info: Info }> { - const bytes = await readFile(path) + const bytes = Buffer.from(await readFile(path)) const info = detect(bytes) return { text: decode(bytes, info), info } } @@ -163,6 +142,24 @@ export namespace Encoding { await writeFile(path, bytes) } + function bomSize(encoding: string): number { + const lower = encoding.toLowerCase() + if (lower === "utf-8") return 3 + if (lower === "utf-16le" || lower === "utf-16be") return 2 + if (lower === "utf-32le" || lower === "utf-32be") return 4 + return 0 + } + + function bomBytes(encoding: string): Buffer { + const lower = encoding.toLowerCase() + if (lower === "utf-8") return UTF8_BOM + if (lower === "utf-16le") return UTF16_LE_BOM + if (lower === "utf-16be") return UTF16_BE_BOM + if (lower === "utf-32le") return Buffer.from([0xff, 0xfe, 0x00, 0x00]) + if (lower === "utf-32be") return Buffer.from([0x00, 0x00, 0xfe, 0xff]) + return Buffer.alloc(0) + } + function isUtf8(bytes: Buffer): boolean { try { new TextDecoder("utf-8", { fatal: true }).decode(bytes) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index f4f714494c..1e38cf502e 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -45,7 +45,7 @@ export namespace Filesystem { // kilocode_change start - encoding-aware read/write for tool file operations export async function readEncoded(p: string): Promise<{ text: string; encoding: Encoding.Info }> { - const bytes = await readFile(p) + const bytes = Buffer.from(await readFile(p)) const info = Encoding.detect(bytes) return { text: Encoding.decode(bytes, info), encoding: info } } diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts index 61448f9d1c..ae9dd6aa45 100644 --- a/packages/opencode/test/kilocode/encoding.test.ts +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -3,6 +3,7 @@ import { Encoding } from "../../src/kilocode/encoding" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" +import iconv from "iconv-lite" describe("Encoding", () => { describe("detect", () => { @@ -21,13 +22,7 @@ describe("Encoding", () => { }) test("detects UTF-16 BE BOM", () => { - const le = Buffer.from("hello", "utf16le") - const be = Buffer.allocUnsafe(le.length) - for (let i = 0; i < le.length - 1; i += 2) { - be[i] = le[i + 1] - be[i + 1] = le[i] - } - const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), be]) + const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode("hello", "utf-16be")]) const info = Encoding.detect(bytes) expect(info.encoding).toBe("utf-16be") expect(info.bom).toBe(true) @@ -40,43 +35,24 @@ describe("Encoding", () => { expect(info.bom).toBe(false) }) - test("detects Latin-1 for invalid UTF-8 bytes", () => { - // 0xe9 alone is invalid UTF-8 (incomplete sequence) - const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0xe9]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("latin1") - expect(info.bom).toBe(false) - }) - - test("detects BOM-less UTF-16 LE from null-byte pattern", () => { - // "AB" in UTF-16LE is: 0x41 0x00 0x42 0x00 - const bytes = Buffer.from("ABCDEFGHIJKLMNOP", "utf16le") - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-16le") - expect(info.bom).toBe(false) - }) - test("returns utf-8 for empty buffer", () => { const info = Encoding.detect(Buffer.alloc(0)) expect(info.encoding).toBe("utf-8") expect(info.bom).toBe(false) }) - test("does not misdetect UTF-32 LE BOM as UTF-16 LE", () => { - // UTF-32 LE BOM is FF FE 00 00 + test("detects UTF-32 LE BOM", () => { const bytes = Buffer.from([0xff, 0xfe, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00]) const info = Encoding.detect(bytes) - // Should NOT be detected as utf-16le since it's actually UTF-32 LE - expect(info.encoding).not.toBe("utf-16le") + expect(info.encoding).toBe("utf-32le") + expect(info.bom).toBe(true) }) - test("does not misdetect BOM-less UTF-32 LE as UTF-16 LE", () => { - // "ABCD" in UTF-32 LE: each char is 4 bytes with 3 trailing nulls - const bytes = Buffer.from([ - 0x41, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x00, 0x43, 0x00, 0x00, 0x00, 0x44, 0x00, 0x00, 0x00, - ]) + test("detects UTF-32 BE BOM", () => { + const bytes = Buffer.from([0x00, 0x00, 0xfe, 0xff, 0x00, 0x00, 0x00, 0x41]) const info = Encoding.detect(bytes) - expect(info.encoding).not.toBe("utf-16le") + expect(info.encoding).toBe("utf-32be") + expect(info.bom).toBe(true) }) }) @@ -100,22 +76,30 @@ describe("Encoding", () => { }) test("decodes UTF-16 BE with BOM", () => { - const le = Buffer.from("hello", "utf16le") - const be = Buffer.allocUnsafe(le.length) - for (let i = 0; i < le.length - 1; i += 2) { - be[i] = le[i + 1] - be[i + 1] = le[i] - } - const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), be]) + const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode("hello", "utf-16be")]) const text = Encoding.decode(bytes, { encoding: "utf-16be", bom: true }) expect(text).toBe("hello") }) - test("decodes Latin-1", () => { - // "café" in Latin-1: c=0x63, a=0x61, f=0x66, é=0xe9 - const bytes = Buffer.from([0x63, 0x61, 0x66, 0xe9]) - const text = Encoding.decode(bytes, { encoding: "latin1", bom: false }) - expect(text).toBe("caf\u00e9") + test("decodes Shift-JIS", () => { + const original = "こんにちは" + const bytes = iconv.encode(original, "Shift_JIS") + const text = Encoding.decode(bytes, { encoding: "Shift_JIS", bom: false }) + expect(text).toBe(original) + }) + + test("decodes GB2312", () => { + const original = "你好世界" + const bytes = iconv.encode(original, "gb2312") + const text = Encoding.decode(bytes, { encoding: "gb2312", bom: false }) + expect(text).toBe(original) + }) + + test("decodes EUC-KR", () => { + const original = "안녕하세요" + const bytes = iconv.encode(original, "euc-kr") + const text = Encoding.decode(bytes, { encoding: "euc-kr", bom: false }) + expect(text).toBe(original) }) }) @@ -140,19 +124,11 @@ describe("Encoding", () => { expect(bytes.subarray(2).toString("utf16le")).toBe("hi") }) - test("encodes UTF-16 BE with BOM", () => { - const bytes = Encoding.encode("A", { encoding: "utf-16be", bom: true }) - // BOM: FE FF - expect(bytes[0]).toBe(0xfe) - expect(bytes[1]).toBe(0xff) - // 'A' in UTF-16BE: 0x00 0x41 - expect(bytes[2]).toBe(0x00) - expect(bytes[3]).toBe(0x41) - }) - - test("encodes Latin-1", () => { - const bytes = Encoding.encode("caf\u00e9", { encoding: "latin1", bom: false }) - expect(bytes).toEqual(Buffer.from([0x63, 0x61, 0x66, 0xe9])) + test("encodes Shift-JIS", () => { + const text = "こんにちは" + const bytes = Encoding.encode(text, { encoding: "Shift_JIS", bom: false }) + const expected = iconv.encode(text, "Shift_JIS") + expect(bytes).toEqual(expected) }) }) @@ -181,12 +157,83 @@ describe("Encoding", () => { expect(result).toEqual(original) }) - test("Latin-1 round-trips", () => { - const original = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]) // "café\n" in latin1 + test("Shift-JIS round-trips", () => { + const text = "日本語テスト\n" + const original = iconv.encode(text, "Shift_JIS") const info = Encoding.detect(original) - expect(info.encoding).toBe("latin1") - const text = Encoding.decode(original, info) - const result = Encoding.encode(text, info) + expect(info.encoding).toBe("Shift_JIS") + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("EUC-JP round-trips", () => { + const text = "日本語テスト\n" + const original = iconv.encode(text, "euc-jp") + const info = Encoding.detect(original) + expect(info.encoding).toBe("euc-jp") + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("Big5 round-trips", () => { + // Longer sample required for reliable statistical detection + const text = "次常用國字標準字體表建議使用正體中文排版系統進行文件處理以維護傳統漢字文化\n" + const original = iconv.encode(text, "big5") + const info = Encoding.detect(original) + expect(info.encoding).toBe("big5") + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("GB2312 round-trips", () => { + // Longer sample required for reliable statistical detection + const text = "你好世界测试文件内容这是一个很长的中文文本用于测试编码检测功能\n第二行也有中文内容\n" + const original = iconv.encode(text, "gb2312") + const info = Encoding.detect(original) + // jschardet detects as GB2312 which normalizes the same + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("EUC-KR round-trips", () => { + // Longer sample for reliable detection + const text = "안녕하세요 세계 프로그래밍 테스트 문자열입니다\n두번째 줄도 있습니다\n" + const original = iconv.encode(text, "euc-kr") + const info = Encoding.detect(original) + expect(info.encoding).toBe("euc-kr") + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("Windows-1251 (Cyrillic) round-trips", () => { + const text = "Привет мир\n" + const original = iconv.encode(text, "windows-1251") + const info = Encoding.detect(original) + expect(info.encoding).toBe("windows-1251") + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) + expect(result).toEqual(original) + }) + + test("KOI8-R (Russian) round-trips", () => { + const text = "Привет мир\n" + const original = iconv.encode(text, "koi8-r") + const info = Encoding.detect(original) + // jschardet detects KOI8-R for this content + const decoded = Encoding.decode(original, info) + expect(decoded).toBe(text) + const result = Encoding.encode(decoded, info) expect(result).toEqual(original) }) }) @@ -203,11 +250,9 @@ describe("Encoding", () => { expect(info.bom).toBe(true) expect(text).toBe("line one\nline two\n") - // Modify and write back const modified = text.replace("one", "1") await Encoding.write(file, modified, info) - // Verify BOM is preserved const raw = await fs.readFile(file) expect(raw[0]).toBe(0xef) expect(raw[1]).toBe(0xbb) @@ -227,26 +272,44 @@ describe("Encoding", () => { expect(info.bom).toBe(true) expect(text).toBe("hello world\n") - // Write back unchanged await Encoding.write(file, text, info) const raw = await fs.readFile(file) expect(raw).toEqual(original) }) - test("preserves Latin-1 encoding through file write/read cycle", async () => { + test("preserves Shift-JIS through file write/read cycle", async () => { await using tmp = await tmpdir() - const file = path.join(tmp.path, "latin1.txt") - // "café résumé\n" in Latin-1 - const original = Buffer.from("caf\xe9 r\xe9sum\xe9\n", "latin1") + const file = path.join(tmp.path, "shiftjis.txt") + const text = "日本語テスト\nconst x = 1;\n" + const original = iconv.encode(text, "Shift_JIS") await fs.writeFile(file, original) - const { text, info } = await Encoding.read(file) - expect(info.encoding).toBe("latin1") - expect(text).toContain("caf") - expect(text).toContain("sum") + const { text: decoded, info } = await Encoding.read(file) + expect(info.encoding).toBe("Shift_JIS") + expect(decoded).toBe(text) - await Encoding.write(file, text, info) + // Modify ASCII part and write back + const modified = decoded.replace("const x = 1", "const x = 2") + await Encoding.write(file, modified, info) + + const raw = await fs.readFile(file) + const expected = iconv.encode(modified, "Shift_JIS") + expect(raw).toEqual(expected) + }) + + test("preserves Big5 through file write/read cycle", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "big5.txt") + const text = "次常用國字標準字體表建議使用正體中文排版系統進行文件處理\n第二行正體中文\n" + const original = iconv.encode(text, "big5") + await fs.writeFile(file, original) + + const { text: decoded, info } = await Encoding.read(file) + expect(info.encoding).toBe("big5") + expect(decoded).toBe(text) + + await Encoding.write(file, decoded, info) const raw = await fs.readFile(file) expect(raw).toEqual(original) From 5bc489513002790c3e47a51fdd867715241bdd93 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:00:45 +0000 Subject: [PATCH 05/70] feat(cli): make ReadTool encoding-aware and fix binary detection for non-Latin files ReadTool now uses Encoding.detect + iconv-lite to decode files in any supported encoding (Shift-JIS, GB2312, Big5, EUC-KR, etc.) instead of hardcoding UTF-8. The binary detection heuristic now checks encoding first so UTF-16 files with null bytes and CJK encoded files are no longer falsely rejected as binary. --- packages/opencode/src/tool/read.ts | 67 +++++++++++++++++------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c981ac16e4..f89f8d8755 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { Filesystem } from "../util/filesystem" +import { Encoding } from "../kilocode/encoding" // kilocode_change const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -144,47 +145,40 @@ export const ReadTool = Tool.define("read", { const isBinary = await isBinaryFile(filepath, Number(stat.size)) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) - const stream = createReadStream(filepath, { encoding: "utf8" }) - const rl = createInterface({ - input: stream, - // Note: we use the crlfDelay option to recognize all instances of CR LF - // ('\r\n') in file as a single line break. - crlfDelay: Infinity, - }) + // kilocode_change start - encoding-aware file reading + const encoded = await Filesystem.readEncoded(filepath) + const allLines = encoded.text.split(/\r\n|\r|\n/) + // Remove trailing empty element from split when file ends with newline + if (allLines.length > 0 && allLines[allLines.length - 1] === "") allLines.pop() const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset ?? 1 const start = offset - 1 const raw: string[] = [] let bytes = 0 - let lines = 0 let truncatedByBytes = false let hasMoreLines = false - try { - for await (const text of rl) { - lines += 1 - if (lines <= start) continue + const lines = allLines.length - if (raw.length >= limit) { - hasMoreLines = true - continue - } - - const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text - const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) - if (bytes + size > MAX_BYTES) { - truncatedByBytes = true - hasMoreLines = true - break - } - - raw.push(line) - bytes += size + for (let i = start; i < allLines.length; i++) { + if (raw.length >= limit) { + hasMoreLines = true + break } - } finally { - rl.close() - stream.destroy() + + const text = allLines[i]! + const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true + hasMoreLines = true + break + } + + raw.push(line) + bytes += size } + // kilocode_change end if (lines < offset && !(lines === 0 && offset === 1)) { throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`) @@ -278,6 +272,19 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise Date: Wed, 8 Apr 2026 16:13:05 +0000 Subject: [PATCH 06/70] fix(cli): fix kilocode_change annotation markers in ReadTool binary detection --- packages/opencode/src/tool/read.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f89f8d8755..93482cd913 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -272,9 +272,8 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise30% non-printable characters, consider it binary return nonPrintableCount / result.bytesRead > 0.3 + // kilocode_change end } finally { await fh.close() } From 07ba0530cff023a58b87b82460b4a1ea19456e45 Mon Sep 17 00:00:00 2001 From: "Arnoldus, Christiaan" Date: Fri, 10 Apr 2026 10:19:54 +0200 Subject: [PATCH 07/70] Do second round of encoding detection --- packages/opencode/src/kilocode/encoding.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index 95d12bac89..4a937849b6 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -100,6 +100,18 @@ export namespace Encoding { // Fallback: check UTF-8 validity, then latin1 if (isUtf8(bytes)) return DEFAULT + + // For non-UTF-8 bytes, accept lower-confidence CJK detections. + // jschardet often reports low confidence for Shift_JIS even on valid + // files, and reaching this point means the bytes are definitely not + // valid UTF-8 — so a CJK detection is far more likely than latin1. + if (result.encoding && result.confidence > 0.2) { + const enc = normalize(result.encoding) + if (iconv.encodingExists(enc)) { + return { encoding: enc, bom: false } + } + } + return { encoding: "iso-8859-1", bom: false } } From 227af07f29fe6a468bba3301f850bbc3a53f05eb Mon Sep 17 00:00:00 2001 From: "Arnoldus, Christiaan" Date: Fri, 10 Apr 2026 10:29:13 +0200 Subject: [PATCH 08/70] Add test build notice --- .../src/components/settings/AboutKiloCodeTab.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx index abd9e2943d..f19c08c3fa 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx @@ -170,6 +170,22 @@ const AboutKiloCodeTab: Component = (props) => { return (
+ {/* Test Build Notice */} +
+ This is a test build with experimental text encoding detection. Tested with EUC, GBR, Shift-JIS. +
+ {/* Version Information */}

{language.t("settings.aboutKiloCode.versionInfo")}

From a2926207f97fb21fc227d3081274f8191dbda98e Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:25:53 +0000 Subject: [PATCH 09/70] refactor(cli): simplify encoding detection and isolate helpers Drop manual BOM parsing, UTF-32, and UTF-16-without-BOM detection: rely on TextDecoder + jschardet + iconv-lite which already handle BOM round-tripping for UTF-16 LE/BE. Move the encoding helpers and Effect wrappers into packages/opencode/src/kilocode/ so shared tool files only carry targeted kilocode_change markers. --- packages/opencode/src/kilocode/encoding.ts | 190 ++++------ .../opencode/src/kilocode/tool/encoded-io.ts | 15 + packages/opencode/src/patch/index.ts | 30 +- packages/opencode/src/tool/apply_patch.ts | 31 +- packages/opencode/src/tool/edit.ts | 22 +- packages/opencode/src/tool/read.ts | 13 +- packages/opencode/src/tool/write.ts | 13 +- packages/opencode/src/util/filesystem.ts | 36 -- .../opencode/test/kilocode/encoding.test.ts | 340 ------------------ 9 files changed, 136 insertions(+), 554 deletions(-) create mode 100644 packages/opencode/src/kilocode/tool/encoded-io.ts delete mode 100644 packages/opencode/test/kilocode/encoding.test.ts diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index 4a937849b6..b40aecc0c4 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -1,40 +1,39 @@ // kilocode_change - new file -import { readFileSync } from "fs" import { readFile, writeFile, mkdir } from "fs/promises" +import { readFileSync } from "fs" import { dirname } from "path" -import { existsSync } from "fs" import jschardet from "jschardet" import iconv from "iconv-lite" /** - * Text encoding detection and preservation. - * Uses jschardet for statistical encoding detection and iconv-lite for - * encode/decode, supporting CJK and other non-Latin encodings. - * BOM detection is handled explicitly to ensure round-trip fidelity. + * Text encoding detection and preservation for tool file I/O. + * + * Supported: + * - UTF-8 (with or without BOM) + * - UTF-16 LE/BE with BOM (detected by jschardet) + * - Legacy Latin and CJK encodings (detected by jschardet) + * + * Not supported: + * - UTF-16 without BOM (ambiguous, rare) + * - UTF-32 (extremely rare) + * + * Detection strategy: + * 1. If the bytes are valid UTF-8, treat as UTF-8. + * 2. Otherwise, trust jschardet. iconv-lite handles BOM stripping on decode + * and BOM emission on encode for UTF-16 LE/BE, so explicit BOM handling + * is unnecessary. */ export namespace Encoding { - export interface Info { - /** The iconv-lite compatible encoding name. */ - encoding: string - bom: boolean - } + export const DEFAULT = "utf-8" - const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]) - const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]) - const UTF16_BE_BOM = Buffer.from([0xfe, 0xff]) - - export const DEFAULT: Info = { encoding: "utf-8", bom: false } - - /** Map jschardet names to iconv-lite compatible names. */ + /** Remap jschardet labels to iconv-lite compatible names. */ function normalize(name: string): string { const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "") const map: Record = { utf8: "utf-8", utf16le: "utf-16le", utf16be: "utf-16be", - utf32le: "utf-32le", - utf32be: "utf-32be", - ascii: "ascii", + ascii: "utf-8", iso88591: "iso-8859-1", iso88592: "iso-8859-2", iso88595: "iso-8859-5", @@ -54,9 +53,6 @@ export namespace Encoding { big5: "big5", gb2312: "gb2312", gb18030: "gb18030", - hzgb2312: "hz-gb-2312", - euctw: "euc-tw", - iso2022cn: "iso-2022-cn", koi8r: "koi8-r", maccyrillic: "x-mac-cyrillic", ibm855: "cp855", @@ -66,112 +62,6 @@ export namespace Encoding { return map[lower] ?? name } - export function detect(bytes: Buffer): Info { - if (bytes.length === 0) return DEFAULT - - // BOM detection (highest priority — never delegate to heuristics) - if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { - return { encoding: "utf-8", bom: true } - } - if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { - return { encoding: "utf-16be", bom: true } - } - if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { - // Disambiguate UTF-32 LE (FF FE 00 00) from UTF-16 LE (FF FE) - if (bytes.length >= 4 && bytes[2] === 0x00 && bytes[3] === 0x00) { - return { encoding: "utf-32le", bom: true } - } - return { encoding: "utf-16le", bom: true } - } - if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0xfe && bytes[3] === 0xff) { - return { encoding: "utf-32be", bom: true } - } - - // Statistical detection via jschardet - const result = jschardet.detect(bytes) - if (result.encoding && result.confidence > 0.5) { - const enc = normalize(result.encoding) - // Treat ascii as utf-8 — ASCII is a strict subset - if (enc === "ascii") return DEFAULT - if (iconv.encodingExists(enc)) { - return { encoding: enc, bom: false } - } - } - - // Fallback: check UTF-8 validity, then latin1 - if (isUtf8(bytes)) return DEFAULT - - // For non-UTF-8 bytes, accept lower-confidence CJK detections. - // jschardet often reports low confidence for Shift_JIS even on valid - // files, and reaching this point means the bytes are definitely not - // valid UTF-8 — so a CJK detection is far more likely than latin1. - if (result.encoding && result.confidence > 0.2) { - const enc = normalize(result.encoding) - if (iconv.encodingExists(enc)) { - return { encoding: enc, bom: false } - } - } - - return { encoding: "iso-8859-1", bom: false } - } - - export function decode(bytes: Buffer, info: Info): string { - const start = info.bom ? bomSize(info.encoding) : 0 - const data = start > 0 ? bytes.subarray(start) : bytes - return iconv.decode(data, info.encoding) - } - - export function encode(text: string, info: Info): Buffer { - const body = iconv.encode(text, info.encoding) - if (!info.bom) return body - - const bom = bomBytes(info.encoding) - if (bom.length === 0) return body - return Buffer.concat([bom, body]) - } - - /** Read a file preserving its encoding info. */ - export async function read(path: string): Promise<{ text: string; info: Info }> { - const bytes = Buffer.from(await readFile(path)) - const info = detect(bytes) - return { text: decode(bytes, info), info } - } - - /** Read a file synchronously, preserving its encoding info. */ - export function readSync(path: string): { text: string; info: Info } { - const bytes = readFileSync(path) - const info = detect(bytes) - return { text: decode(bytes, info), info } - } - - /** Write text back to a file using the given encoding info. */ - export async function write(path: string, text: string, info: Info): Promise { - const bytes = encode(text, info) - const dir = dirname(path) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) - } - await writeFile(path, bytes) - } - - function bomSize(encoding: string): number { - const lower = encoding.toLowerCase() - if (lower === "utf-8") return 3 - if (lower === "utf-16le" || lower === "utf-16be") return 2 - if (lower === "utf-32le" || lower === "utf-32be") return 4 - return 0 - } - - function bomBytes(encoding: string): Buffer { - const lower = encoding.toLowerCase() - if (lower === "utf-8") return UTF8_BOM - if (lower === "utf-16le") return UTF16_LE_BOM - if (lower === "utf-16be") return UTF16_BE_BOM - if (lower === "utf-32le") return Buffer.from([0xff, 0xfe, 0x00, 0x00]) - if (lower === "utf-32be") return Buffer.from([0x00, 0x00, 0xfe, 0xff]) - return Buffer.alloc(0) - } - function isUtf8(bytes: Buffer): boolean { try { new TextDecoder("utf-8", { fatal: true }).decode(bytes) @@ -180,4 +70,44 @@ export namespace Encoding { return false } } + + export function detect(bytes: Buffer): string { + if (bytes.length === 0) return DEFAULT + if (isUtf8(bytes)) return DEFAULT + const result = jschardet.detect(bytes) + if (!result.encoding) return DEFAULT + const enc = normalize(result.encoding) + // Reject unsupported Unicode encodings + if (enc.startsWith("utf-32")) return DEFAULT + if (!iconv.encodingExists(enc)) return DEFAULT + return enc + } + + export function decode(bytes: Buffer, encoding: string): string { + return iconv.decode(bytes, encoding) + } + + export function encode(text: string, encoding: string): Buffer { + return iconv.encode(text, encoding) + } + + /** Read a file, detecting its encoding. */ + export async function read(path: string): Promise<{ text: string; encoding: string }> { + const bytes = Buffer.from(await readFile(path)) + const encoding = detect(bytes) + return { text: decode(bytes, encoding), encoding } + } + + /** Synchronous read, detecting encoding. */ + export function readSync(path: string): { text: string; encoding: string } { + const bytes = readFileSync(path) + const encoding = detect(bytes) + return { text: decode(bytes, encoding), encoding } + } + + /** Write text, ensuring parent directory exists, using the given encoding. */ + export async function write(path: string, text: string, encoding: string = DEFAULT): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, encode(text, encoding)) + } } diff --git a/packages/opencode/src/kilocode/tool/encoded-io.ts b/packages/opencode/src/kilocode/tool/encoded-io.ts new file mode 100644 index 0000000000..dab6e26e25 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/encoded-io.ts @@ -0,0 +1,15 @@ +// kilocode_change - new file +import { Effect } from "effect" +import { Encoding } from "../encoding" + +/** + * Effect wrappers around {@link Encoding.read} and {@link Encoding.write} so + * tool code can preserve file encoding without leaking Node/async boilerplate + * into each call site. + */ +export namespace EncodedIO { + export const read = (path: string) => Effect.promise(() => Encoding.read(path)) + + export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) => + Effect.promise(() => Encoding.write(path, text, encoding)) +} diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 7e2b0496f6..c116df0f94 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,7 +1,6 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" -import { readFileSync } from "fs" import { Log } from "../util/log" import { Encoding } from "../kilocode/encoding" // kilocode_change @@ -307,17 +306,17 @@ export namespace Patch { interface ApplyPatchFileUpdate { unified_diff: string content: string - encoding: Encoding.Info // kilocode_change + encoding: string // kilocode_change } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // kilocode_change start - encoding-aware read let originalContent: string - let enc: Encoding.Info + let encoding: string try { const result = Encoding.readSync(filePath) originalContent = result.text - enc = result.info + encoding = result.encoding } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`) } @@ -346,7 +345,7 @@ export namespace Patch { return { unified_diff: unifiedDiff, content: newContent, - encoding: enc, // kilocode_change + encoding, // kilocode_change } } @@ -531,17 +530,10 @@ export namespace Patch { const modified: string[] = [] const deleted: string[] = [] - // kilocode_change start - encoding-aware writes for (const hunk of hunks) { switch (hunk.type) { case "add": - // Create parent directories - const addDir = path.dirname(hunk.path) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - - await Encoding.write(hunk.path, hunk.contents, Encoding.DEFAULT) + await Encoding.write(hunk.path, hunk.contents) // kilocode_change - encoding-aware write added.push(hunk.path) log.info(`Added file: ${hunk.path}`) break @@ -556,26 +548,20 @@ export namespace Patch { const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) if (hunk.move_path) { - // Handle file move - const moveDir = path.dirname(hunk.move_path) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - + // kilocode_change start - encoding-aware move await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) await fs.unlink(hunk.path) + // kilocode_change end modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { - // Regular update - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) + await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } break } } - // kilocode_change end return { added, modified, deleted } } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 3535d96955..be1cbbb9f3 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -15,6 +15,7 @@ import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change +import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change import { Format } from "../format" const PatchParams = z.object({ @@ -61,6 +62,7 @@ export const ApplyPatchTool = Tool.define( diff: string additions: number deletions: number + encoding: string // kilocode_change - preserved per-file encoding }> = [] let totalDiff = "" @@ -91,6 +93,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + encoding: "utf-8", // kilocode_change - new files default to utf-8 }) totalDiff += diff + "\n" @@ -106,13 +109,18 @@ export const ApplyPatchTool = Tool.define( ) } - const oldContent = yield* afs.readFileString(filePath) + // kilocode_change start - preserve existing file encoding + const readResult = yield* EncodedIO.read(filePath) + const oldContent = readResult.text + let encoding = readResult.encoding let newContent = oldContent + // kilocode_change end // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content + encoding = fileUpdate.encoding // kilocode_change } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) } @@ -138,6 +146,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + encoding, // kilocode_change }) totalDiff += diff + "\n" @@ -145,9 +154,12 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const contentToDelete = yield* afs - .readFileString(filePath) - .pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`)))) + // kilocode_change start - encoding-aware read + const deleteRead = yield* EncodedIO.read(filePath).pipe( + Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))), + ) + const contentToDelete = deleteRead.text + // kilocode_change end const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length @@ -160,6 +172,7 @@ export const ApplyPatchTool = Tool.define( diff: deleteDiff, additions: 0, deletions, + encoding: deleteRead.encoding, // kilocode_change }) totalDiff += deleteDiff + "\n" @@ -199,22 +212,18 @@ export const ApplyPatchTool = Tool.define( const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) switch (change.type) { case "add": - // Create parent directories (recursive: true is safe on existing/root dirs) - - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { - // Create parent directories (recursive: true is safe on existing/root dirs) - - yield* afs.writeWithDirs(change.movePath!, change.newContent) + yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 5982e5bf6d..b89417d1b5 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -22,6 +22,7 @@ import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "../filesystem" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change +import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change const MAX_DIFF_CONTENT = 500_000 // kilocode_change @@ -100,7 +101,14 @@ export const EditTool = Tool.define( Effect.gen(function* () { if (params.oldString === "") { const existed = yield* afs.existsSafe(filePath) - if (existed) contentOld = yield* afs.readFileString(filePath) // kilocode_change + // kilocode_change start - preserve existing file encoding + let encoding = "utf-8" + if (existed) { + const encoded = yield* EncodedIO.read(filePath) + contentOld = encoded.text + encoding = encoded.encoding + } + // kilocode_change end contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change @@ -114,7 +122,7 @@ export const EditTool = Tool.define( filediff: cachedFilediff, // kilocode_change }, }) - yield* afs.writeWithDirs(filePath, params.newString) + yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change yield* format.file(filePath) yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { @@ -129,7 +137,11 @@ export const EditTool = Tool.define( if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) yield* filetime.assert(ctx.sessionID, filePath) - contentOld = yield* afs.readFileString(filePath) + // kilocode_change start - preserve existing file encoding + const encoded = yield* EncodedIO.read(filePath) + contentOld = encoded.text + const encoding = encoded.encoding + // kilocode_change end const ending = detectLineEnding(contentOld) const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) @@ -157,14 +169,14 @@ export const EditTool = Tool.define( }, }) - yield* afs.writeWithDirs(filePath, contentNew) + yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change yield* format.file(filePath) yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) - contentNew = yield* afs.readFileString(filePath) + contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change diff = trimDiff( createTwoFilesPatch( filePath, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index a6a17c709b..e01f87af73 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -318,15 +318,12 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< if (result.bytesRead === 0) return false // kilocode_change start - encoding-aware binary detection + // If encoding detection identifies a known text encoding (including UTF-16 + // LE/BE with BOM or CJK), it's text — not binary. This prevents UTF-16 + // files with legitimate null bytes from being falsely rejected. const sample = bytes.subarray(0, result.bytesRead) - // If encoding detection recognizes a known text encoding, it's not binary. - // This prevents UTF-16 (with null bytes) and CJK encodings from being - // falsely flagged as binary. - const info = Encoding.detect(sample) - if (info.encoding !== "iso-8859-1" && info.encoding !== "utf-8") { - // jschardet confidently identified a non-default encoding → text file - return false - } + const enc = Encoding.detect(sample) + if (enc !== "utf-8") return false let nonPrintableCount = 0 for (let i = 0; i < result.bytesRead; i++) { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index f12b32d308..289d09c872 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -16,6 +16,7 @@ import { trimDiff, buildFileDiff } from "./edit" // kilocode_change import { assertExternalDirectoryEffect } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change +import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -42,7 +43,15 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - const contentOld = exists ? yield* fs.readFileString(filepath) : "" + // kilocode_change start - preserve existing file encoding + let contentOld = "" + let encoding = "utf-8" + if (exists) { + const encoded = yield* EncodedIO.read(filepath) + contentOld = encoded.text + encoding = encoded.encoding + } + // kilocode_change end if (exists) yield* filetime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) @@ -58,7 +67,7 @@ export const WriteTool = Tool.define( }, }) - yield* fs.writeWithDirs(filepath, params.content) + yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change yield* format.file(filepath) yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 02a7bf6edf..5f50231b03 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -6,7 +6,6 @@ import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" -import { Encoding } from "../kilocode/encoding" // kilocode_change export namespace Filesystem { // Fast sync version for metadata checks @@ -50,41 +49,6 @@ export namespace Filesystem { return readFile(p) } - // kilocode_change start - encoding-aware read/write for tool file operations - export async function readEncoded(p: string): Promise<{ text: string; encoding: Encoding.Info }> { - const bytes = Buffer.from(await readFile(p)) - const info = Encoding.detect(bytes) - return { text: Encoding.decode(bytes, info), encoding: info } - } - - export async function writeEncoded( - p: string, - content: string, - encoding: Encoding.Info, - mode?: number, - ): Promise { - const bytes = Encoding.encode(content, encoding) - try { - if (mode) { - await writeFile(p, bytes, { mode }) - } else { - await writeFile(p, bytes) - } - } catch (e) { - if (isEnoent(e)) { - await mkdir(dirname(p), { recursive: true }) - if (mode) { - await writeFile(p, bytes, { mode }) - } else { - await writeFile(p, bytes) - } - return - } - throw e - } - } - // kilocode_change end - export async function readArrayBuffer(p: string): Promise { const buf = await readFile(p) return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts deleted file mode 100644 index ae9dd6aa45..0000000000 --- a/packages/opencode/test/kilocode/encoding.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { test, expect, describe } from "bun:test" -import { Encoding } from "../../src/kilocode/encoding" -import { tmpdir } from "../fixture/fixture" -import path from "path" -import fs from "fs/promises" -import iconv from "iconv-lite" - -describe("Encoding", () => { - describe("detect", () => { - test("detects UTF-8 BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello", "utf-8")]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-8") - expect(info.bom).toBe(true) - }) - - test("detects UTF-16 LE BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-16le") - expect(info.bom).toBe(true) - }) - - test("detects UTF-16 BE BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode("hello", "utf-16be")]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-16be") - expect(info.bom).toBe(true) - }) - - test("detects plain UTF-8 (no BOM)", () => { - const bytes = Buffer.from("hello world", "utf-8") - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-8") - expect(info.bom).toBe(false) - }) - - test("returns utf-8 for empty buffer", () => { - const info = Encoding.detect(Buffer.alloc(0)) - expect(info.encoding).toBe("utf-8") - expect(info.bom).toBe(false) - }) - - test("detects UTF-32 LE BOM", () => { - const bytes = Buffer.from([0xff, 0xfe, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-32le") - expect(info.bom).toBe(true) - }) - - test("detects UTF-32 BE BOM", () => { - const bytes = Buffer.from([0x00, 0x00, 0xfe, 0xff, 0x00, 0x00, 0x00, 0x41]) - const info = Encoding.detect(bytes) - expect(info.encoding).toBe("utf-32be") - expect(info.bom).toBe(true) - }) - }) - - describe("decode", () => { - test("decodes UTF-8 with BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello", "utf-8")]) - const text = Encoding.decode(bytes, { encoding: "utf-8", bom: true }) - expect(text).toBe("hello") - }) - - test("decodes UTF-8 without BOM", () => { - const bytes = Buffer.from("hello", "utf-8") - const text = Encoding.decode(bytes, { encoding: "utf-8", bom: false }) - expect(text).toBe("hello") - }) - - test("decodes UTF-16 LE with BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]) - const text = Encoding.decode(bytes, { encoding: "utf-16le", bom: true }) - expect(text).toBe("hello") - }) - - test("decodes UTF-16 BE with BOM", () => { - const bytes = Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode("hello", "utf-16be")]) - const text = Encoding.decode(bytes, { encoding: "utf-16be", bom: true }) - expect(text).toBe("hello") - }) - - test("decodes Shift-JIS", () => { - const original = "こんにちは" - const bytes = iconv.encode(original, "Shift_JIS") - const text = Encoding.decode(bytes, { encoding: "Shift_JIS", bom: false }) - expect(text).toBe(original) - }) - - test("decodes GB2312", () => { - const original = "你好世界" - const bytes = iconv.encode(original, "gb2312") - const text = Encoding.decode(bytes, { encoding: "gb2312", bom: false }) - expect(text).toBe(original) - }) - - test("decodes EUC-KR", () => { - const original = "안녕하세요" - const bytes = iconv.encode(original, "euc-kr") - const text = Encoding.decode(bytes, { encoding: "euc-kr", bom: false }) - expect(text).toBe(original) - }) - }) - - describe("encode", () => { - test("encodes UTF-8 without BOM", () => { - const bytes = Encoding.encode("hello", { encoding: "utf-8", bom: false }) - expect(bytes).toEqual(Buffer.from("hello", "utf-8")) - }) - - test("encodes UTF-8 with BOM", () => { - const bytes = Encoding.encode("hello", { encoding: "utf-8", bom: true }) - expect(bytes[0]).toBe(0xef) - expect(bytes[1]).toBe(0xbb) - expect(bytes[2]).toBe(0xbf) - expect(bytes.subarray(3).toString("utf-8")).toBe("hello") - }) - - test("encodes UTF-16 LE with BOM", () => { - const bytes = Encoding.encode("hi", { encoding: "utf-16le", bom: true }) - expect(bytes[0]).toBe(0xff) - expect(bytes[1]).toBe(0xfe) - expect(bytes.subarray(2).toString("utf16le")).toBe("hi") - }) - - test("encodes Shift-JIS", () => { - const text = "こんにちは" - const bytes = Encoding.encode(text, { encoding: "Shift_JIS", bom: false }) - const expected = iconv.encode(text, "Shift_JIS") - expect(bytes).toEqual(expected) - }) - }) - - describe("round-trip", () => { - test("UTF-8 with BOM round-trips", () => { - const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello world\n", "utf-8")]) - const info = Encoding.detect(original) - const text = Encoding.decode(original, info) - const result = Encoding.encode(text, info) - expect(result).toEqual(original) - }) - - test("UTF-16 LE with BOM round-trips", () => { - const original = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello world\n", "utf16le")]) - const info = Encoding.detect(original) - const text = Encoding.decode(original, info) - const result = Encoding.encode(text, info) - expect(result).toEqual(original) - }) - - test("plain UTF-8 round-trips", () => { - const original = Buffer.from("hello world\n", "utf-8") - const info = Encoding.detect(original) - const text = Encoding.decode(original, info) - const result = Encoding.encode(text, info) - expect(result).toEqual(original) - }) - - test("Shift-JIS round-trips", () => { - const text = "日本語テスト\n" - const original = iconv.encode(text, "Shift_JIS") - const info = Encoding.detect(original) - expect(info.encoding).toBe("Shift_JIS") - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("EUC-JP round-trips", () => { - const text = "日本語テスト\n" - const original = iconv.encode(text, "euc-jp") - const info = Encoding.detect(original) - expect(info.encoding).toBe("euc-jp") - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("Big5 round-trips", () => { - // Longer sample required for reliable statistical detection - const text = "次常用國字標準字體表建議使用正體中文排版系統進行文件處理以維護傳統漢字文化\n" - const original = iconv.encode(text, "big5") - const info = Encoding.detect(original) - expect(info.encoding).toBe("big5") - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("GB2312 round-trips", () => { - // Longer sample required for reliable statistical detection - const text = "你好世界测试文件内容这是一个很长的中文文本用于测试编码检测功能\n第二行也有中文内容\n" - const original = iconv.encode(text, "gb2312") - const info = Encoding.detect(original) - // jschardet detects as GB2312 which normalizes the same - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("EUC-KR round-trips", () => { - // Longer sample for reliable detection - const text = "안녕하세요 세계 프로그래밍 테스트 문자열입니다\n두번째 줄도 있습니다\n" - const original = iconv.encode(text, "euc-kr") - const info = Encoding.detect(original) - expect(info.encoding).toBe("euc-kr") - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("Windows-1251 (Cyrillic) round-trips", () => { - const text = "Привет мир\n" - const original = iconv.encode(text, "windows-1251") - const info = Encoding.detect(original) - expect(info.encoding).toBe("windows-1251") - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - - test("KOI8-R (Russian) round-trips", () => { - const text = "Привет мир\n" - const original = iconv.encode(text, "koi8-r") - const info = Encoding.detect(original) - // jschardet detects KOI8-R for this content - const decoded = Encoding.decode(original, info) - expect(decoded).toBe(text) - const result = Encoding.encode(decoded, info) - expect(result).toEqual(original) - }) - }) - - describe("file read/write", () => { - test("preserves UTF-8 BOM through file write/read cycle", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "bom.txt") - const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("line one\nline two\n", "utf-8")]) - await fs.writeFile(file, original) - - const { text, info } = await Encoding.read(file) - expect(info.encoding).toBe("utf-8") - expect(info.bom).toBe(true) - expect(text).toBe("line one\nline two\n") - - const modified = text.replace("one", "1") - await Encoding.write(file, modified, info) - - const raw = await fs.readFile(file) - expect(raw[0]).toBe(0xef) - expect(raw[1]).toBe(0xbb) - expect(raw[2]).toBe(0xbf) - expect(raw.subarray(3).toString("utf-8")).toBe("line 1\nline two\n") - }) - - test("preserves UTF-16 LE BOM through file write/read cycle", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "utf16le.txt") - const content = "hello world\n" - const original = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, "utf16le")]) - await fs.writeFile(file, original) - - const { text, info } = await Encoding.read(file) - expect(info.encoding).toBe("utf-16le") - expect(info.bom).toBe(true) - expect(text).toBe("hello world\n") - - await Encoding.write(file, text, info) - - const raw = await fs.readFile(file) - expect(raw).toEqual(original) - }) - - test("preserves Shift-JIS through file write/read cycle", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "shiftjis.txt") - const text = "日本語テスト\nconst x = 1;\n" - const original = iconv.encode(text, "Shift_JIS") - await fs.writeFile(file, original) - - const { text: decoded, info } = await Encoding.read(file) - expect(info.encoding).toBe("Shift_JIS") - expect(decoded).toBe(text) - - // Modify ASCII part and write back - const modified = decoded.replace("const x = 1", "const x = 2") - await Encoding.write(file, modified, info) - - const raw = await fs.readFile(file) - const expected = iconv.encode(modified, "Shift_JIS") - expect(raw).toEqual(expected) - }) - - test("preserves Big5 through file write/read cycle", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "big5.txt") - const text = "次常用國字標準字體表建議使用正體中文排版系統進行文件處理\n第二行正體中文\n" - const original = iconv.encode(text, "big5") - await fs.writeFile(file, original) - - const { text: decoded, info } = await Encoding.read(file) - expect(info.encoding).toBe("big5") - expect(decoded).toBe(text) - - await Encoding.write(file, decoded, info) - - const raw = await fs.readFile(file) - expect(raw).toEqual(original) - }) - - test("readSync works for UTF-8 BOM files", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "sync.txt") - const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("sync test", "utf-8")]) - await fs.writeFile(file, original) - - const { text, info } = Encoding.readSync(file) - expect(info.encoding).toBe("utf-8") - expect(info.bom).toBe(true) - expect(text).toBe("sync test") - }) - - test("creates parent directories when writing", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "nested", "dir", "file.txt") - - await Encoding.write(file, "content", Encoding.DEFAULT) - - const raw = await fs.readFile(file, "utf-8") - expect(raw).toBe("content") - }) - }) -}) From 526f6807ffacfd0729e826ba9969383ab9cd5c0a Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:40:56 +0000 Subject: [PATCH 10/70] test(cli): add integration tests for tool encoding preservation Exercise Read/Write/Edit/ApplyPatch tools directly against files in UTF-8, UTF-16 LE/BE (with BOM), Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, and KOI8-R to verify the tools decode input correctly and round-trip bytes back to the original encoding. Also ensure UTF-16 BOMs survive the write pipeline. --- packages/opencode/src/kilocode/encoding.ts | 6 + .../test/kilocode/tool-encoding.test.ts | 306 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 packages/opencode/test/kilocode/tool-encoding.test.ts diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index b40aecc0c4..eed7b00400 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -88,6 +88,12 @@ export namespace Encoding { } export function encode(text: string, encoding: string): Buffer { + // iconv-lite's utf-16le/utf-16be do not emit a BOM, but UTF-16 without a + // BOM is unsupported in this codebase. Prepend the appropriate BOM so the + // next detection pass can still recognise the encoding. + const lower = encoding.toLowerCase() + if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) + if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) return iconv.encode(text, encoding) } diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts new file mode 100644 index 0000000000..b9c318c777 --- /dev/null +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -0,0 +1,306 @@ +// kilocode_change - new file +// Integration tests verifying that the agent file tools (read, write, edit, +// apply_patch) detect and preserve the original encoding of files on disk. +// Tests exercise the real tool pipeline rather than the Encoding helper +// directly so we validate end-to-end behaviour. + +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import fs from "fs/promises" +import iconv from "iconv-lite" +import { Agent } from "../../src/agent/agent" +import { AppFileSystem } from "../../src/filesystem" +import { ApplyPatchTool } from "../../src/tool/apply_patch" +import { Bus } from "../../src/bus" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { EditTool } from "../../src/tool/edit" +import { FileTime } from "../../src/file/time" +import { Format } from "../../src/format" +import { Instance } from "../../src/project/instance" +import { Instruction } from "../../src/session/instruction" +import { LSP } from "../../src/lsp" +import { MessageID, SessionID } from "../../src/session/schema" +import { ReadTool } from "../../src/tool/read" +import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" +import { WriteTool } from "../../src/tool/write" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const ctx = { + sessionID: SessionID.make("ses_test-encoding"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + FileTime.defaultLayer, + Instruction.defaultLayer, + LSP.defaultLayer, + Bus.layer, + Format.defaultLayer, + Truncate.defaultLayer, + ), +) + +const runRead = (args: Tool.InferParameters) => + Effect.gen(function* () { + const info = yield* ReadTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) + }) + +const runWrite = (args: Tool.InferParameters) => + Effect.gen(function* () { + const info = yield* WriteTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) + }) + +const runEdit = (args: Tool.InferParameters) => + Effect.gen(function* () { + const info = yield* EditTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) + }) + +const runPatch = (args: Tool.InferParameters) => + Effect.gen(function* () { + const info = yield* ApplyPatchTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) + }) + +const markRead = (filepath: string) => + Effect.gen(function* () { + const ft = yield* FileTime.Service + yield* ft.read(ctx.sessionID, filepath) + }) + +// iconv-lite's utf-16le/utf-16be do not emit a BOM on their own, but this +// codebase only supports UTF-16 with BOM. Prepend one for fixture files. +const encodeBytes = (text: string, encoding: string): Buffer => { + const lower = encoding.toLowerCase() + if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) + if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) + return iconv.encode(text, encoding) +} + +// Create a file with the given encoding by writing raw bytes. +const putEncoded = (filepath: string, text: string, encoding: string) => + Effect.promise(async () => { + await fs.mkdir(path.dirname(filepath), { recursive: true }) + await fs.writeFile(filepath, encodeBytes(text, encoding)) + }) + +const loadDecoded = (filepath: string, encoding: string) => + Effect.promise(async () => { + const bytes = await fs.readFile(filepath) + return iconv.decode(bytes, encoding) + }) + +const loadBytes = (filepath: string) => Effect.promise(() => fs.readFile(filepath)) + +// Sample phrases chosen to exercise each encoding's characteristic byte patterns. +const samples = { + utf8: "Hello, world! — £100", + shiftJis: "こんにちは、世界!日本語のテストです。", + eucJp: "日本語のEUC-JPテスト文字列です。", + gb2312: "你好,世界!这是简体中文测试。", + big5: "你好,世界!這是繁體中文測試。", + eucKr: "안녕하세요, 세계! 한국어 테스트입니다.", + windows1251: "Привет, мир! Это тест кириллицы.", + koi8r: "Привет, мир! КОИ-8 Р тест.", + latin1: "Caf\u00e9 na\u00efve r\u00e9sum\u00e9 — \u00a3100", +} + +describe("tool encoding preservation", () => { + describe("ReadTool decodes files with non-UTF-8 encodings", () => { + const cases: Array<[string, string, string]> = [ + ["UTF-8", "utf-8", samples.utf8], + ["UTF-16 LE with BOM", "utf-16le", samples.utf8], + ["UTF-16 BE with BOM", "utf-16be", samples.utf8], + ["Shift_JIS", "Shift_JIS", samples.shiftJis], + ["EUC-JP", "euc-jp", samples.eucJp], + ["GB2312", "gb2312", samples.gb2312], + ["Big5", "big5", samples.big5], + ["EUC-KR", "euc-kr", samples.eucKr], + ["Windows-1251", "windows-1251", samples.windows1251], + ["KOI8-R", "koi8-r", samples.koi8r], + ] + + for (const [label, encoding, text] of cases) { + it.live(`decodes ${label} content for the model`, () => + providEncoded(encoding, text, (filepath) => + Effect.gen(function* () { + const result = yield* runRead({ filePath: filepath }) + expect(result.output).toContain(text) + }), + ), + ) + } + }) + + describe("ReadTool does not flag non-Latin text files as binary", () => { + it.live("accepts Shift_JIS", () => + providEncoded("Shift_JIS", samples.shiftJis, (filepath) => + Effect.gen(function* () { + const result = yield* runRead({ filePath: filepath }) + expect(result.output).toContain(samples.shiftJis) + }), + ), + ) + + it.live("accepts UTF-16 LE with BOM (contains NUL bytes)", () => + providEncoded("utf-16le", samples.utf8, (filepath) => + Effect.gen(function* () { + const result = yield* runRead({ filePath: filepath }) + expect(result.output).toContain(samples.utf8) + }), + ), + ) + }) + + describe("WriteTool preserves existing file encoding when overwriting", () => { + const cases: Array<[string, string, string]> = [ + ["Shift_JIS", "Shift_JIS", samples.shiftJis], + ["GB2312", "gb2312", samples.gb2312], + ["Windows-1251", "windows-1251", samples.windows1251], + ["UTF-16 LE", "utf-16le", samples.utf8], + ] + + for (const [label, encoding, original] of cases) { + it.live(`preserves ${label} encoding on overwrite`, () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "file.txt") + yield* putEncoded(filepath, original, encoding) + yield* markRead(filepath) + + const replacement = original + " updated" + yield* runWrite({ filePath: filepath, content: replacement }) + + const decoded = yield* loadDecoded(filepath, encoding) + expect(decoded).toBe(replacement) + + // Bytes should still match the original encoding (and differ from UTF-8). + const bytes = yield* loadBytes(filepath) + expect(bytes.equals(encodeBytes(replacement, encoding))).toBe(true) + }), + ), + ) + } + + it.live("defaults new files to UTF-8", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "new.txt") + yield* runWrite({ filePath: filepath, content: samples.utf8 }) + + const bytes = yield* loadBytes(filepath) + expect(bytes.equals(Buffer.from(samples.utf8, "utf-8"))).toBe(true) + }), + ), + ) + }) + + describe("EditTool preserves existing file encoding across edits", () => { + const cases: Array<[string, string, string, string, string]> = [ + ["Shift_JIS", "Shift_JIS", samples.shiftJis, "日本語", "ニホンゴ"], + ["GB2312", "gb2312", samples.gb2312, "简体中文", "中文简体"], + ["Windows-1251", "windows-1251", samples.windows1251, "мир", "планета"], + ["UTF-16 LE", "utf-16le", samples.utf8 + "\n second line", "world", "earth"], + ] + + for (const [label, encoding, original, oldString, newString] of cases) { + it.live(`preserves ${label} through edit`, () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "doc.txt") + yield* putEncoded(filepath, original, encoding) + yield* markRead(filepath) + + yield* runEdit({ filePath: filepath, oldString, newString }) + + const decoded = yield* loadDecoded(filepath, encoding) + const expected = original.replace(oldString, newString) + expect(decoded).toBe(expected) + + const bytes = yield* loadBytes(filepath) + expect(bytes.equals(encodeBytes(expected, encoding))).toBe(true) + }), + ), + ) + } + }) + + describe("ApplyPatchTool preserves encoding", () => { + it.live("preserves Shift_JIS through an update hunk", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "doc.txt") + const original = "line1\n" + samples.shiftJis + "\nline3\n" + yield* putEncoded(filepath, original, "Shift_JIS") + + const patch = [ + "*** Begin Patch", + "*** Update File: doc.txt", + "@@", + " line1", + "-" + samples.shiftJis, + "+" + samples.eucJp.replace(/[^\u3000-\u30ff\u4e00-\u9fff]/g, ""), + " line3", + "*** End Patch", + ].join("\n") + + yield* runPatch({ patchText: patch }) + + const decoded = yield* loadDecoded(filepath, "Shift_JIS") + expect(decoded).toContain("line1") + expect(decoded).toContain("line3") + + // File must still decode as Shift_JIS — UTF-8 bytes would mojibake here. + const bytes = yield* loadBytes(filepath) + expect(bytes.includes(Buffer.from([0xe3, 0x81]))).toBe(false) + }), + ), + ) + + it.live("new files added via apply_patch are UTF-8", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const patch = ["*** Begin Patch", "*** Add File: new.txt", "+hello world", "*** End Patch"].join("\n") + yield* runPatch({ patchText: patch }) + const bytes = yield* loadBytes(path.join(dir, "new.txt")) + expect(bytes.equals(Buffer.from("hello world\n", "utf-8"))).toBe(true) + }), + ), + ) + }) +}) + +// Shared helper to set up a temp instance with an encoded file at `file.txt`. +function providEncoded(encoding: string, text: string, body: (filepath: string) => Effect.Effect) { + return provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "file.txt") + yield* putEncoded(filepath, text, encoding) + yield* markRead(filepath) + return yield* body(filepath) + }), + ) +} From 010a94698e449bdd9270f44e53aa209dd4c7a248 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:41:26 +0000 Subject: [PATCH 11/70] chore: add changeset for encoding preservation --- .changeset/preserve-file-encoding.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/preserve-file-encoding.md diff --git a/.changeset/preserve-file-encoding.md b/.changeset/preserve-file-encoding.md new file mode 100644 index 0000000000..a454443b56 --- /dev/null +++ b/.changeset/preserve-file-encoding.md @@ -0,0 +1,16 @@ +--- +"@kilocode/cli": minor +--- + +Preserve the original text encoding when reading and editing files. The read, edit, write, and apply_patch tools now detect each file's encoding and write it back unchanged, so non-UTF-8 source trees no longer get corrupted when the agent touches them. + +Supported: + +- UTF-8 +- UTF-16 with BOM +- Legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859 family, and others) + +Not supported: + +- UTF-16 without BOM +- UTF-32 From ce80c09356d00d9bff440cbd05d237f440faf014 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:54:26 +0000 Subject: [PATCH 12/70] wip --- .kilo/package-lock.json | 31 +++++++++++++++++++++++++++++++ .kilocode/package-lock.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .kilo/package-lock.json create mode 100644 .kilocode/package-lock.json diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 0000000000..7013f0cbd7 --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "*" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.1.23", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.1.23", + "zod": "4.1.8" + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.1.23", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json new file mode 100644 index 0000000000..a83a334755 --- /dev/null +++ b/.kilocode/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".kilocode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "*" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.1.23", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.1.23", + "zod": "4.1.8" + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.1.23", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} From a26b4c34b26311cc8dd9068cd14d60f5eb2bad7d Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:01:42 +0000 Subject: [PATCH 13/70] Revert "Add test build notice" This reverts commit 227af07f29fe6a468bba3301f850bbc3a53f05eb. --- .../src/components/settings/AboutKiloCodeTab.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx index f19c08c3fa..abd9e2943d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx @@ -170,22 +170,6 @@ const AboutKiloCodeTab: Component = (props) => { return (
- {/* Test Build Notice */} -
- This is a test build with experimental text encoding detection. Tested with EUC, GBR, Shift-JIS. -
- {/* Version Information */}

{language.t("settings.aboutKiloCode.versionInfo")}

From 25bec4bf2b9c1cd521209cace11b944eda6169e5 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:01:47 +0000 Subject: [PATCH 14/70] Revert "wip" This reverts commit ce80c09356d00d9bff440cbd05d237f440faf014. --- .kilo/package-lock.json | 31 ------------------------------- .kilocode/package-lock.json | 31 ------------------------------- 2 files changed, 62 deletions(-) delete mode 100644 .kilo/package-lock.json delete mode 100644 .kilocode/package-lock.json diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json deleted file mode 100644 index 7013f0cbd7..0000000000 --- a/.kilo/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".kilo", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "*" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.1.23", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.1.23", - "zod": "4.1.8" - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.1.23", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json deleted file mode 100644 index a83a334755..0000000000 --- a/.kilocode/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".kilocode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "*" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.1.23", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.1.23", - "zod": "4.1.8" - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.1.23", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} From fa08250d73b2b3d66851622b59481437c71ecd5d Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:15:30 +0000 Subject: [PATCH 15/70] refactor(cli): restore streaming line read and tidy markers - Restore the streaming createInterface loop in the `lines` helper so the diff against main stays small; decode bytes with iconv-lite up front and feed the resulting text through Readable.from. - Drop 'new file' kilocode_change markers from files under kilocode/ directories (those paths are exempt from the annotation check). - Tighten the changeset copy to sound like a user-facing release note. --- .changeset/preserve-file-encoding.md | 13 +---- packages/opencode/src/kilocode/encoding.ts | 1 - .../opencode/src/kilocode/tool/encoded-io.ts | 1 - packages/opencode/src/tool/read.ts | 57 +++++++++++-------- .../test/kilocode/tool-encoding.test.ts | 1 - 5 files changed, 36 insertions(+), 37 deletions(-) diff --git a/.changeset/preserve-file-encoding.md b/.changeset/preserve-file-encoding.md index a454443b56..28221dde06 100644 --- a/.changeset/preserve-file-encoding.md +++ b/.changeset/preserve-file-encoding.md @@ -2,15 +2,8 @@ "@kilocode/cli": minor --- -Preserve the original text encoding when reading and editing files. The read, edit, write, and apply_patch tools now detect each file's encoding and write it back unchanged, so non-UTF-8 source trees no longer get corrupted when the agent touches them. +Detect and preserve the original text encoding of files when the agent reads or edits them. Source trees in Japanese, Chinese, Korean, Cyrillic, or Western European encodings no longer get mangled when Kilo touches them, and non-UTF-8 files are displayed correctly to the model instead of as garbled text. -Supported: +Supported: UTF-8, UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). -- UTF-8 -- UTF-16 with BOM -- Legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859 family, and others) - -Not supported: - -- UTF-16 without BOM -- UTF-32 +Not supported: UTF-16 without BOM, UTF-32. diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index eed7b00400..3b5a17b7f5 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { readFile, writeFile, mkdir } from "fs/promises" import { readFileSync } from "fs" import { dirname } from "path" diff --git a/packages/opencode/src/kilocode/tool/encoded-io.ts b/packages/opencode/src/kilocode/tool/encoded-io.ts index dab6e26e25..8aa6569235 100644 --- a/packages/opencode/src/kilocode/tool/encoded-io.ts +++ b/packages/opencode/src/kilocode/tool/encoded-io.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { Effect } from "effect" import { Encoding } from "../encoding" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index e01f87af73..e00c5d7bf3 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -2,6 +2,8 @@ import z from "zod" import { Effect, Scope } from "effect" import { open } from "fs/promises" import * as path from "path" +import { Readable } from "stream" // kilocode_change +import { createInterface } from "readline" import { Tool } from "./tool" import { AppFileSystem } from "../filesystem" import { LSP } from "../lsp" @@ -233,45 +235,53 @@ export const ReadTool = Tool.define( }), ) -// kilocode_change start - encoding-aware file reading +// kilocode_change start export async function lines(filepath: string, opts: { limit: number; offset: number }) { const encoded = await Encoding.read(filepath) - const all = encoded.text.split(/\r\n|\r|\n/) - // Remove trailing empty element from split when file ends with newline - if (all.length > 0 && all[all.length - 1] === "") all.pop() + const rl = createInterface({ + input: Readable.from([encoded.text]), + // Note: we use the crlfDelay option to recognize all instances of CR LF + // ('\r\n') in file as a single line break. + crlfDelay: Infinity, + }) const start = opts.offset - 1 const raw: string[] = [] let bytes = 0 + let count = 0 let cut = false let more = false + try { + for await (const text of rl) { + count += 1 + if (count <= start) continue - for (let i = start; i < all.length; i++) { - if (raw.length >= opts.limit) { - more = true - break + if (raw.length >= opts.limit) { + more = true + continue + } + + const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (bytes + size > MAX_BYTES) { + cut = true + more = true + break + } + + raw.push(line) + bytes += size } - - const text = all[i]! - const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text - const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) - if (bytes + size > MAX_BYTES) { - cut = true - more = true - break - } - - raw.push(line) - bytes += size + } finally { + rl.close() } - return { raw, count: all.length, cut, more, offset: opts.offset } + return { raw, count, cut, more, offset: opts.offset } } // kilocode_change end // kilocode_change start export async function isBinaryFile(filepath: string, fileSize: number): Promise { - // kilocode_change end const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { @@ -317,7 +327,6 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< const result = await fh.read(bytes, 0, sampleSize, 0) if (result.bytesRead === 0) return false - // kilocode_change start - encoding-aware binary detection // If encoding detection identifies a known text encoding (including UTF-16 // LE/BE with BOM or CJK), it's text — not binary. This prevents UTF-16 // files with legitimate null bytes from being falsely rejected. @@ -334,8 +343,8 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< } // If >30% non-printable characters, consider it binary return nonPrintableCount / result.bytesRead > 0.3 - // kilocode_change end } finally { await fh.close() } } +// kilocode_change end diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index b9c318c777..c1324cd567 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file // Integration tests verifying that the agent file tools (read, write, edit, // apply_patch) detect and preserve the original encoding of files on disk. // Tests exercise the real tool pipeline rather than the Encoding helper From 0cef72517c1883b83472d7542e6229739a3b17fa Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:16:03 +0000 Subject: [PATCH 16/70] wip --- .kilo/package-lock.json | 31 +++++++++++++++++++++++++++++++ .kilocode/package-lock.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .kilo/package-lock.json create mode 100644 .kilocode/package-lock.json diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 0000000000..7013f0cbd7 --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "*" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.1.23", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.1.23", + "zod": "4.1.8" + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.1.23", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json new file mode 100644 index 0000000000..a83a334755 --- /dev/null +++ b/.kilocode/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".kilocode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "*" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.1.23", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.1.23", + "zod": "4.1.8" + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.1.23", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} From 5538fc9c3299f63fc5e0d43aa5db5de85128cb42 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:32:48 +0000 Subject: [PATCH 17/70] feat(cli): preserve UTF-8 BOM through read/write round-trip iconv-lite's utf-8 codec strips any leading BOM on decode and never emits one on encode, so files that started with EF BB BF would lose their BOM after an edit. Track UTF-8-with-BOM as a distinct synthetic encoding label and re-prepend the BOM bytes on write so the round-trip keeps the file byte-identical (modulo any actual edits). --- .changeset/preserve-file-encoding.md | 4 +-- packages/opencode/src/kilocode/encoding.ts | 31 ++++++++++++++----- .../test/kilocode/tool-encoding.test.ts | 14 +++++++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.changeset/preserve-file-encoding.md b/.changeset/preserve-file-encoding.md index 28221dde06..034e6540f3 100644 --- a/.changeset/preserve-file-encoding.md +++ b/.changeset/preserve-file-encoding.md @@ -2,8 +2,8 @@ "@kilocode/cli": minor --- -Detect and preserve the original text encoding of files when the agent reads or edits them. Source trees in Japanese, Chinese, Korean, Cyrillic, or Western European encodings no longer get mangled when Kilo touches them, and non-UTF-8 files are displayed correctly to the model instead of as garbled text. +The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. -Supported: UTF-8, UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). +Supported: UTF-8 (with or without BOM), UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). Not supported: UTF-16 without BOM, UTF-32. diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index 3b5a17b7f5..d4c21e5678 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -17,13 +17,26 @@ import iconv from "iconv-lite" * - UTF-32 (extremely rare) * * Detection strategy: - * 1. If the bytes are valid UTF-8, treat as UTF-8. - * 2. Otherwise, trust jschardet. iconv-lite handles BOM stripping on decode - * and BOM emission on encode for UTF-16 LE/BE, so explicit BOM handling - * is unnecessary. + * 1. If the bytes are valid UTF-8, treat as UTF-8 (tracking the presence of a + * BOM so it can be written back). + * 2. Otherwise, trust jschardet. + * + * iconv-lite's UTF codecs strip BOMs on decode and do not emit them on encode, + * so UTF BOMs are handled explicitly in {@link encode} to round-trip cleanly. */ export namespace Encoding { export const DEFAULT = "utf-8" + /** + * Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8 + * codec always strips BOMs on decode and never emits one on encode, so we + * track the "with BOM" case explicitly to round-trip it faithfully. + */ + export const UTF8_BOM = "utf-8-bom" + const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf]) + + function hasUtf8Bom(bytes: Buffer): boolean { + return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf + } /** Remap jschardet labels to iconv-lite compatible names. */ function normalize(name: string): string { @@ -72,7 +85,7 @@ export namespace Encoding { export function detect(bytes: Buffer): string { if (bytes.length === 0) return DEFAULT - if (isUtf8(bytes)) return DEFAULT + if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT const result = jschardet.detect(bytes) if (!result.encoding) return DEFAULT const enc = normalize(result.encoding) @@ -83,13 +96,15 @@ export namespace Encoding { } export function decode(bytes: Buffer, encoding: string): string { + if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8") return iconv.decode(bytes, encoding) } export function encode(text: string, encoding: string): Buffer { - // iconv-lite's utf-16le/utf-16be do not emit a BOM, but UTF-16 without a - // BOM is unsupported in this codebase. Prepend the appropriate BOM so the - // next detection pass can still recognise the encoding. + // iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM" + // as a distinct variant. Prepend the BOM manually so round-tripping keeps + // the original byte signature intact. + if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(text, "utf-8")]) const lower = encoding.toLowerCase() if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index c1324cd567..e05d8ff4ff 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -90,9 +90,12 @@ const markRead = (filepath: string) => yield* ft.read(ctx.sessionID, filepath) }) -// iconv-lite's utf-16le/utf-16be do not emit a BOM on their own, but this -// codebase only supports UTF-16 with BOM. Prepend one for fixture files. +// iconv-lite's UTF codecs don't emit BOMs, but this codebase supports +// "UTF-X with BOM" as a distinct variant. Prepend one here for fixture files +// that are meant to have one. +const UTF8_BOM = "utf-8-bom" const encodeBytes = (text: string, encoding: string): Buffer => { + if (encoding === UTF8_BOM) return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), iconv.encode(text, "utf-8")]) const lower = encoding.toLowerCase() if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) @@ -109,6 +112,10 @@ const putEncoded = (filepath: string, text: string, encoding: string) => const loadDecoded = (filepath: string, encoding: string) => Effect.promise(async () => { const bytes = await fs.readFile(filepath) + if (encoding === UTF8_BOM) { + const stripped = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf + return iconv.decode(stripped ? bytes.subarray(3) : bytes, "utf-8") + } return iconv.decode(bytes, encoding) }) @@ -131,6 +138,7 @@ describe("tool encoding preservation", () => { describe("ReadTool decodes files with non-UTF-8 encodings", () => { const cases: Array<[string, string, string]> = [ ["UTF-8", "utf-8", samples.utf8], + ["UTF-8 with BOM", UTF8_BOM, samples.utf8], ["UTF-16 LE with BOM", "utf-16le", samples.utf8], ["UTF-16 BE with BOM", "utf-16be", samples.utf8], ["Shift_JIS", "Shift_JIS", samples.shiftJis], @@ -176,6 +184,7 @@ describe("tool encoding preservation", () => { describe("WriteTool preserves existing file encoding when overwriting", () => { const cases: Array<[string, string, string]> = [ + ["UTF-8 with BOM", UTF8_BOM, samples.utf8], ["Shift_JIS", "Shift_JIS", samples.shiftJis], ["GB2312", "gb2312", samples.gb2312], ["Windows-1251", "windows-1251", samples.windows1251], @@ -219,6 +228,7 @@ describe("tool encoding preservation", () => { describe("EditTool preserves existing file encoding across edits", () => { const cases: Array<[string, string, string, string, string]> = [ + ["UTF-8 with BOM", UTF8_BOM, samples.utf8 + "\n second line", "world", "earth"], ["Shift_JIS", "Shift_JIS", samples.shiftJis, "日本語", "ニホンゴ"], ["GB2312", "gb2312", samples.gb2312, "简体中文", "中文简体"], ["Windows-1251", "windows-1251", samples.windows1251, "мир", "планета"], From 6f55a3061d75a81454d993a43fe82f6eafeca3d4 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:04:35 +0000 Subject: [PATCH 18/70] refactor(cli): restore stream.destroy and keep original comments - Wire Readable.from through a named `stream` variable so the `lines` helper can destroy it in the finally block, matching the previous createReadStream pattern. - Restore the 'Create parent directories', 'Handle file move', 'Regular update', 'Read original file content', and 'For delete, we need to read the current content' comments that were dropped when switching to Encoding.write / Encoding.read. The explicit fs.mkdir calls are no longer needed because Encoding.write mkdirs recursively, but the intent comments still apply. --- packages/opencode/src/patch/index.ts | 20 +++++++++++++------- packages/opencode/src/tool/apply_patch.ts | 10 +++++++--- packages/opencode/src/tool/read.ts | 4 +++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index c116df0f94..3e6ab4bb1a 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -311,6 +311,7 @@ export namespace Patch { export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // kilocode_change start - encoding-aware read + // Read original file content let originalContent: string let encoding: string try { @@ -342,11 +343,13 @@ export namespace Patch { // Generate unified diff const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + // kilocode_change start - include detected encoding for round-trip write return { unified_diff: unifiedDiff, content: newContent, - encoding, // kilocode_change + encoding, } + // kilocode_change end } function computeReplacements( @@ -530,10 +533,12 @@ export namespace Patch { const modified: string[] = [] const deleted: string[] = [] + // kilocode_change start - encoding-aware writes (Encoding.write mkdirs recursively) for (const hunk of hunks) { switch (hunk.type) { case "add": - await Encoding.write(hunk.path, hunk.contents) // kilocode_change - encoding-aware write + // Create parent directories + await Encoding.write(hunk.path, hunk.contents) added.push(hunk.path) log.info(`Added file: ${hunk.path}`) break @@ -548,20 +553,21 @@ export namespace Patch { const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) if (hunk.move_path) { - // kilocode_change start - encoding-aware move + // Handle file move await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) await fs.unlink(hunk.path) - // kilocode_change end modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change + // Regular update + await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } break } } + // kilocode_change end return { added, modified, deleted } } @@ -608,6 +614,7 @@ export namespace Patch { hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, ) + // kilocode_change start - encoding-aware read for delete hunks switch (hunk.type) { case "add": changes.set(resolvedPath, { @@ -620,19 +627,18 @@ export namespace Patch { // For delete, we need to read the current content const deletePath = path.resolve(effectiveCwd, hunk.path) try { - // kilocode_change start - encoding-aware read const result = await Encoding.read(deletePath) changes.set(resolvedPath, { type: "delete", content: result.text, }) - // kilocode_change end } catch (error) { return { type: MaybeApplyPatchVerified.CorrectnessError, error: new Error(`Failed to read file for deletion: ${deletePath}`), } } + // kilocode_change end break case "update": diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index be1cbbb9f3..823359e8f0 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -208,22 +208,25 @@ export const ApplyPatchTool = Tool.define( // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] + // kilocode_change start - encoding-aware writes (EncodedIO.write mkdirs recursively) for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) switch (change.type) { case "add": - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change + // Create parent directories (recursive: true is safe on existing/root dirs) + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { - yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change + // Create parent directories (recursive: true is safe on existing/root dirs) + yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -235,6 +238,7 @@ export const ApplyPatchTool = Tool.define( updates.push({ file: change.filePath, event: "unlink" }) break } + // kilocode_change end if (edited) { yield* format.file(edited) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index e00c5d7bf3..471508fe63 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -238,8 +238,9 @@ export const ReadTool = Tool.define( // kilocode_change start export async function lines(filepath: string, opts: { limit: number; offset: number }) { const encoded = await Encoding.read(filepath) + const stream = Readable.from([encoded.text]) const rl = createInterface({ - input: Readable.from([encoded.text]), + input: stream, // Note: we use the crlfDelay option to recognize all instances of CR LF // ('\r\n') in file as a single line break. crlfDelay: Infinity, @@ -274,6 +275,7 @@ export async function lines(filepath: string, opts: { limit: number; offset: num } } finally { rl.close() + stream.destroy() } return { raw, count, cut, more, offset: opts.offset } From 0bda9d15ed5ef99fe149fd680a813ca3b4c1d050 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 21 Apr 2026 19:17:02 +0300 Subject: [PATCH 19/70] fix(cli): restore mid-turn injection --- .changeset/restore-hot-inject.md | 5 ++ packages/opencode/src/session/prompt.ts | 9 ++- .../kilocode/prompt-dismiss-contract.test.ts | 8 +- .../kilocode/session-prompt-queue.test.ts | 80 +++++++++---------- 4 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 .changeset/restore-hot-inject.md diff --git a/.changeset/restore-hot-inject.md b/.changeset/restore-hot-inject.md new file mode 100644 index 0000000000..0a27ad7dc7 --- /dev/null +++ b/.changeset/restore-hot-inject.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Restore mid-turn message injection. Sending a new message while the agent is running now cancels the current turn and processes the new message immediately. Pending review suggestions are dismissed automatically so a new prompt after a review is never stuck behind a showing suggestion. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b1520da2a..a0b4c80052 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1307,9 +1307,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (input.noReply === true) return message - // kilocode_change start — dismiss pending suggestions so a previous loop - // blocked on a suggestion can settle before the queue runs the next prompt + // kilocode_change start — hot-inject semantics: cancel any in-flight loop + // and drop any queued follow-ups so the new prompt runs immediately. + // Dismissing pending suggestions also unblocks the in-flight loop if it was + // waiting on Suggestion.show() — the suggest tool's abort listener then + // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) + yield* KiloSessionPromptQueue.cancel(input.sessionID) + yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( input.sessionID, diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 0ce893e44c..c0db325701 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -23,12 +23,12 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll runs before the prompt queue enqueues the new loop", () => { + test("dismissAll and cancel run before the prompt queue enqueues the new loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede KiloSessionPromptQueue.enqueue so a previous loop - // blocked on a suggestion can settle before the queue starts the next prompt. + // dismissAll must precede queue/state cancellation so a previous loop blocked + // on a suggestion can settle before the replacement prompt restarts the loop. const block = content.match( - /kilocode_change start[^\n]*dismiss[\s\S]*?Suggestion\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.cancel\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index ee35913122..8c47327552 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -221,11 +221,10 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) - test("continues a queued prompt after the active run finishes", async () => { + test("cancels the in-flight turn when a new prompt arrives", async () => { const ready = Promise.withResolvers() - const release = Promise.withResolvers() + const injected = Promise.withResolvers() const calls: number[] = [] - const replies = ["first reply", "second reply", "third reply"] const server = Bun.serve({ port: 0, fetch(req) { @@ -235,8 +234,8 @@ describe("session prompt queue", () => { calls.push(Date.now()) const body = calls.length === 1 - ? reply({ text: replies[0], ready: ready.resolve, wait: release.promise }) - : reply({ text: replies[calls.length - 1] ?? "extra reply" }) + ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + : reply({ text: "second reply", ready: injected.resolve }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -288,43 +287,40 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) - const third = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "third prompt" }], - }) - await Bun.sleep(20) - expect(calls).toHaveLength(1) - const queued = await Session.messages({ sessionID: session.id }) - expect(queued.filter((msg) => msg.info.role === "user")).toHaveLength(3) - expect(queued.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + await injected.promise + expect(calls).toHaveLength(2) - release.resolve() - await first + const one = await first const two = await second - const three = await third + expect(one.info.role).toBe("assistant") expect(hasText(two, "second reply")).toBe(true) - expect(hasText(three, "third reply")).toBe(true) - expect(calls).toHaveLength(3) + expect(calls).toHaveLength(2) const msgs = await Session.messages({ sessionID: session.id }) const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") + const prompts = users.flatMap((msg) => + msg.parts.filter((part) => part.type === "text").map((part) => part.text), + ) const text = assistants.flatMap((msg) => msg.parts.filter((part) => part.type === "text").map((part) => part.text), ) - expect(users).toHaveLength(3) - expect(assistants).toHaveLength(3) - expect(text).toContain("first reply") + expect(users).toHaveLength(2) + expect(prompts).toContain("first prompt") + expect(prompts).toContain("second prompt") expect(text).toContain("second reply") - expect(text).toContain("third reply") - for (const [index, item] of assistants.entries()) { - const user = users[index]?.info - if (item.info.role !== "assistant" || user?.role !== "user") throw new Error("missing turn") - expect(item.info.parentID).toBe(user.id) + expect(text).not.toContain("first reply") + + const latest = assistants.find((msg) => hasText(msg, "second reply")) + const secondUser = users.find((msg) => hasText(msg, "second prompt")) + expect(latest?.info.role).toBe("assistant") + expect(secondUser?.info.role).toBe("user") + if (latest?.info.role !== "assistant" || secondUser?.info.role !== "user") { + throw new Error("missing hot-injected turn") } + expect(latest.info.parentID).toBe(secondUser.info.id) }, }) } finally { @@ -332,8 +328,9 @@ describe("session prompt queue", () => { } }) - test("cancel drops queued prompts and resets internal state", async () => { + test("cancel resets internal state after a hot-injected prompt replaces the active turn", async () => { const ready = Promise.withResolvers() + const injected = Promise.withResolvers() const calls: number[] = [] const server = Bun.serve({ port: 0, @@ -342,7 +339,10 @@ describe("session prompt queue", () => { if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) calls.push(Date.now()) - const body = reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + const body = + calls.length === 1 + ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + : reply({ text: "second reply", ready: injected.resolve, wait: new Promise(() => {}) }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -386,23 +386,21 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) - const third = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "third prompt" }], - }) - await Bun.sleep(20) - expect(calls).toHaveLength(1) + await injected.promise + expect(calls).toHaveLength(2) await SessionPrompt.cancel(session.id) - await Promise.all([first, second, third]) + const [one, two] = await Promise.all([first, second]) - expect(calls).toHaveLength(1) + expect(one.info.role).toBe("assistant") + expect(two.info.role).toBe("assistant") + expect(calls).toHaveLength(2) const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(1) - expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) + expect(users).toHaveLength(2) + expect(assistants).toHaveLength(2) // Internal state should have no lingering tail/version/target entries after the last release. const ids = await Effect.runPromise( From cce5e459bc9f01fa4b716301b4b23c0598753fbc Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:18:47 +0000 Subject: [PATCH 20/70] =?UTF-8?q?refactor(cli):=20self-review=20=E2=80=94?= =?UTF-8?q?=20tighten=20markers,=20fix=20detection=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Encoding.detect: lowercase-compare UTF-32 rejection so jschardet's uppercase 'UTF-32'/'UTF-32BE'/'UTF-32LE' labels no longer slip through (iconv-lite happens to have utf-32 codecs that would then be accepted). - Encoding.read: drop redundant Buffer.from wrap around readFile's already-Buffer return value. - EncodedIO: switch to Effect.tryPromise so I/O failures surface as typed errors that .pipe(Effect.catch(...)) can recover from; the apply_patch delete branch relies on this to translate read errors into 'apply_patch verification failed'. - Shrink kilocode_change blocks in edit.ts, write.ts, apply_patch.ts, patch/index.ts, and read.ts to per-line inline markers where each block was only wrapping 1-3 changed lines. - Collapse the 'if (exists) { let contentOld; let encoding; ... }' blocks in edit.ts and write.ts into a single ternary + destructure that keeps all three lines self-contained and each independently annotated. - Remove the unused 'latin1' sample and fix the 'providEncoded' typo in the encoding integration test file. - Strengthen the apply_patch Shift_JIS test to assert exact bytes instead of the weak 'does not contain these two UTF-8 bytes' check. - Drop the stray .kilo/ and .kilocode/ lockfiles that were re-added by an upstream 'wip' commit. --- .kilo/package-lock.json | 31 ------------------- .kilocode/package-lock.json | 31 ------------------- packages/opencode/src/kilocode/encoding.ts | 6 ++-- .../opencode/src/kilocode/tool/encoded-io.ts | 9 ++++-- packages/opencode/src/patch/index.ts | 28 ++++++----------- packages/opencode/src/tool/apply_patch.ts | 8 ++--- packages/opencode/src/tool/edit.ts | 19 ++++-------- packages/opencode/src/tool/read.ts | 13 ++++---- packages/opencode/src/tool/write.ts | 12 ++----- .../test/kilocode/tool-encoding.test.ts | 20 ++++++------ 10 files changed, 47 insertions(+), 130 deletions(-) delete mode 100644 .kilo/package-lock.json delete mode 100644 .kilocode/package-lock.json diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json deleted file mode 100644 index 7013f0cbd7..0000000000 --- a/.kilo/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".kilo", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "*" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.1.23", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.1.23", - "zod": "4.1.8" - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.1.23", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json deleted file mode 100644 index a83a334755..0000000000 --- a/.kilocode/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".kilocode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "*" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.1.23", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.1.23", - "zod": "4.1.8" - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.1.23", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index d4c21e5678..9345dd56a9 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -89,8 +89,8 @@ export namespace Encoding { const result = jschardet.detect(bytes) if (!result.encoding) return DEFAULT const enc = normalize(result.encoding) - // Reject unsupported Unicode encodings - if (enc.startsWith("utf-32")) return DEFAULT + // Reject unsupported Unicode encodings (UTF-32 and anything iconv-lite cannot decode) + if (enc.toLowerCase().startsWith("utf-32")) return DEFAULT if (!iconv.encodingExists(enc)) return DEFAULT return enc } @@ -113,7 +113,7 @@ export namespace Encoding { /** Read a file, detecting its encoding. */ export async function read(path: string): Promise<{ text: string; encoding: string }> { - const bytes = Buffer.from(await readFile(path)) + const bytes = await readFile(path) const encoding = detect(bytes) return { text: decode(bytes, encoding), encoding } } diff --git a/packages/opencode/src/kilocode/tool/encoded-io.ts b/packages/opencode/src/kilocode/tool/encoded-io.ts index 8aa6569235..19c74b4e4b 100644 --- a/packages/opencode/src/kilocode/tool/encoded-io.ts +++ b/packages/opencode/src/kilocode/tool/encoded-io.ts @@ -4,11 +4,14 @@ import { Encoding } from "../encoding" /** * Effect wrappers around {@link Encoding.read} and {@link Encoding.write} so * tool code can preserve file encoding without leaking Node/async boilerplate - * into each call site. + * into each call site. Uses {@link Effect.tryPromise} so I/O failures surface + * as typed errors that can be recovered with `.pipe(Effect.catch(...))`. */ export namespace EncodedIO { - export const read = (path: string) => Effect.promise(() => Encoding.read(path)) + const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause))) + + export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap }) export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) => - Effect.promise(() => Encoding.write(path, text, encoding)) + Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap }) } diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 3e6ab4bb1a..323ce403fc 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -310,18 +310,16 @@ export namespace Patch { } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { - // kilocode_change start - encoding-aware read // Read original file content let originalContent: string - let encoding: string + let encoding: string // kilocode_change try { - const result = Encoding.readSync(filePath) - originalContent = result.text - encoding = result.encoding + const result = Encoding.readSync(filePath) // kilocode_change - encoding-aware read + originalContent = result.text // kilocode_change + encoding = result.encoding // kilocode_change } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`) } - // kilocode_change end let originalLines = originalContent.split("\n") @@ -343,13 +341,11 @@ export namespace Patch { // Generate unified diff const unifiedDiff = generateUnifiedDiff(originalContent, newContent) - // kilocode_change start - include detected encoding for round-trip write return { unified_diff: unifiedDiff, content: newContent, - encoding, + encoding, // kilocode_change - include detected encoding for round-trip write } - // kilocode_change end } function computeReplacements( @@ -533,12 +529,11 @@ export namespace Patch { const modified: string[] = [] const deleted: string[] = [] - // kilocode_change start - encoding-aware writes (Encoding.write mkdirs recursively) for (const hunk of hunks) { switch (hunk.type) { case "add": // Create parent directories - await Encoding.write(hunk.path, hunk.contents) + await Encoding.write(hunk.path, hunk.contents) // kilocode_change - encoding-aware write (mkdirs) added.push(hunk.path) log.info(`Added file: ${hunk.path}`) break @@ -554,20 +549,19 @@ export namespace Patch { if (hunk.move_path) { // Handle file move - await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) + await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) + await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } break } } - // kilocode_change end return { added, modified, deleted } } @@ -614,7 +608,6 @@ export namespace Patch { hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, ) - // kilocode_change start - encoding-aware read for delete hunks switch (hunk.type) { case "add": changes.set(resolvedPath, { @@ -627,10 +620,10 @@ export namespace Patch { // For delete, we need to read the current content const deletePath = path.resolve(effectiveCwd, hunk.path) try { - const result = await Encoding.read(deletePath) + const result = await Encoding.read(deletePath) // kilocode_change - encoding-aware read changes.set(resolvedPath, { type: "delete", - content: result.text, + content: result.text, // kilocode_change }) } catch (error) { return { @@ -638,7 +631,6 @@ export namespace Patch { error: new Error(`Failed to read file for deletion: ${deletePath}`), } } - // kilocode_change end break case "update": diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 823359e8f0..deb5d6a6de 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -109,12 +109,10 @@ export const ApplyPatchTool = Tool.define( ) } - // kilocode_change start - preserve existing file encoding - const readResult = yield* EncodedIO.read(filePath) - const oldContent = readResult.text - let encoding = readResult.encoding + const pre = yield* EncodedIO.read(filePath) // kilocode_change - preserve file encoding + const oldContent = pre.text // kilocode_change + let encoding = pre.encoding // kilocode_change let newContent = oldContent - // kilocode_change end // Apply the update chunks to get new content try { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index b89417d1b5..5ff46065a2 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -101,14 +101,9 @@ export const EditTool = Tool.define( Effect.gen(function* () { if (params.oldString === "") { const existed = yield* afs.existsSafe(filePath) - // kilocode_change start - preserve existing file encoding - let encoding = "utf-8" - if (existed) { - const encoded = yield* EncodedIO.read(filePath) - contentOld = encoded.text - encoding = encoded.encoding - } - // kilocode_change end + const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" } // kilocode_change + contentOld = pre.text // kilocode_change + const encoding = pre.encoding // kilocode_change - preserve file encoding on write contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change @@ -137,11 +132,9 @@ export const EditTool = Tool.define( if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) yield* filetime.assert(ctx.sessionID, filePath) - // kilocode_change start - preserve existing file encoding - const encoded = yield* EncodedIO.read(filePath) - contentOld = encoded.text - const encoding = encoded.encoding - // kilocode_change end + const pre = yield* EncodedIO.read(filePath) // kilocode_change - preserve file encoding + contentOld = pre.text // kilocode_change + const encoding = pre.encoding // kilocode_change const ending = detectLineEnding(contentOld) const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 471508fe63..5d596fd4ec 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -237,8 +237,9 @@ export const ReadTool = Tool.define( // kilocode_change start export async function lines(filepath: string, opts: { limit: number; offset: number }) { - const encoded = await Encoding.read(filepath) - const stream = Readable.from([encoded.text]) + // kilocode_change end + const encoded = await Encoding.read(filepath) // kilocode_change - decode with detected encoding + const stream = Readable.from([encoded.text]) // kilocode_change - replaces createReadStream const rl = createInterface({ input: stream, // Note: we use the crlfDelay option to recognize all instances of CR LF @@ -280,10 +281,10 @@ export async function lines(filepath: string, opts: { limit: number; offset: num return { raw, count, cut, more, offset: opts.offset } } -// kilocode_change end // kilocode_change start export async function isBinaryFile(filepath: string, fileSize: number): Promise { + // kilocode_change end const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { @@ -329,12 +330,11 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< const result = await fh.read(bytes, 0, sampleSize, 0) if (result.bytesRead === 0) return false - // If encoding detection identifies a known text encoding (including UTF-16 - // LE/BE with BOM or CJK), it's text — not binary. This prevents UTF-16 - // files with legitimate null bytes from being falsely rejected. + // kilocode_change start - treat detected non-UTF-8 text (CJK, UTF-16 with BOM) as text, not binary const sample = bytes.subarray(0, result.bytesRead) const enc = Encoding.detect(sample) if (enc !== "utf-8") return false + // kilocode_change end let nonPrintableCount = 0 for (let i = 0; i < result.bytesRead; i++) { @@ -349,4 +349,3 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< await fh.close() } } -// kilocode_change end diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 289d09c872..bf1c27dfe7 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -43,15 +43,9 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - // kilocode_change start - preserve existing file encoding - let contentOld = "" - let encoding = "utf-8" - if (exists) { - const encoded = yield* EncodedIO.read(filepath) - contentOld = encoded.text - encoding = encoded.encoding - } - // kilocode_change end + const pre = exists ? yield* EncodedIO.read(filepath) : { text: "", encoding: "utf-8" } // kilocode_change + const contentOld = pre.text // kilocode_change + const encoding = pre.encoding // kilocode_change - preserve file encoding on write if (exists) yield* filetime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index e05d8ff4ff..19a8b7e4b0 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -131,7 +131,6 @@ const samples = { eucKr: "안녕하세요, 세계! 한국어 테스트입니다.", windows1251: "Привет, мир! Это тест кириллицы.", koi8r: "Привет, мир! КОИ-8 Р тест.", - latin1: "Caf\u00e9 na\u00efve r\u00e9sum\u00e9 — \u00a3100", } describe("tool encoding preservation", () => { @@ -152,7 +151,7 @@ describe("tool encoding preservation", () => { for (const [label, encoding, text] of cases) { it.live(`decodes ${label} content for the model`, () => - providEncoded(encoding, text, (filepath) => + provideEncoded(encoding, text, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(text) @@ -164,7 +163,7 @@ describe("tool encoding preservation", () => { describe("ReadTool does not flag non-Latin text files as binary", () => { it.live("accepts Shift_JIS", () => - providEncoded("Shift_JIS", samples.shiftJis, (filepath) => + provideEncoded("Shift_JIS", samples.shiftJis, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(samples.shiftJis) @@ -173,7 +172,7 @@ describe("tool encoding preservation", () => { ) it.live("accepts UTF-16 LE with BOM (contains NUL bytes)", () => - providEncoded("utf-16le", samples.utf8, (filepath) => + provideEncoded("utf-16le", samples.utf8, (filepath) => Effect.gen(function* () { const result = yield* runRead({ filePath: filepath }) expect(result.output).toContain(samples.utf8) @@ -262,7 +261,9 @@ describe("tool encoding preservation", () => { provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "doc.txt") + const replacement = "日本語" const original = "line1\n" + samples.shiftJis + "\nline3\n" + const expected = original.replace(samples.shiftJis, replacement) yield* putEncoded(filepath, original, "Shift_JIS") const patch = [ @@ -271,7 +272,7 @@ describe("tool encoding preservation", () => { "@@", " line1", "-" + samples.shiftJis, - "+" + samples.eucJp.replace(/[^\u3000-\u30ff\u4e00-\u9fff]/g, ""), + "+" + replacement, " line3", "*** End Patch", ].join("\n") @@ -279,12 +280,11 @@ describe("tool encoding preservation", () => { yield* runPatch({ patchText: patch }) const decoded = yield* loadDecoded(filepath, "Shift_JIS") - expect(decoded).toContain("line1") - expect(decoded).toContain("line3") + expect(decoded).toBe(expected) - // File must still decode as Shift_JIS — UTF-8 bytes would mojibake here. + // Bytes must still be Shift_JIS, not silently promoted to UTF-8. const bytes = yield* loadBytes(filepath) - expect(bytes.includes(Buffer.from([0xe3, 0x81]))).toBe(false) + expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true) }), ), ) @@ -303,7 +303,7 @@ describe("tool encoding preservation", () => { }) // Shared helper to set up a temp instance with an encoded file at `file.txt`. -function providEncoded(encoding: string, text: string, body: (filepath: string) => Effect.Effect) { +function provideEncoded(encoding: string, text: string, body: (filepath: string) => Effect.Effect) { return provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "file.txt") From d500a983b9b315a0bba7dc292a07c417fc94f683 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 09:53:56 +0300 Subject: [PATCH 21/70] fix(cli): reserve latest injected prompt --- .../src/kilocode/session/prompt-queue.ts | 30 ++++++- packages/opencode/src/session/prompt.ts | 2 +- .../kilocode/prompt-dismiss-contract.test.ts | 10 ++- .../kilocode/session-prompt-queue.test.ts | 89 +++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index fa520935b6..af100e9335 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -9,6 +9,12 @@ type Slot = { readonly tail: Promise } +type Reserve = { + readonly id: number + readonly version: number + readonly previous: Promise +} + type Target = { readonly base: MessageID readonly extras: ReadonlySet @@ -18,6 +24,8 @@ export namespace KiloSessionPromptQueue { const tails = new Map>() const versions = new Map() const targets = new Map() + const reserved = new Map() + let ids = 0 const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 const settle = (promise: Promise) => @@ -32,6 +40,20 @@ export namespace KiloSessionPromptQueue { }) } + export function reserve(sessionID: SessionID) { + return Effect.sync(() => { + const next = version(sessionID) + 1 + versions.set(sessionID, next) + const slot = { + id: ++ids, + version: next, + previous: tails.get(sessionID) ?? Promise.resolve(), + } satisfies Reserve + reserved.set(sessionID, slot) + return slot + }) + } + /** * Exempt an injected user message from being hidden by scope(). * Called after PlanFollowup.inject() so the injected follow-up is visible @@ -90,12 +112,16 @@ export namespace KiloSessionPromptQueue { ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { - const previous = tails.get(sessionID) ?? Promise.resolve() + const held = reserved.get(sessionID) + const same = !!held && held.previous === tails.get(sessionID) && held.version === version(sessionID) + const seed = same ? held : undefined + if (seed) reserved.delete(sessionID) + const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: version(sessionID), previous, done, tail } satisfies Slot + return { version: seed?.version ?? version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a0b4c80052..6cab88e3cb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1313,7 +1313,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // waiting on Suggestion.show() — the suggest tool's abort listener then // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* KiloSessionPromptQueue.cancel(input.sessionID) + yield* KiloSessionPromptQueue.reserve(input.sessionID) yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index c0db325701..6a7ee74ddc 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -23,12 +23,14 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll and cancel run before the prompt queue enqueues the new loop", () => { + test("dismissAll and reserve run before the prompt queue enqueues the new loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede queue/state cancellation so a previous loop blocked - // on a suggestion can settle before the replacement prompt restarts the loop. + // dismissAll must precede queue reservation/state cancellation so a previous + // loop blocked on a suggestion can settle before the replacement prompt + // restarts the loop, while still letting newer prompts supersede older + // replacements during the cancel window. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.cancel\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 8c47327552..8bc7e9e6cb 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -1,7 +1,9 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect } from "effect" +import { Bus } from "../../src/bus" import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" +import { Suggestion } from "../../src/kilocode/suggestion" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" @@ -221,6 +223,48 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) + test("retains distinct reserved versions during rapid replacement", async () => { + const sessionID = SessionID.make("session_reserve_race") + const gate = Promise.withResolvers() + const runs: string[] = [] + + const one = Effect.runPromise( + Effect.gen(function* () { + yield* KiloSessionPromptQueue.reserve(sessionID) + return yield* KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_b"), + Effect.promise(() => gate.promise).pipe(Effect.as("b")), + Effect.succeed("b-cancelled"), + ) + }), + ) + + const two = Effect.runPromise( + Effect.gen(function* () { + yield* KiloSessionPromptQueue.reserve(sessionID) + return yield* KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_c"), + Effect.sync(() => { + runs.push("c") + return "c" + }), + Effect.sync(() => { + runs.push("c-cancelled") + return "c-cancelled" + }), + ) + }), + ) + + gate.resolve() + + expect(await one).toBe("b-cancelled") + expect(await two).toBe("c") + expect(runs).toEqual(["c"]) + }) + test("cancels the in-flight turn when a new prompt arrives", async () => { const ready = Promise.withResolvers() const injected = Promise.withResolvers() @@ -418,4 +462,49 @@ describe("session prompt queue", () => { server.stop(true) } }) + + test("new prompt dismisses a pending suggestion", async () => { + const shown = Promise.withResolvers() + const dismissed = Promise.withResolvers() + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Suggestion unblock regression" }) + const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => { + if (event.properties.sessionID === session.id) shown.resolve() + }) + const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => { + if (event.properties.sessionID === session.id) dismissed.resolve() + }) + + try { + const base = Suggestion.show({ + sessionID: session.id, + text: "Run review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], + }).catch((err) => { + if (err instanceof Suggestion.DismissedError) return "dismissed" + throw err + }) + + await shown.promise + await SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }) + await dismissed.promise + + expect(await base).toBe("dismissed") + expect(await Suggestion.list()).toEqual([]) + } finally { + offShown() + offDismissed() + } + }, + }) + }) }) From c5ca65730614ca8e6548eeef5dcd70e7de2d1d6f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 13:49:26 +0300 Subject: [PATCH 22/70] fix(cli): bind reservation owner --- .../src/kilocode/session/prompt-queue.ts | 6 +- packages/opencode/src/session/prompt.ts | 3 +- .../kilocode/prompt-dismiss-contract.test.ts | 2 +- .../kilocode/session-prompt-queue.test.ts | 79 ++++++++++++------- 4 files changed, 56 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index af100e9335..cfcc6f645a 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -109,19 +109,19 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, + hold?: Reserve, ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { const held = reserved.get(sessionID) - const same = !!held && held.previous === tails.get(sessionID) && held.version === version(sessionID) - const seed = same ? held : undefined + const seed = held && hold && held.id === hold.id ? held : undefined if (seed) reserved.delete(sessionID) const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: seed?.version ?? version(sessionID), previous, done, tail } satisfies Slot + return { version: hold?.version ?? version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6cab88e3cb..26de4e6ec5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1313,7 +1313,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // waiting on Suggestion.show() — the suggest tool's abort listener then // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* KiloSessionPromptQueue.reserve(input.sessionID) + const hold = yield* KiloSessionPromptQueue.reserve(input.sessionID) yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( @@ -1321,6 +1321,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the message.info.id, loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), + hold, ) }, ) diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 6a7ee74ddc..df2d0b1e35 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -30,7 +30,7 @@ describe("prompt.ts Kilo-specific invariants", () => { // restarts the loop, while still letting newer prompts supersede older // replacements during the cancel window. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?const hold = yield\* KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?hold/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 8bc7e9e6cb..2d2ab73d2e 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -225,44 +225,65 @@ describe("session prompt queue", () => { test("retains distinct reserved versions during rapid replacement", async () => { const sessionID = SessionID.make("session_reserve_race") + const ready = Promise.withResolvers() const gate = Promise.withResolvers() const runs: string[] = [] - const one = Effect.runPromise( - Effect.gen(function* () { - yield* KiloSessionPromptQueue.reserve(sessionID) - return yield* KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_b"), - Effect.promise(() => gate.promise).pipe(Effect.as("b")), - Effect.succeed("b-cancelled"), - ) - }), + const base = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_a"), + Effect.sync(() => ready.resolve()).pipe( + Effect.flatMap(() => Effect.promise(() => gate.promise)), + Effect.as("a"), + ), + Effect.succeed("a-cancelled"), + ), ) - const two = Effect.runPromise( - Effect.gen(function* () { - yield* KiloSessionPromptQueue.reserve(sessionID) - return yield* KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_c"), - Effect.sync(() => { - runs.push("c") - return "c" - }), - Effect.sync(() => { - runs.push("c-cancelled") - return "c-cancelled" - }), - ) - }), + await ready.promise + + const one = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + const two = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_b"), + Effect.sync(() => { + runs.push("b") + return "b" + }), + Effect.sync(() => { + runs.push("b-cancelled") + return "b-cancelled" + }), + one, + ), + ) + + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_c"), + Effect.sync(() => { + runs.push("c") + return "c" + }), + Effect.sync(() => { + runs.push("c-cancelled") + return "c-cancelled" + }), + two, + ), ) gate.resolve() - expect(await one).toBe("b-cancelled") - expect(await two).toBe("c") - expect(runs).toEqual(["c"]) + expect(await base).toBe("a") + expect(await first).toBe("b-cancelled") + expect(await second).toBe("c") + expect(runs).toEqual(["b-cancelled", "c"]) }) test("cancels the in-flight turn when a new prompt arrives", async () => { From 311a73404ba095c07af54ced20f439fdaa41ab0d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 16:00:12 +0300 Subject: [PATCH 23/70] fix(cli): break after current stream instead of aborting on new prompt Replace the state.cancel-based mid-turn injection with a break-after-stream queue. When a new prompt arrives while the assistant is streaming, the current LLM step now finishes cleanly, any pending suggest/question tool auto-dismisses via Suggestion.dismissAll and the new Question.dismissAll, and runLoop breaks out before the next LLM step via KiloSessionPromptQueue.hasFollowup. Queued prompts still run in order, each getting a full turn unless a newer one arrives during it. --- .changeset/restore-hot-inject.md | 2 +- .../src/kilocode/session/prompt-queue.ts | 58 ++--- packages/opencode/src/question/index.ts | 22 +- packages/opencode/src/session/prompt.ts | 29 ++- packages/opencode/src/tool/question.ts | 24 +- .../kilocode/prompt-dismiss-contract.test.ts | 36 ++- .../kilocode/question-dismiss-all.test.ts | 108 +++++++++ .../kilocode/session-prompt-queue.test.ts | 224 ++++++++++++------ 8 files changed, 376 insertions(+), 127 deletions(-) create mode 100644 packages/opencode/test/kilocode/question-dismiss-all.test.ts diff --git a/.changeset/restore-hot-inject.md b/.changeset/restore-hot-inject.md index 0a27ad7dc7..48daa9a34a 100644 --- a/.changeset/restore-hot-inject.md +++ b/.changeset/restore-hot-inject.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Restore mid-turn message injection. Sending a new message while the agent is running now cancels the current turn and processes the new message immediately. Pending review suggestions are dismissed automatically so a new prompt after a review is never stuck behind a showing suggestion. +Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index cfcc6f645a..04df601c98 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -3,18 +3,13 @@ import { MessageV2 } from "@/session/message-v2" import { MessageID, SessionID } from "@/session/schema" type Slot = { + readonly seq: number readonly version: number readonly previous: Promise readonly done: PromiseWithResolvers readonly tail: Promise } -type Reserve = { - readonly id: number - readonly version: number - readonly previous: Promise -} - type Target = { readonly base: MessageID readonly extras: ReadonlySet @@ -24,8 +19,13 @@ export namespace KiloSessionPromptQueue { const tails = new Map>() const versions = new Map() const targets = new Map() - const reserved = new Map() - let ids = 0 + // Monotonic arrival counter per session. latest holds the seq of the most + // recently enqueued slot; activeSince snapshots latest at the moment the + // currently running slot actually started. hasFollowup returns true only when + // a newer slot was enqueued after the active one began running. + const latest = new Map() + const activeSince = new Map() + let seq = 0 const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 const settle = (promise: Promise) => @@ -40,20 +40,6 @@ export namespace KiloSessionPromptQueue { }) } - export function reserve(sessionID: SessionID) { - return Effect.sync(() => { - const next = version(sessionID) + 1 - versions.set(sessionID, next) - const slot = { - id: ++ids, - version: next, - previous: tails.get(sessionID) ?? Promise.resolve(), - } satisfies Reserve - reserved.set(sessionID, slot) - return slot - }) - } - /** * Exempt an injected user message from being hidden by scope(). * Called after PlanFollowup.inject() so the injected follow-up is visible @@ -67,6 +53,18 @@ export namespace KiloSessionPromptQueue { targets.set(sessionID, { base: current.base, extras }) } + /** + * True when a newer prompt was enqueued after the currently running slot + * began. runLoop calls this between LLM steps to break out so the next + * queued prompt can take over without starting another LLM round-trip for + * the now-superseded turn. + */ + export function hasFollowup(sessionID: SessionID): boolean { + const l = latest.get(sessionID) ?? 0 + const a = activeSince.get(sessionID) ?? 0 + return l > a + } + export function scope(sessionID: SessionID, messages: MessageV2.WithParts[]) { const target = targets.get(sessionID) if (!target) return messages @@ -109,24 +107,26 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, - hold?: Reserve, ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { - const held = reserved.get(sessionID) - const seed = held && hold && held.id === hold.id ? held : undefined - if (seed) reserved.delete(sessionID) - const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() + const mine = ++seq + latest.set(sessionID, mine) + const previous = tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: hold?.version ?? version(sessionID), previous, done, tail } satisfies Slot + return { seq: mine, version: version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( Effect.flatMap(() => { if (slot.version !== version(sessionID)) return cancelled + // Snapshot the latest seq at the moment this slot actually starts + // running. hasFollowup compares against this value so the slot only + // breaks when something newer than itself arrives. + activeSince.set(sessionID, latest.get(sessionID) ?? slot.seq) return Effect.acquireUseRelease( Effect.sync(() => { targets.set(sessionID, { base: target, extras: new Set() }) @@ -146,6 +146,8 @@ export namespace KiloSessionPromptQueue { tails.delete(sessionID) versions.delete(sessionID) targets.delete(sessionID) + latest.delete(sessionID) + activeSince.delete(sessionID) }), ) } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 6a1e5e246f..539fd1151e 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -149,6 +149,7 @@ export namespace Question { readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray }) => Effect.Effect readonly reject: (requestID: QuestionID) => Effect.Effect readonly list: () => Effect.Effect> + readonly dismissAll: (sessionID: SessionID) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Question") {} @@ -246,7 +247,25 @@ export namespace Question { return Array.from(pending.values(), (x) => x.info) }) - return Service.of({ ask, reply, reject, list }) + // kilocode_change start - dismiss every pending question on a session so a new + // prompt can unblock an in-flight tool waiting on user input. Mirrors + // Suggestion.dismissAll so both read the same way at the callsite. + const dismissAll = Effect.fn("Question.dismissAll")(function* (sessionID: SessionID) { + const pending = (yield* InstanceState.get(state)).pending + const matches = Array.from(pending.entries()).filter(([, entry]) => entry.info.sessionID === sessionID) + for (const [id, entry] of matches) { + pending.delete(id) + log.info("dismissed", { requestID: id }) + yield* bus.publish(Event.Rejected, { + sessionID: entry.info.sessionID, + requestID: entry.info.id, + }) + yield* Deferred.fail(entry.deferred, new RejectedError()) + } + }) + // kilocode_change end + + return Service.of({ ask, reply, reject, list, dismissAll }) // kilocode_change }), ) @@ -258,5 +277,6 @@ export namespace Question { export const ask = (input: Parameters[0]) => runPromise((svc) => svc.ask(input)) export const reply = (input: Parameters[0]) => runPromise((svc) => svc.reply(input)) export const reject = (requestID: QuestionID) => runPromise((svc) => svc.reject(requestID)) + export const dismissAll = (sessionID: string) => runPromise((svc) => svc.dismissAll(SessionID.make(sessionID))) // kilocode_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 26de4e6ec5..5973456554 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change +import { Question } from "@/question" // kilocode_change import z from "zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -1306,22 +1307,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) } - if (input.noReply === true) return message - // kilocode_change start — hot-inject semantics: cancel any in-flight loop - // and drop any queued follow-ups so the new prompt runs immediately. - // Dismissing pending suggestions also unblocks the in-flight loop if it was - // waiting on Suggestion.show() — the suggest tool's abort listener then - // resolves the suggestion promise on cancel. + // kilocode_change start — unblock tools waiting on user input so any in-flight + // handle.process can return. Adding a new user message is the signal that any + // pending tool prompt is superseded, so we dismiss even on the noReply path. + // Critically we never cancel the in-flight fiber here — that would abort the + // streamText call mid-tokens and cut off the assistant reply. The enqueue call + // below serializes this prompt after the current turn's current LLM step, and + // runLoop checks hasFollowup between steps to break out once it has been + // enqueued during the turn. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - const hold = yield* KiloSessionPromptQueue.reserve(input.sessionID) - yield* state.cancel(input.sessionID) + yield* Effect.promise(() => Question.dismissAll(input.sessionID)) // kilocode_change end + if (input.noReply === true) return message return yield* KiloSessionPromptQueue.enqueue( input.sessionID, message.info.id, loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), - hold, ) }, ) @@ -1583,6 +1585,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the overflow: !handle.message.finish, }) } + // kilocode_change start — break out so a newer queued prompt can take over + // instead of starting another LLM step for the now-superseded turn. The + // current handle.process has fully drained (tokens + inline tool calls) by + // the time we get here, so nothing is cut off. + if (KiloSessionPromptQueue.hasFollowup(sessionID)) { + closeReasons.set(sessionID, "interrupted") + return "break" as const + } + // kilocode_change end return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) if (outcome === "break") break diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 50e4b1c511..f3732f17ab 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -22,11 +22,25 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const answers = yield* question.ask({ - sessionID: ctx.sessionID, - questions: params.questions, - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) + // kilocode_change start - gracefully surface RejectedError (e.g. from Question.dismissAll + // when a new prompt arrives mid-question) as a "dismissed" outcome instead of turning it + // into a defect via Effect.orDie, which would kill the in-flight stream. + const answers = yield* question + .ask({ + sessionID: ctx.sessionID, + questions: params.questions, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + .pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed<"dismissed">("dismissed"))) + if (answers === "dismissed") { + const dismissed: Metadata = { answers: [] } + return { + title: "Question dismissed", + output: "User dismissed the question.", + metadata: dismissed, + } + } + // kilocode_change end const formatted = params.questions .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index df2d0b1e35..2badf38a24 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -1,9 +1,11 @@ /** * Contract test for prompt.ts Kilo-specific invariants. * - * prompt.ts is a shared upstream file. PR #8988 added Suggestion.dismissAll - * there with kilocode_change markers. An upstream merge that restructures - * the prompt handling could silently remove this call — this test catches that. + * prompt.ts is a shared upstream file. The Kilo-specific "new prompt unblocks + * pending suggestions/questions then enqueues without cancelling the in-flight + * stream" behaviour lives inside a kilocode_change block. An upstream merge + * that restructures the prompt handling could silently remove these calls — + * this test catches that. */ import { describe, test, expect } from "bun:test" @@ -18,20 +20,36 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toMatch(/import\s*\{[^}]*Suggestion[^}]*\}\s*from\s*["']@\/kilocode\/suggestion["']/) }) + test("imports Question from the question module", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + expect(content).toMatch(/import\s*\{[^}]*Question[^}]*\}\s*from\s*["']@\/question["']/) + }) + test("calls Suggestion.dismissAll before restarting the session loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll and reserve run before the prompt queue enqueues the new loop", () => { + test("dismissAll for suggestions and questions runs before enqueue, without cancelling the in-flight fiber", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede queue reservation/state cancellation so a previous - // loop blocked on a suggestion can settle before the replacement prompt - // restarts the loop, while still letting newer prompts supersede older - // replacements during the cancel window. + // dismissAll for both suggestions and questions must precede the enqueue so + // an in-flight handle.process blocked on a pending tool prompt can return. + // Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve — + // either of those would abort the running streamText mid-tokens, which was + // the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?const hold = yield\* KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?hold/, + /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() + expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/) + expect(content).not.toMatch(/KiloSessionPromptQueue\.reserve/) + }) + + test("runLoop breaks out between LLM steps when a newer prompt was enqueued", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + // hasFollowup has to be checked inside runLoop so the current handle.process + // finishes naturally (tokens + inline tool calls) and the next LLM step is + // skipped when a follow-up is already queued. + expect(content).toContain("KiloSessionPromptQueue.hasFollowup(sessionID)") }) }) diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts new file mode 100644 index 0000000000..acf5a835a0 --- /dev/null +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Question } from "../../src/question" +import { SessionID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" + +describe("Question.dismissAll", () => { + test("rejects pending asks for the target session and clears them", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sesA = SessionID.make("ses_a") + const sesB = SessionID.make("ses_b") + + const a1 = Question.ask({ + sessionID: sesA, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + const a2 = Question.ask({ + sessionID: sesA, + questions: [ + { + header: "Retry?", + question: "Try again?", + options: [ + { label: "Retry", description: "Retry" }, + { label: "Cancel", description: "Cancel" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + const b1 = Question.ask({ + sessionID: sesB, + questions: [ + { + header: "Deploy?", + question: "Deploy now?", + options: [ + { label: "Ship", description: "Ship" }, + { label: "Wait", description: "Wait" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected-b" + throw err + }) + + // Wait for all three asks to register so we can dismiss them. + for (let i = 0; i < 50; i++) { + if ((await Question.list()).length >= 3) break + await Bun.sleep(10) + } + expect(await Question.list()).toHaveLength(3) + + // Track whether B's promise settles. + let settled = false + b1.then(() => { + settled = true + }) + + await Question.dismissAll("ses_a") + + expect(await a1).toBe("rejected") + expect(await a2).toBe("rejected") + + await new Promise((r) => setTimeout(r, 10)) + expect(settled).toBe(false) + + const remaining = await Question.list() + expect(remaining).toHaveLength(1) + expect(remaining[0]?.sessionID).toBe(sesB) + + await Question.reject(remaining[0]!.id) + expect(await b1).toBe("rejected-b") + }, + }) + }) + + test("is a no-op when no questions exist", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Question.dismissAll("ses_missing") + expect(await Question.list()).toEqual([]) + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 2d2ab73d2e..ce35e00f4d 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Bus } from "../../src/bus" import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" import { Suggestion } from "../../src/kilocode/suggestion" +import { Question } from "../../src/question" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" @@ -223,70 +224,82 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) - test("retains distinct reserved versions during rapid replacement", async () => { - const sessionID = SessionID.make("session_reserve_race") - const ready = Promise.withResolvers() - const gate = Promise.withResolvers() - const runs: string[] = [] - - const base = Effect.runPromise( - KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_a"), - Effect.sync(() => ready.resolve()).pipe( - Effect.flatMap(() => Effect.promise(() => gate.promise)), - Effect.as("a"), - ), - Effect.succeed("a-cancelled"), - ), - ) - - await ready.promise - - const one = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) - const two = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + test("hasFollowup reports true only for prompts enqueued after the active slot started", async () => { + const sessionID = SessionID.make("session_followup_semantics") + const observed: Array<{ where: string; value: boolean }> = [] + const firstStarted = Promise.withResolvers() + const firstReleased = Promise.withResolvers() + const secondStarted = Promise.withResolvers() + const secondReleased = Promise.withResolvers() const first = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_b"), - Effect.sync(() => { - runs.push("b") - return "b" + MessageID.make("message_followup_1"), + Effect.gen(function* () { + observed.push({ where: "first:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + firstStarted.resolve() + yield* Effect.promise(() => firstReleased.promise) + observed.push({ where: "first:end", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + return "first" }), - Effect.sync(() => { - runs.push("b-cancelled") - return "b-cancelled" - }), - one, + Effect.succeed("first-cancelled"), ), ) + await firstStarted.promise + // msg1 is alone — nothing newer has arrived yet. + expect(observed[0]?.value).toBe(false) + const second = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_c"), - Effect.sync(() => { - runs.push("c") - return "c" + MessageID.make("message_followup_2"), + Effect.gen(function* () { + observed.push({ where: "second:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + secondStarted.resolve() + yield* Effect.promise(() => secondReleased.promise) + return "second" }), - Effect.sync(() => { - runs.push("c-cancelled") - return "c-cancelled" - }), - two, + Effect.succeed("second-cancelled"), ), ) - gate.resolve() + // Enqueueing msg2 while msg1 is still running must flip hasFollowup to true + // for msg1's running slot. + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) - expect(await base).toBe("a") - expect(await first).toBe("b-cancelled") - expect(await second).toBe("c") - expect(runs).toEqual(["b-cancelled", "c"]) + const third = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_followup_3"), + Effect.sync(() => { + observed.push({ where: "third:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + return "third" + }), + Effect.succeed("third-cancelled"), + ), + ) + + // Let msg1 finish. + firstReleased.resolve() + await first + await secondStarted.promise + + // msg2 started after msg3 was enqueued, so hasFollowup should be false for + // msg2 — everything waiting is older than msg2's activeSince snapshot. + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(false) + secondReleased.resolve() + + expect(await second).toBe("second") + expect(await third).toBe("third") + + const events = observed.map((item) => `${item.where}=${item.value}`) + expect(events).toEqual(["first:start=false", "first:end=true", "second:start=false", "third:start=false"]) }) - test("cancels the in-flight turn when a new prompt arrives", async () => { + test("processes queued prompts without aborting the in-flight stream", async () => { const ready = Promise.withResolvers() const injected = Promise.withResolvers() const calls: number[] = [] @@ -299,7 +312,7 @@ describe("session prompt queue", () => { calls.push(Date.now()) const body = calls.length === 1 - ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + ? reply({ text: "first reply", ready: ready.resolve }) : reply({ text: "second reply", ready: injected.resolve }) return new Response(body, { status: 200, @@ -353,16 +366,18 @@ describe("session prompt queue", () => { parts: [{ type: "text", text: "second prompt" }], }) - await injected.promise - expect(calls).toHaveLength(2) - const one = await first + await injected.promise const two = await second - expect(one.info.role).toBe("assistant") - expect(hasText(two, "second reply")).toBe(true) expect(calls).toHaveLength(2) + // The in-flight stream must complete; no aborted error on msg1's reply. + expect(one.info.role).toBe("assistant") + if (one.info.role === "assistant") expect(one.info.error).toBeUndefined() + expect(hasText(one, "first reply")).toBe(true) + expect(hasText(two, "second reply")).toBe(true) + const msgs = await Session.messages({ sessionID: session.id }) const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") @@ -373,19 +388,26 @@ describe("session prompt queue", () => { msg.parts.filter((part) => part.type === "text").map((part) => part.text), ) expect(users).toHaveLength(2) + expect(assistants).toHaveLength(2) expect(prompts).toContain("first prompt") expect(prompts).toContain("second prompt") + expect(text).toContain("first reply") expect(text).toContain("second reply") - expect(text).not.toContain("first reply") - const latest = assistants.find((msg) => hasText(msg, "second reply")) + const firstUser = users.find((msg) => hasText(msg, "first prompt")) const secondUser = users.find((msg) => hasText(msg, "second prompt")) - expect(latest?.info.role).toBe("assistant") - expect(secondUser?.info.role).toBe("user") - if (latest?.info.role !== "assistant" || secondUser?.info.role !== "user") { - throw new Error("missing hot-injected turn") + const firstReply = assistants.find((msg) => hasText(msg, "first reply")) + const secondReply = assistants.find((msg) => hasText(msg, "second reply")) + if ( + firstUser?.info.role !== "user" || + secondUser?.info.role !== "user" || + firstReply?.info.role !== "assistant" || + secondReply?.info.role !== "assistant" + ) { + throw new Error("missing expected messages") } - expect(latest.info.parentID).toBe(secondUser.info.id) + expect(firstReply.info.parentID).toBe(firstUser.info.id) + expect(secondReply.info.parentID).toBe(secondUser.info.id) }, }) } finally { @@ -393,9 +415,8 @@ describe("session prompt queue", () => { } }) - test("cancel resets internal state after a hot-injected prompt replaces the active turn", async () => { + test("cancel drops queued prompts and resets internal state", async () => { const ready = Promise.withResolvers() - const injected = Promise.withResolvers() const calls: number[] = [] const server = Bun.serve({ port: 0, @@ -404,10 +425,7 @@ describe("session prompt queue", () => { if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) calls.push(Date.now()) - const body = - calls.length === 1 - ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) - : reply({ text: "second reply", ready: injected.resolve, wait: new Promise(() => {}) }) + const body = reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -451,21 +469,25 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) + const third = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "third prompt" }], + }) - await injected.promise - expect(calls).toHaveLength(2) + // Let msg2/msg3's enqueue capture the current version before cancel bumps it. + await Bun.sleep(20) + expect(calls).toHaveLength(1) await SessionPrompt.cancel(session.id) - const [one, two] = await Promise.all([first, second]) + await Promise.all([first, second, third]) - expect(one.info.role).toBe("assistant") - expect(two.info.role).toBe("assistant") - expect(calls).toHaveLength(2) + // The queued prompts must never reach the LLM once cancel flushes the queue. + expect(calls).toHaveLength(1) const msgs = await Session.messages({ sessionID: session.id }) - const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(users).toHaveLength(2) - expect(assistants).toHaveLength(2) + expect(assistants).toHaveLength(1) + expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) // Internal state should have no lingering tail/version/target entries after the last release. const ids = await Effect.runPromise( @@ -477,6 +499,7 @@ describe("session prompt queue", () => { ), ) expect(ids).toEqual([]) + expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false) }, }) } finally { @@ -528,4 +551,57 @@ describe("session prompt queue", () => { }, }) }) + + test("new prompt dismisses a pending question", async () => { + const asked = Promise.withResolvers() + const rejected = Promise.withResolvers() + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Question unblock regression" }) + const offAsked = Bus.subscribe(Question.Event.Asked, (event) => { + if (event.properties.sessionID === session.id) asked.resolve() + }) + const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => { + if (event.properties.sessionID === session.id) rejected.resolve() + }) + + try { + const pending = Question.ask({ + sessionID: session.id, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go ahead" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + await asked.promise + await SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }) + await rejected.promise + + expect(await pending).toBe("rejected") + expect(await Question.list()).toEqual([]) + } finally { + offAsked() + offRejected() + } + }, + }) + }) }) From 5221569844b8522230f9077469d7444661e28bea Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 16:03:08 +0300 Subject: [PATCH 24/70] fix(cli): cover moved noReply line in kilocode_change block --- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5973456554..fd95da7703 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1317,7 +1317,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the // enqueued during the turn. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) yield* Effect.promise(() => Question.dismissAll(input.sessionID)) - // kilocode_change end if (input.noReply === true) return message return yield* KiloSessionPromptQueue.enqueue( input.sessionID, @@ -1325,6 +1324,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), ) + // kilocode_change end }, ) // kilocode_change end diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 2badf38a24..c5981d9f6e 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -38,7 +38,7 @@ describe("prompt.ts Kilo-specific invariants", () => { // either of those would abort the running streamText mid-tokens, which was // the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue. const block = content.match( - /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/) From dfbdc97cd38e962d2c8ea2233737a45071e2f359 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:04:30 +0000 Subject: [PATCH 25/70] refactor(cli): simplify isBinaryFile check to UTF-16 BOM only Per review: CJK and legacy single-byte encodings already passed the old control-char heuristic (their bytes are all >= 0x80, never NUL), so the full Encoding.detect pass in isBinaryFile was solving a non-problem for them. The only realistic regression in the old heuristic is UTF-16 with BOM, where the second byte of every ASCII character is 0x00 and fires the NUL-byte early-return. Replace the detect call with a 2-byte BOM check via a new Encoding.hasUtf16Bom helper. Also reword the move/add inline markers in patch/index.ts to note that Encoding.write handles mkdir. --- packages/opencode/src/kilocode/encoding.ts | 6 ++++++ packages/opencode/src/patch/index.ts | 2 +- packages/opencode/src/tool/read.ts | 6 ++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index 9345dd56a9..f846da9871 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -38,6 +38,12 @@ export namespace Encoding { return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf } + /** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */ + export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean { + if (limit < 2) return false + return (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff) + } + /** Remap jschardet labels to iconv-lite compatible names. */ function normalize(name: string): string { const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "") diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 323ce403fc..20c46de136 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -549,7 +549,7 @@ export namespace Patch { if (hunk.move_path) { // Handle file move - await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change + await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 5d596fd4ec..2dfb1f6587 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -330,10 +330,8 @@ export async function isBinaryFile(filepath: string, fileSize: number): Promise< const result = await fh.read(bytes, 0, sampleSize, 0) if (result.bytesRead === 0) return false - // kilocode_change start - treat detected non-UTF-8 text (CJK, UTF-16 with BOM) as text, not binary - const sample = bytes.subarray(0, result.bytesRead) - const enc = Encoding.detect(sample) - if (enc !== "utf-8") return false + // kilocode_change start - UTF-16 BOM: NUL bytes are legitimate, skip the NUL/control-char heuristic + if (Encoding.hasUtf16Bom(bytes, result.bytesRead)) return false // kilocode_change end let nonPrintableCount = 0 From 6d45e33efbb097bb5a724223ef4bdb5ee67355cd Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 17:49:44 +0300 Subject: [PATCH 26/70] core: auto-dismiss suggestion and question prompts once a new user message is queued, so a queued follow-up runs immediately instead of stalling behind a tool waiting on esc --- .../opencode/src/kilocode/suggestion/index.ts | 10 ++ packages/opencode/src/question/index.ts | 12 ++ .../kilocode/question-dismiss-all.test.ts | 68 ++++++++- .../kilocode/session-prompt-queue.test.ts | 132 ++++++++++++++++++ .../kilocode/suggestion/auto-dismiss.test.ts | 65 +++++++++ 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 57eebd7012..33e73f3f74 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -1,8 +1,10 @@ import { Bus } from "../../bus" import { BusEvent } from "../../bus/bus-event" import { Identifier } from "../../id/id" +import { SessionID } from "../../session/schema" import { Log } from "../../util/log" import z from "zod" +import { KiloSessionPromptQueue } from "../session/prompt-queue" export namespace Suggestion { const log = Log.create({ service: "suggestion" }) @@ -90,6 +92,14 @@ export namespace Suggestion { blocking?: boolean tool?: { messageID: string; callID: string } }): Promise { + // Auto-dismiss if a newer prompt is already queued on this session. + // Synchronous check immediately before the pending set, so there's no + // interleaving with dismissAll called from SessionPrompt.prompt. + if (KiloSessionPromptQueue.hasFollowup(SessionID.make(input.sessionID))) { + log.info("auto-dismissed — followup queued", { sessionID: input.sessionID }) + throw new DismissedError() + } + const s = { pending } const id = Identifier.ascending("suggestion") diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 539fd1151e..3f533ab5af 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -8,6 +8,9 @@ import { Log } from "@/util/log" import { withStatics } from "@/util/schema" import { QuestionID } from "./schema" import { makeRuntime } from "@/effect/run-service" // kilocode_change +// kilocode_change start +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" +// kilocode_change end export namespace Question { const log = Log.create({ service: "question" }) @@ -195,6 +198,15 @@ export namespace Question { blocking: input.blocking, // kilocode_change tool: input.tool, }) + + // kilocode_change start — auto-dismiss when a newer prompt is queued on this session, + // otherwise a tool that calls Question.ask after the queue event would block the run. + if (KiloSessionPromptQueue.hasFollowup(input.sessionID)) { + log.info("auto-dismissed — followup queued", { sessionID: input.sessionID }) + return yield* Effect.fail(new RejectedError()) + } + // kilocode_change end + pending.set(id, { info, deferred }) yield* bus.publish(Event.Asked, info) diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts index acf5a835a0..002765bd34 100644 --- a/packages/opencode/test/kilocode/question-dismiss-all.test.ts +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" import { Instance } from "../../src/project/instance" import { Question } from "../../src/question" -import { SessionID } from "../../src/session/schema" +import { MessageID, SessionID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" describe("Question.dismissAll", () => { @@ -105,4 +107,68 @@ describe("Question.dismissAll", () => { }, }) }) + + test("ask rejects immediately when a followup is queued on the session", async () => { + // When a newer prompt has already been enqueued on the session, a tool + // that subsequently calls Question.ask would otherwise block the run until + // the user manually dismisses it. Verify the pre-emptive hasFollowup check + // rejects with RejectedError before any pending entry is registered. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sessionID = SessionID.make("ses_auto_ask") + const started = Promise.withResolvers() + const release = Promise.withResolvers() + + // Slot 1 stays running so activeSince is pinned to its seq. + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_ask_1"), + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + return "first" as const + }), + Effect.succeed("first-cancelled" as const), + ), + ) + await started.promise + + // Slot 2 arrives while slot 1 is active — latest > activeSince. + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_ask_2"), + Effect.succeed("second" as const), + Effect.succeed("second-cancelled" as const), + ), + ) + await Bun.sleep(10) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) + + await expect( + Question.ask({ + sessionID, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }), + ).rejects.toBeInstanceOf(Question.RejectedError) + expect(await Question.list()).toEqual([]) + + release.resolve() + expect(await first).toBe("first") + expect(await second).toBe("second") + }, + }) + }) }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index ce35e00f4d..df5ec27f10 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -604,4 +604,136 @@ describe("session prompt queue", () => { }, }) }) + + test("auto-dismisses a suggestion shown after a queued prompt", async () => { + // Reverse ordering of the "new prompt dismisses a pending suggestion" test: + // queue the follow-up first, then open the blocker. Suggestion.show must see + // hasFollowup=true and reject synchronously, before any pending entry or + // Shown event is published. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sessionID = SessionID.make("ses_auto_suggestion") + const started = Promise.withResolvers() + const release = Promise.withResolvers() + + // Slot 1: active, activeSince snapshots latest=1. + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_auto_sug_1"), + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + return "first" as const + }), + Effect.succeed("first-cancelled" as const), + ), + ) + await started.promise + + // Slot 2: enqueued while slot 1 is active → latest=2 > activeSince=1. + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_auto_sug_2"), + Effect.succeed("second" as const), + Effect.succeed("second-cancelled" as const), + ), + ) + await Bun.sleep(10) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) + + let shown = 0 + const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => { + if (event.properties.sessionID === sessionID) shown++ + }) + try { + await expect( + Suggestion.show({ + sessionID, + text: "Run review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], + }), + ).rejects.toBeInstanceOf(Suggestion.DismissedError) + } finally { + offShown() + } + expect(shown).toBe(0) + expect(await Suggestion.list()).toEqual([]) + + release.resolve() + expect(await first).toBe("first") + expect(await second).toBe("second") + }, + }) + }) + + test("auto-dismisses a question shown after a queued prompt", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sessionID = SessionID.make("ses_auto_question") + const started = Promise.withResolvers() + const release = Promise.withResolvers() + + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_auto_q_1"), + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + return "first" as const + }), + Effect.succeed("first-cancelled" as const), + ), + ) + await started.promise + + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_auto_q_2"), + Effect.succeed("second" as const), + Effect.succeed("second-cancelled" as const), + ), + ) + await Bun.sleep(10) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) + + let asked = 0 + const offAsked = Bus.subscribe(Question.Event.Asked, (event) => { + if (event.properties.sessionID === sessionID) asked++ + }) + try { + await expect( + Question.ask({ + sessionID, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go ahead" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }), + ).rejects.toBeInstanceOf(Question.RejectedError) + } finally { + offAsked() + } + expect(asked).toBe(0) + expect(await Question.list()).toEqual([]) + + release.resolve() + expect(await first).toBe("first") + expect(await second).toBe("second") + }, + }) + }) }) diff --git a/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts new file mode 100644 index 0000000000..d8c6a19122 --- /dev/null +++ b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { KiloSessionPromptQueue } from "../../../src/kilocode/session/prompt-queue" +import { Suggestion } from "../../../src/kilocode/suggestion" +import { Instance } from "../../../src/project/instance" +import { MessageID, SessionID } from "../../../src/session/schema" +import { tmpdir } from "../../fixture/fixture" + +describe("Suggestion.show auto-dismiss on queued followup", () => { + test("show rejects immediately when a followup is queued on the session", async () => { + // A tool that calls Suggestion.show after a queued prompt has arrived would + // otherwise block the turn on user input. Verify the pre-emptive + // hasFollowup check rejects with DismissedError before any pending entry + // is registered or a Shown event is published. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sessionID = SessionID.make("ses_auto_show") + const started = Promise.withResolvers() + const release = Promise.withResolvers() + + // Slot 1 stays running so activeSince is pinned to its seq. + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_show_1"), + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + return "first" as const + }), + Effect.succeed("first-cancelled" as const), + ), + ) + await started.promise + + // Slot 2 arrives while slot 1 is active — latest > activeSince. + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_show_2"), + Effect.succeed("second" as const), + Effect.succeed("second-cancelled" as const), + ), + ) + await Bun.sleep(10) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) + + await expect( + Suggestion.show({ + sessionID, + text: "Run review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], + }), + ).rejects.toBeInstanceOf(Suggestion.DismissedError) + expect(await Suggestion.list()).toEqual([]) + + release.resolve() + expect(await first).toBe("first") + expect(await second).toBe("second") + }, + }) + }) +}) From d5492f4765ec08c9879a9f0e1733cf94c6c51d2e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 17:58:33 +0300 Subject: [PATCH 27/70] fix(cli): mark dismissed question as blocked --- packages/opencode/src/tool/question.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index f99e12406f..c820025f5a 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -10,6 +10,7 @@ const parameters = z.object({ type Metadata = { answers: ReadonlyArray + dismissed?: boolean // kilocode_change } export const QuestionTool = Tool.define( @@ -33,7 +34,7 @@ export const QuestionTool = Tool.define Effect.succeed<"dismissed">("dismissed"))) if (answers === "dismissed") { - const dismissed: Metadata = { answers: [] } + const dismissed: Metadata = { answers: [], dismissed: true } return { title: "Question dismissed", output: "User dismissed the question.", From 8c47af8af402f2251ed32e17810db71f40e707b9 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:59:41 +0000 Subject: [PATCH 28/70] refactor(cli): reapply encoding changes after main merge Main moved the Patch namespace out of packages/opencode/src/patch/index.ts into patch/patch.ts and switched tool imports to the new @opencode-ai/shared/filesystem and ../../src/tool barrels. Reapply the encoding-aware read/write changes on top of the refactored patch.ts, and update the integration test imports to match the new module layout. --- packages/opencode/test/kilocode/tool-encoding.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 19a8b7e4b0..2ac56a8d00 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -9,7 +9,7 @@ import path from "path" import fs from "fs/promises" import iconv from "iconv-lite" import { Agent } from "../../src/agent/agent" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Bus } from "../../src/bus" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -21,8 +21,8 @@ import { Instruction } from "../../src/session/instruction" import { LSP } from "../../src/lsp" import { MessageID, SessionID } from "../../src/session/schema" import { ReadTool } from "../../src/tool/read" -import { Tool } from "../../src/tool/tool" -import { Truncate } from "../../src/tool/truncate" +import * as Tool from "../../src/tool/tool" +import { Truncate } from "../../src/tool" import { WriteTool } from "../../src/tool/write" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" From 8eaff58330992d56e0c0a4177c22efd954b12326 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:00:19 +0000 Subject: [PATCH 29/70] chore: restore .kilo/.kilocode lockfiles that main re-tracked --- .kilo/package-lock.json | 115 ++++++++++++++++++++++++++++++++++++ .kilocode/package-lock.json | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 .kilo/package-lock.json create mode 100644 .kilocode/package-lock.json diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 0000000000..f78a49868c --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.14" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz", + "integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.14", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz", + "integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json new file mode 100644 index 0000000000..65452b7e4d --- /dev/null +++ b/.kilocode/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".kilocode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.14" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz", + "integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.14", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz", + "integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} From e84ea3afc677e2a18f26e2d2168e7122edfb41a9 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:04:12 +0000 Subject: [PATCH 30/70] refactor(cli): self-review post-merge cleanup in apply_patch - Drop the dead 'encoding = pre.encoding' initial value that was only read if deriveNewContentsFromChunks threw, and that code path returns before encoding is ever used. - Inline pre.text into the read line so we don't bind a 'pre' local just to destructure it once. --- packages/opencode/src/tool/apply_patch.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index d5e9ea3247..d6a1be855e 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -109,10 +109,9 @@ export const ApplyPatchTool = Tool.define( ) } - const pre = yield* EncodedIO.read(filePath) // kilocode_change - preserve file encoding - const oldContent = pre.text // kilocode_change - let encoding = pre.encoding // kilocode_change + const oldContent = (yield* EncodedIO.read(filePath)).text // kilocode_change - encoding-aware read let newContent = oldContent + let encoding: string // kilocode_change - filled in by the patch helper below // Apply the update chunks to get new content try { From 36542685d6be4cecf08957150239d3978288fd32 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 22 Apr 2026 19:55:39 -0400 Subject: [PATCH 31/70] feat(jetbrains): buffered session update queue with EDT-owned flush and visibility gating Extract a dedicated SessionUpdateQueue that owns stream-event batching, cadence-based EDT flushing, and hidden-UI deferral for the JetBrains session controller. - Add SessionUpdateQueue: Disposable, Java scheduled executor ticker, EDT-only pending list, requestFlush(forced)/flushNow logic, holdFlush for existing-session history ordering, conservative text PartDelta coalescing before delivery - Simplify SessionController: remove inline locks/jobs/flags, delegate all flush decisions to the queue, use holdFlush during history+recovery then force-flush on completion - Drop SessionUi visibility wiring (queue reads component.isShowing directly) - Update test base: deterministic forced flush, per-controller Root component for visibility control, hide/show helpers - Add SessionUpdateQueueTest: hidden buffering, delta coalescing, cadence flush - Fix ListenerLifecycleTest/assertControllerEvents: accept multiline string, sort both sides before comparing --- .../client/session/SessionController.kt | 27 +++- .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../client/session/SessionUpdateQueue.kt | 117 ++++++++++++++++++ .../client/session/ListenerLifecycleTest.kt | 6 +- .../session/SessionControllerTestBase.kt | 45 ++++++- .../client/session/SessionUpdateQueueTest.kt | 81 ++++++++++++ .../client/session/ui/QuestionPanelTest.kt | 8 +- 7 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 1305e6fa25..8ac3c55c5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -55,6 +55,8 @@ class SessionController( private val workspace: Workspace, private val app: KiloAppService, private val cs: CoroutineScope, + comp: java.awt.Component? = null, + private val flushMs: Long = EVENT_FLUSH_MS, ) : Disposable { companion object { @@ -70,6 +72,7 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory + private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, id != null) private var partType: String? = null private var tool: String? = null @@ -82,6 +85,8 @@ class SessionController( Disposer.register(parent) { listeners.remove(listener) } } + internal fun flushEvents() = updates.requestFlush(true) + fun prompt(text: String) { val sid = sessionId ?: "pending" LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } @@ -247,13 +252,16 @@ class SessionController( try { val history = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } - edt { + runEdt { this@SessionController.model.loadHistory(history) if (!model.isEmpty()) showMessages() } recoverPending(id) } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + } finally { + updates.holdFlush(false) + updates.requestFlush(true) } } } @@ -270,7 +278,7 @@ class SessionController( return@collect } LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" } - edt { handle(event) } + updates.enqueue(event) } } finally { LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=false" } @@ -293,7 +301,7 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch" } - edt { + runEdt { if (permissions.isNotEmpty()) { model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) } else if (questions.isNotEmpty()) { @@ -459,6 +467,10 @@ class SessionController( } } + private fun handle(events: List) { + for (event in events) handle(event) + } + private fun showMessages() { if (!model.showMessages) { model.showMessages = true @@ -500,6 +512,15 @@ class SessionController( ApplicationManager.getApplication().invokeLater(block) } + private fun runEdt(block: () -> Unit) { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + block() + return + } + application.invokeAndWait(block) + } + override fun dispose() { eventJob?.cancel() cs.cancel() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 64e9df2a11..4bf70fa7d5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -52,7 +52,7 @@ class SessionUi( private val LOG = KiloLog.create(SessionUi::class.java) } - private val controller = SessionController(this, null, sessions, workspace, app, cs) + private val controller = SessionController(this, null, sessions, workspace, app, cs, this) // ------ card switch ------ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt new file mode 100644 index 0000000000..600adc9a2d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import java.awt.Component +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +internal const val EVENT_FLUSH_MS = 250L + +internal class SessionUpdateQueue( + parent: Disposable, + private val comp: Component?, + private val flushMs: Long = EVENT_FLUSH_MS, + private val fire: (List) -> Unit, + hold: Boolean, +) : Disposable { + private val app = ApplicationManager.getApplication() + private val pending = mutableListOf() + private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() + private var last = 0L + private var hold = hold + + init { + Disposer.register(parent, this) + exec?.scheduleAtFixedRate( + { requestFlush(false) }, + flushMs, + flushMs, + TimeUnit.MILLISECONDS, + ) + } + + fun enqueue(event: ChatEventDto) { + edt { + pending.add(event) + flushNow(false) + } + } + + fun holdFlush(hold: Boolean) { + edt { this.hold = hold } + } + + fun requestFlush(forced: Boolean) { + edt { flushNow(forced) } + } + + override fun dispose() { + exec?.shutdownNow() + if (app.isDispatchThread) { + pending.clear() + return + } + app.invokeLater { pending.clear() } + } + + private fun flushNow(forced: Boolean) { + if (hold) return + if (!showing()) return + if (pending.isEmpty()) return + val now = System.currentTimeMillis() + if (!forced && now - last < flushMs) return + val batch = condense(pending.toList()) + pending.clear() + last = now + fire(batch) + } + + private fun showing(): Boolean = comp?.isShowing ?: true + + private fun edt(block: () -> Unit) { + if (app.isDispatchThread) { + block() + return + } + app.invokeLater(block) + } +} + +private fun condense(events: List): List { + if (events.size < 2) return events + val out = mutableListOf() + val deltas = LinkedHashMap() + + fun drain() { + if (deltas.isEmpty()) return + out.addAll(deltas.values) + deltas.clear() + } + + for (event in events) { + val delta = event as? ChatEventDto.PartDelta + val key = delta?.key() + if (key == null) { + drain() + out.add(event) + continue + } + val prev = deltas[key] + deltas[key] = if (prev != null) prev.merge(delta) else delta + } + + drain() + return out +} + +private fun ChatEventDto.PartDelta.key(): String? { + if (field != "text") return null + return "$sessionID:$messageID:$partID:$field" +} + +private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta = + ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 174c05879a..a9a7de5c9d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -46,16 +46,12 @@ class ListenerLifecycleTest : SessionControllerTestBase() { edt { m.prompt("go") } flush() + assertEquals(events1, events2) assertControllerEvents(""" ViewChanged show AppChanged WorkspaceChanged """, events1) - assertControllerEvents(""" - ViewChanged show - AppChanged - WorkspaceChanged - """, events2) } fun `test session status idle fires StateChanged to Idle`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index d441027166..8c33d4f6ce 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -41,6 +41,17 @@ import kotlinx.coroutines.runBlocking */ abstract class SessionControllerTestBase : BasePlatformTestCase() { + private class Root : javax.swing.JPanel() { + private var shown = true + override fun isShowing(): Boolean = shown + fun showState(show: Boolean) { + shown = show + } + } + + private val controllers = mutableListOf() + private val roots = mutableMapOf() + protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi protected lateinit var projectRpc: FakeWorkspaceRpcApi @@ -79,8 +90,23 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ Controller creation ------ - protected fun controller(id: String? = null) = - SessionController(parent, id, sessions, workspace, app, scope) + protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) + + protected fun controller(id: String? = null, flushMs: Long): SessionController { + val root = Root() + val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs) + controllers.add(m) + roots[m] = root + return m + } + + protected fun hide(m: SessionController) { + edt { (roots[m] ?: error("missing root")).showState(false) } + } + + protected fun show(m: SessionController) { + edt { (roots[m] ?: error("missing root")).showState(true) } + } // ------ Event collection ------ @@ -109,10 +135,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ EDT + coroutine helpers ------ - /** Let coroutines settle, then drain all pending EDT events. */ + /** Let coroutines settle without forcing buffered controller delivery. */ + protected fun settle() = runBlocking { + repeat(5) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + /** Let coroutines settle, force buffered controller delivery, then drain EDT. */ protected fun flush() = runBlocking { repeat(5) { delay(100) + controllers.forEach { it.flushEvents() } edt { UIUtil.dispatchAllInvocationEvents() } } } @@ -154,7 +189,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } protected fun assertControllerEvents(expected: String, events: List) { - assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + val exp = expected.trimIndent().lines().map { it.trim() }.filter { it.isNotEmpty() }.sorted() + val act = events.map { it.toString() }.sorted() + assertEquals(exp.joinToString("\n"), act.joinToString("\n")) } protected fun assertModelEvents(expected: String, events: List) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt new file mode 100644 index 0000000000..b59272c575 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -0,0 +1,81 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto + +class SessionUpdateQueueTest : SessionControllerTestBase() { + + fun `test hidden controller buffers until shown`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + flush() + + assertModelEvents(""" + StateChanged Busy + MessageAdded msg1 + TurnAdded msg1 [msg1] + """, modelEvents) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test buffered deltas coalesce into one model delta`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"), flush = false) + settle() + flush() + + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded }) + val delta = modelEvents.filterIsInstance() + assertEquals(1, delta.size) + assertModel( + """ + assistant#msg1 + text#prt1: + hello world + """, + m, + ) + assertEquals(listOf("hello world"), delta.map { it.delta }) + } + + fun `test visible controller flushes after cadence`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 50L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + flush() + + assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged }) + assertTrue(m.model.state is SessionState.Busy) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index 496e2a2301..9f16b99529 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -21,6 +21,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import javax.swing.JPanel @Suppress("UnstableApiUsage") class QuestionPanelTest : BasePlatformTestCase() { @@ -47,7 +48,8 @@ class QuestionPanelTest : BasePlatformTestCase() { app = KiloAppService(scope, appRpc) workspaces = KiloWorkspaceService(scope, workspaceRpc) workspace = workspaces.workspace("/test") - controller = SessionController(parent, "ses_test", sessions, workspace, app, scope) + val root = JPanel() + controller = SessionController(parent, "ses_test", sessions, workspace, app, scope, root) panel = QuestionPanel(controller) } @@ -69,6 +71,8 @@ class QuestionPanelTest : BasePlatformTestCase() { question = "Pick one", header = "Header", options = listOf(QuestionOption("Yes", "desc")), + multiple = false, + custom = true, ) ), ) @@ -80,6 +84,6 @@ class QuestionPanelTest : BasePlatformTestCase() { assertFalse(panel.isVisible) assertEquals(0, panel.componentCount) assertTrue(rpc.questionReplies.isEmpty()) - assertTrue(rpc.questionRejections.isEmpty()) + assertTrue(rpc.questionRejects.isEmpty()) } } From a9107539e3ab1f86376341c2ab7fac0124059aa5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 11:29:30 +0300 Subject: [PATCH 32/70] refactor(cli): move question tool dismissed helpers to kilocode file --- .../opencode/src/kilocode/tool/question.ts | 27 +++++++++++++++++++ packages/opencode/src/tool/question.ts | 18 +++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/kilocode/tool/question.ts diff --git a/packages/opencode/src/kilocode/tool/question.ts b/packages/opencode/src/kilocode/tool/question.ts new file mode 100644 index 0000000000..9e093e7c7e --- /dev/null +++ b/packages/opencode/src/kilocode/tool/question.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { Question } from "@/question" + +/** + * Helpers for the shared `@/tool/question` tool that surface a dismissed-question + * outcome (from `Question.dismissAll` when a new prompt arrives mid-question) as + * a normal tool result instead of letting `Effect.orDie` turn the + * `QuestionRejectedError` into a defect that kills the in-flight stream. + * + * Extracted here so the shared tool file keeps just a one-liner pipe plus an + * early return, minimising the surface area that conflicts with upstream. + */ +export namespace KiloQuestionTool { + const DISMISSED = "dismissed" as const + type Dismissed = typeof DISMISSED + + export const catchDismissed = (eff: Effect.Effect) => + eff.pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed(DISMISSED))) + + export const isDismissed = (v: unknown): v is Dismissed => v === DISMISSED + + export const dismissedResult = () => ({ + title: "Question dismissed", + output: "User dismissed the question.", + metadata: { answers: [] as ReadonlyArray, dismissed: true as const }, + }) +} diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index c820025f5a..4b52ebd7cf 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" +import { KiloQuestionTool } from "@/kilocode/tool/question" // kilocode_change const parameters = z.object({ questions: z.array(Question.Prompt.zod).describe("Questions to ask"), @@ -23,24 +24,17 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - // kilocode_change start - gracefully surface RejectedError (e.g. from Question.dismissAll - // when a new prompt arrives mid-question) as a "dismissed" outcome instead of turning it - // into a defect via Effect.orDie, which would kill the in-flight stream. + // kilocode_change start - surface Question.dismissAll's RejectedError as a normal + // tool result via KiloQuestionTool helpers, so Effect.orDie below does not turn + // it into a defect and kill the in-flight stream. const answers = yield* question .ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, }) - .pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed<"dismissed">("dismissed"))) - if (answers === "dismissed") { - const dismissed: Metadata = { answers: [], dismissed: true } - return { - title: "Question dismissed", - output: "User dismissed the question.", - metadata: dismissed, - } - } + .pipe(KiloQuestionTool.catchDismissed) + if (KiloQuestionTool.isDismissed(answers)) return KiloQuestionTool.dismissedResult() // kilocode_change end const formatted = params.questions From d0e109dcb683d831c0d65092d6e2687a5f2a9b5f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 11:29:37 +0300 Subject: [PATCH 33/70] refactor(cli): move question dismissAll to kilocode file --- .../opencode/src/kilocode/question/index.ts | 62 +++++++++++++++++++ packages/opencode/src/question/index.ts | 33 +++------- 2 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/kilocode/question/index.ts diff --git a/packages/opencode/src/kilocode/question/index.ts b/packages/opencode/src/kilocode/question/index.ts new file mode 100644 index 0000000000..b827c86674 --- /dev/null +++ b/packages/opencode/src/kilocode/question/index.ts @@ -0,0 +1,62 @@ +import { Deferred, Effect } from "effect" +import { InstanceState } from "@/effect" +import { Log } from "@/util" +import { SessionID } from "@/session/schema" +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" + +/** + * Kilo-specific helpers for the shared `@/question` module. + * + * Extracted here so the upstream file keeps just the import, an Interface entry + * for `dismissAll`, and one-liner calls at the use sites — minimising the + * surface area that conflicts with upstream. + */ +export namespace KiloQuestion { + const log = Log.create({ service: "question" }) + + /** Minimal entry shape both helpers need; matches `PendingEntry` in `@/question`. */ + type Entry = { + info: { id: unknown; sessionID: SessionID } + deferred: Deferred.Deferred + } + + /** + * Factory for `Question.dismissAll`: dismisses every pending question on a + * session so a new prompt can unblock an in-flight tool waiting on user + * input. Mirrors `Suggestion.dismissAll` so both read the same way at the + * callsite. + * + * The caller provides a `publishRejected` callback (closed over the already- + * resolved `Bus.Service` in the Question layer) and an error factory so this + * helper stays free of any `@/question` import and dodges a circular dep. + */ + export const makeDismissAll = + (args: { + state: InstanceState.InstanceState<{ pending: Map }> + publishRejected: (entry: PE) => Effect.Effect + makeError: () => PE["deferred"] extends Deferred.Deferred ? E : never + }) => + (sessionID: SessionID) => + Effect.gen(function* () { + const pending = (yield* InstanceState.get(args.state)).pending + for (const [id, entry] of Array.from(pending.entries())) { + if (entry.info.sessionID !== sessionID) continue + pending.delete(id) + log.info("dismissed", { requestID: id }) + yield* args.publishRejected(entry) + yield* Deferred.fail(entry.deferred, args.makeError()) + } + }) + + /** + * Auto-dismiss when a newer prompt is already queued on this session — a + * tool that calls `Question.ask` after the queue event would otherwise block + * the run while the user waits for their queued prompt to take over. + */ + export const guardFollowup = (sessionID: SessionID, makeError: () => E) => + Effect.gen(function* () { + if (!KiloSessionPromptQueue.hasFollowup(sessionID)) return + log.info("auto-dismissed — followup queued", { sessionID }) + return yield* Effect.fail(makeError()) + }) +} diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 9227ff88d0..4303da1aba 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -8,9 +8,7 @@ import { Log } from "@/util" import { withStatics } from "@/util/schema" import { QuestionID } from "./schema" import { makeRuntime } from "@/effect/run-service" // kilocode_change -// kilocode_change start -import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" -// kilocode_change end +import { KiloQuestion } from "@/kilocode/question" // kilocode_change export namespace Question { const log = Log.create({ service: "question" }) @@ -199,13 +197,7 @@ export namespace Question { tool: input.tool, }) - // kilocode_change start — auto-dismiss when a newer prompt is queued on this session, - // otherwise a tool that calls Question.ask after the queue event would block the run. - if (KiloSessionPromptQueue.hasFollowup(input.sessionID)) { - log.info("auto-dismissed — followup queued", { sessionID: input.sessionID }) - return yield* Effect.fail(new RejectedError()) - } - // kilocode_change end + yield* KiloQuestion.guardFollowup(input.sessionID, () => new RejectedError()) // kilocode_change pending.set(id, { info, deferred }) yield* bus.publish(Event.Asked, info) @@ -259,21 +251,12 @@ export namespace Question { return Array.from(pending.values(), (x) => x.info) }) - // kilocode_change start - dismiss every pending question on a session so a new - // prompt can unblock an in-flight tool waiting on user input. Mirrors - // Suggestion.dismissAll so both read the same way at the callsite. - const dismissAll = Effect.fn("Question.dismissAll")(function* (sessionID: SessionID) { - const pending = (yield* InstanceState.get(state)).pending - const matches = Array.from(pending.entries()).filter(([, entry]) => entry.info.sessionID === sessionID) - for (const [id, entry] of matches) { - pending.delete(id) - log.info("dismissed", { requestID: id }) - yield* bus.publish(Event.Rejected, { - sessionID: entry.info.sessionID, - requestID: entry.info.id, - }) - yield* Deferred.fail(entry.deferred, new RejectedError()) - } + // kilocode_change start - body lives in @/kilocode/question/KiloQuestion.makeDismissAll + const dismissAll = KiloQuestion.makeDismissAll({ + state, + publishRejected: (entry) => + bus.publish(Event.Rejected, { sessionID: entry.info.sessionID, requestID: entry.info.id }), + makeError: () => new RejectedError(), }) // kilocode_change end From c214d63afb426df0b3499b5240fe5ce525561497 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 12:41:07 +0300 Subject: [PATCH 34/70] fix(cli): narrow when suggest offers a local code review Stops the suggest tool from surfacing a local-review prompt after PR-comment replies, reactive fixes, trivial edits, non-implementation work, or review-adjacent turns. Tool description and system prompt now share one explicit gate: only suggest when the user initiated implementation and the diff is substantial. --- .changeset/suggest-review-narrower.md | 5 +++++ packages/opencode/src/kilocode/soul.txt | 10 ++++++++-- packages/opencode/src/kilocode/suggestion/tool.txt | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .changeset/suggest-review-narrower.md diff --git a/.changeset/suggest-review-narrower.md b/.changeset/suggest-review-narrower.md new file mode 100644 index 0000000000..6d5e9821a9 --- /dev/null +++ b/.changeset/suggest-review-narrower.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Narrow when the CLI suggests a local code review so it no longer surfaces after PR-comment replies, reactive fixes (CI/lint failures, reported issues), trivial edits, non-implementation work (research, commits, docs), or review-adjacent turns. diff --git a/packages/opencode/src/kilocode/soul.txt b/packages/opencode/src/kilocode/soul.txt index 39443253ef..607a28be76 100644 --- a/packages/opencode/src/kilocode/soul.txt +++ b/packages/opencode/src/kilocode/soul.txt @@ -17,8 +17,14 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man - Use the `question` tool only when you need an actual answer from the user. - If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step. -- When you have completed implementation work and you are at least 90% confident the task is done, use `suggest` to offer a code review of uncommitted changes. -- Only suggest review when the user's request appears fully addressed. Do not suggest it after every edit or partial implementation turn. +- Only use `suggest` to offer a code review of uncommitted changes when ALL of the following are true: the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated; you have completed that work and are at least 90% confident the task is fully addressed; and the resulting diff is substantial enough that another independent pass could meaningfully catch issues. +- Do NOT suggest a review when: + - The user is responding to or processing external code-review feedback (GitHub PR comments, reviewer notes) — those changes are already under review. + - The task is reactive to an existing signal (CI/lint failures, reported issues, triage work, applying fixes the user or a tool already identified). + - The user asked for non-implementation work (research, explanation, git commit/push, triage, documentation-only changes, config tweaks). + - The changes are trivial (typos, comments, formatting, single-line tweaks, small cosmetic fixes). + - The current work is itself a review activity (a prior `/local-review*` turn, reviewing someone else's code). +- Do not suggest it after every edit or partial implementation turn. - Do not repeat a review suggestion that was already dismissed in this conversation. - Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained. - When suggesting a code review, choose the right command for the action prompt: diff --git a/packages/opencode/src/kilocode/suggestion/tool.txt b/packages/opencode/src/kilocode/suggestion/tool.txt index cc95a64896..caea8f78a9 100644 --- a/packages/opencode/src/kilocode/suggestion/tool.txt +++ b/packages/opencode/src/kilocode/suggestion/tool.txt @@ -14,6 +14,16 @@ Guidelines: - Make each action prompt self-contained so it can be injected as a synthetic user message - If you need a real answer from the user, use the `question` tool instead +When to suggest a review: +- Only when the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated, AND the resulting diff is substantial enough that another independent pass could meaningfully catch issues + +Do NOT suggest a review when: +- The user is responding to or processing external code-review feedback (GitHub PR comments, reviewer notes) — those changes are already under review +- The task is reactive to an existing signal (CI/lint failures, reported issues, triage work, applying fixes the user or a tool already identified) +- The user asked for non-implementation work (research, explanation, git commit/push, triage, documentation-only changes, config tweaks) +- The changes are trivial (typos, comments, formatting, single-line tweaks, small cosmetic fixes) +- The current work is itself a review activity (a prior `/local-review*` turn, reviewing someone else's code) + Choosing the right review command for the action prompt: - Use `/local-review-uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files) - Use `/local-review` as the action prompt for committed branch-level changes From eb52bda015f268f676dab40fa97ff8a5a6208d1a Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:04:01 +0000 Subject: [PATCH 35/70] fix(cli): prevent double BOM when content starts with U+FEFF Strip a leading U+FEFF from text before prepending a UTF-8/UTF-16 BOM so round-tripping content that already carries a BOM character emits exactly one BOM. Also clarifies the changeset that new files default to UTF-8. --- .changeset/preserve-file-encoding.md | 2 +- packages/opencode/src/kilocode/encoding.ts | 11 ++++--- .../test/kilocode/tool-encoding.test.ts | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.changeset/preserve-file-encoding.md b/.changeset/preserve-file-encoding.md index 034e6540f3..2bbfe07f03 100644 --- a/.changeset/preserve-file-encoding.md +++ b/.changeset/preserve-file-encoding.md @@ -2,7 +2,7 @@ "@kilocode/cli": minor --- -The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. +The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. New files are still created as UTF-8 without BOM — detection only applies when overwriting or editing an existing file. Supported: UTF-8 (with or without BOM), UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). diff --git a/packages/opencode/src/kilocode/encoding.ts b/packages/opencode/src/kilocode/encoding.ts index f846da9871..70fc51330e 100644 --- a/packages/opencode/src/kilocode/encoding.ts +++ b/packages/opencode/src/kilocode/encoding.ts @@ -109,11 +109,14 @@ export namespace Encoding { export function encode(text: string, encoding: string): Buffer { // iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM" // as a distinct variant. Prepend the BOM manually so round-tripping keeps - // the original byte signature intact. - if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(text, "utf-8")]) + // the original byte signature intact. Strip a leading U+FEFF from `text` + // first so we never emit a double BOM when the decoded text already + // contains one (e.g. if a tool round-trips content verbatim). + const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text + if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(body, "utf-8")]) const lower = encoding.toLowerCase() - if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)]) - if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)]) + if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(body, encoding)]) + if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(body, encoding)]) return iconv.encode(text, encoding) } diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 2ac56a8d00..1e03493fd6 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -223,6 +223,35 @@ describe("tool encoding preservation", () => { }), ), ) + + // Guard against double-BOM regressions: if the model ever hands back content + // that already starts with U+FEFF (e.g. by round-tripping literal bytes), + // writing it to a BOM-encoded file must still produce exactly one BOM. + const bomCases: Array<[string, string, Buffer]> = [ + ["UTF-8 with BOM", UTF8_BOM, Buffer.from([0xef, 0xbb, 0xbf])], + ["UTF-16 LE", "utf-16le", Buffer.from([0xff, 0xfe])], + ["UTF-16 BE", "utf-16be", Buffer.from([0xfe, 0xff])], + ] + for (const [label, encoding, bom] of bomCases) { + it.live(`does not emit a double BOM for ${label} when content starts with U+FEFF`, () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "file.txt") + yield* putEncoded(filepath, "hello", encoding) + yield* markRead(filepath) + + yield* runWrite({ filePath: filepath, content: "\uFEFFgoodbye" }) + + const bytes = yield* loadBytes(filepath) + // Exactly one BOM prefix, immediately followed by encoded payload. + expect(bytes.subarray(0, bom.length).equals(bom)).toBe(true) + expect(bytes.subarray(bom.length, bom.length * 2).equals(bom)).toBe(false) + const decoded = yield* loadDecoded(filepath, encoding) + expect(decoded).toBe("goodbye") + }), + ), + ) + } }) describe("EditTool preserves existing file encoding across edits", () => { From 2285bd0831238ec9a46adf351bf42ea525636bb3 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 13:12:08 +0300 Subject: [PATCH 36/70] fix(cli): align suggest gate with commands --- packages/opencode/src/kilocode/soul.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/soul.txt b/packages/opencode/src/kilocode/soul.txt index 607a28be76..24445c0569 100644 --- a/packages/opencode/src/kilocode/soul.txt +++ b/packages/opencode/src/kilocode/soul.txt @@ -17,7 +17,7 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man - Use the `question` tool only when you need an actual answer from the user. - If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step. -- Only use `suggest` to offer a code review of uncommitted changes when ALL of the following are true: the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated; you have completed that work and are at least 90% confident the task is fully addressed; and the resulting diff is substantial enough that another independent pass could meaningfully catch issues. +- Only use `suggest` to offer a code review when ALL of the following are true: the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated; you have completed that work and are at least 90% confident the task is fully addressed; and the resulting diff is substantial enough that another independent pass could meaningfully catch issues. - Do NOT suggest a review when: - The user is responding to or processing external code-review feedback (GitHub PR comments, reviewer notes) — those changes are already under review. - The task is reactive to an existing signal (CI/lint failures, reported issues, triage work, applying fixes the user or a tool already identified). From 06ce7ee8668f6c2eedf0e4020709c1771e8109d8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 14:15:51 +0300 Subject: [PATCH 37/70] vscode: require double Esc to stop a turn and keep queued messages visible Pressing Escape once no longer cancels the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI, so users don't lose their queued messages to an accidental tap. --- .changeset/double-esc-abort.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../kilo-vscode/src/agent-manager/types.ts | 1 - .../kilo-vscode/src/kilo-provider/abort.ts | 18 +--- packages/kilo-vscode/tests/unit/abort.test.ts | 49 ++--------- .../tests/unit/session-abort-press.test.ts | 84 +++++++++++++++++++ .../src/components/chat/ChatView.tsx | 8 +- .../src/components/chat/PromptInput.tsx | 3 +- .../src/context/session-abort-press.ts | 65 ++++++++++++++ .../webview-ui/src/context/session.tsx | 4 - .../webview-ui/src/types/messages.ts | 1 - 11 files changed, 173 insertions(+), 72 deletions(-) create mode 100644 .changeset/double-esc-abort.md create mode 100644 packages/kilo-vscode/tests/unit/session-abort-press.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts diff --git a/.changeset/double-esc-abort.md b/.changeset/double-esc-abort.md new file mode 100644 index 0000000000..2e3658134f --- /dev/null +++ b/.changeset/double-esc-abort.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Pressing Escape once in the Kilo Code sidebar no longer aborts the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI. Queued messages stay visible so you can see what was waiting in the queue. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 46ad590eb6..85cd37481b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -54,7 +54,7 @@ import { clearCommandsCache, loadCommands } from "./kilo-provider/commands" import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page" import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" -import { abortSession, parseQueued } from "./kilo-provider/abort" +import { abortSession } from "./kilo-provider/abort" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { retryable, backoff, MAX_RETRIES } from "./util/retry" @@ -613,7 +613,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } case "abort": this.cancelRetry(message.sessionID ?? "") - await this.handleAbort(message.sessionID, parseQueued(message.queuedMessageIDs)) + await this.handleAbort(message.sessionID) break case "revertSession": this.handleRevertSession(message.sessionID, message.messageID).catch((e) => @@ -2558,7 +2558,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async handleAbort(sessionID?: string, queuedMessageIDs: string[] = []): Promise { + private async handleAbort(sessionID?: string): Promise { if (!this.client) { return } @@ -2573,7 +2573,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper client: this.client, sessionID: targetSessionID, dir: this.getWorkspaceDirectory(targetSessionID), - queuedMessageIDs, }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to abort session:", error) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 65e34a730b..5f4846c96c 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -623,7 +623,6 @@ interface ForkSessionIn { interface AbortIn { type: "abort" sessionID: string - queuedMessageIDs?: string[] } interface ContinueInWorktreeIn { diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index e295fea545..8d3d00676e 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,21 +1,5 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -export function parseQueued(value: unknown) { - if (!Array.isArray(value)) return [] - return value.filter((id): id is string => typeof id === "string") -} - -export async function abortSession(input: { - client: KiloClient - sessionID: string - dir: string - queuedMessageIDs: string[] -}) { +export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) { await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }) - - for (const mid of new Set(input.queuedMessageIDs)) { - await input.client.session - .deleteMessage({ sessionID: input.sessionID, messageID: mid, directory: input.dir }, { throwOnError: true }) - .catch((err) => console.error("[Kilo New] KiloProvider: Failed to remove queued message:", err)) - } } diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index c9002a650c..126333e6a4 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { abortSession, parseQueued } from "../../src/kilo-provider/abort" +import { abortSession } from "../../src/kilo-provider/abort" function client(calls: unknown[], fail = false) { return { @@ -10,35 +10,15 @@ function client(calls: unknown[], fail = false) { if (fail) throw new Error("abort failed") return { data: true } }, - deleteMessage: async (params: unknown, opts: unknown) => { - calls.push({ type: "delete", params, opts }) - return { data: true } - }, }, } as unknown as KiloClient } -describe("parseQueued", () => { - it("keeps only string queued message ids", () => { - expect(parseQueued(["message_1", 2, null, "message_2", {}])).toEqual(["message_1", "message_2"]) - }) - - it("returns empty ids for invalid payloads", () => { - expect(parseQueued(undefined)).toEqual([]) - expect(parseQueued({ queuedMessageIDs: ["message_1"] })).toEqual([]) - }) -}) - describe("abortSession", () => { - it("aborts before removing queued follow-up messages", async () => { + it("calls session.abort with the session id and directory", async () => { const calls: unknown[] = [] - await abortSession({ - client: client(calls), - sessionID: "session_1", - dir: "/repo", - queuedMessageIDs: ["message_2", "message_3", "message_2"], - }) + await abortSession({ client: client(calls), sessionID: "session_1", dir: "/repo" }) expect(calls).toEqual([ { @@ -46,30 +26,15 @@ describe("abortSession", () => { params: { sessionID: "session_1", directory: "/repo" }, opts: { throwOnError: true }, }, - { - type: "delete", - params: { sessionID: "session_1", messageID: "message_2", directory: "/repo" }, - opts: { throwOnError: true }, - }, - { - type: "delete", - params: { sessionID: "session_1", messageID: "message_3", directory: "/repo" }, - opts: { throwOnError: true }, - }, ]) }) - it("does not remove queued messages when abort fails", async () => { + it("rejects when the abort request fails", async () => { const calls: unknown[] = [] - await expect( - abortSession({ - client: client(calls, true), - sessionID: "session_1", - dir: "/repo", - queuedMessageIDs: ["message_2"], - }), - ).rejects.toThrow("abort failed") + await expect(abortSession({ client: client(calls, true), sessionID: "session_1", dir: "/repo" })).rejects.toThrow( + "abort failed", + ) expect(calls).toEqual([ { diff --git a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts new file mode 100644 index 0000000000..44c997179d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createAbortPressForTest } from "../../webview-ui/src/context/session-abort-press" + +// Minimal fake timer harness: `set` returns an incrementing id and stores the +// callback; `advance` runs the callback if called. Matches the subset of timer +// behavior the helper depends on (set, clear, expiry). +function fakeTimers() { + const queue = new Map void>() + let nextId = 0 + return { + set: (fn: () => void) => { + nextId += 1 + queue.set(nextId, fn) + return nextId as unknown as ReturnType + }, + clear: (t: ReturnType) => { + queue.delete(t as unknown as number) + }, + expire: (t: ReturnType) => { + const fn = queue.get(t as unknown as number) + if (!fn) return false + queue.delete(t as unknown as number) + fn() + return true + }, + pending: () => queue.size, + } +} + +describe("session-abort-press", () => { + it("requires two presses to trigger", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + expect(gate.press()).toBe(false) + expect(gate.count).toBe(1) + expect(gate.hasTimer).toBe(true) + + expect(gate.press()).toBe(true) + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + }) + + it("resets after the window elapses", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + expect(gate.press()).toBe(false) + expect(timers.pending()).toBe(1) + + // Expire the timer — simulates the 5s window elapsing with no second press. + timers.expire(1 as unknown as ReturnType) + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + + // Next press restarts the counter from 1. + expect(gate.press()).toBe(false) + expect(gate.count).toBe(1) + }) + + it("re-arms the timer on every press", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + gate.press() + // Press again before the window closes — prior timer is cleared, new one started. + // Since this is the second press, it triggers and clears (no pending timer). + gate.press() + expect(timers.pending()).toBe(0) + }) + + it("reset() clears count and timer", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + gate.press() + expect(gate.hasTimer).toBe(true) + + gate.reset() + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + expect(timers.pending()).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 7da3fa1dce..5114c5b441 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useWorktreeMode } from "../../context/worktree-mode" import { useServer } from "../../context/server" +import { registerAbortPress, resetAbortPress } from "../../context/session-abort-press" import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" interface ChatViewProps { @@ -89,10 +90,13 @@ export const ChatView: Component = (props) => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return e.preventDefault() - session.abort() + if (registerAbortPress()) session.abort() } document.addEventListener("keydown", handler) - onCleanup(() => document.removeEventListener("keydown", handler)) + onCleanup(() => { + document.removeEventListener("keydown", handler) + resetAbortPress() + }) }) // Listen for "Continue in Worktree" progress messages 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 c9f6c2bec0..6148317b54 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -17,6 +17,7 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" +import { registerAbortPress } from "../../context/session-abort-press" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" @@ -553,7 +554,7 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape" && isBusy()) { e.preventDefault() e.stopPropagation() - session.abort() + if (registerAbortPress()) session.abort() return } if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts new file mode 100644 index 0000000000..24b2c3e8bd --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts @@ -0,0 +1,65 @@ +// Shared counter for the double-Esc-to-abort gesture. +// Mirrors the CLI's `store.interrupt` in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx: +// press increments a counter and (re)starts a 5s reset timer; the second press within the window +// triggers abort and resets. + +const WINDOW_MS = 5000 + +interface Timers { + set: (fn: () => void, ms: number) => ReturnType + clear: (t: ReturnType) => void +} + +function press(state: { count: number; timer: ReturnType | undefined }, timers: Timers): boolean { + state.count += 1 + if (state.timer) timers.clear(state.timer) + if (state.count >= 2) { + state.count = 0 + state.timer = undefined + return true + } + state.timer = timers.set(() => { + state.count = 0 + state.timer = undefined + }, WINDOW_MS) + return false +} + +const defaults: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (t) => clearTimeout(t), +} + +const shared: { count: number; timer: ReturnType | undefined } = { count: 0, timer: undefined } + +// Registers an Esc press; returns true when this press is the second within the +// 5s window (and the caller should trigger abort). +export function registerAbortPress(): boolean { + return press(shared, defaults) +} + +// Resets the counter. Safe to call from anywhere (idle transitions, tests, etc.). +export function resetAbortPress(): void { + shared.count = 0 + if (shared.timer) defaults.clear(shared.timer) + shared.timer = undefined +} + +// Test-only factory: creates an isolated press-state with caller-supplied timers. +export function createAbortPressForTest(timers: Timers) { + const state = { count: 0, timer: undefined as ReturnType | undefined } + return { + press: () => press(state, timers), + reset: () => { + state.count = 0 + if (state.timer) timers.clear(state.timer) + state.timer = undefined + }, + get count() { + return state.count + }, + get hasTimer() { + return state.timer !== undefined + }, + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 485f4ba964..0a1936d723 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -46,7 +46,6 @@ import { import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { resolveSessionAgent } from "./session-agent" -import { queuedUserMessageIDs } from "./session-queue" import { PartStash } from "./part-stash" import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model" @@ -1748,12 +1747,9 @@ export const SessionProvider: ParentComponent = (props) => { return } - const queuedMessageIDs = queuedUserMessageIDs(messages(), statusInfo()) - vscode.postMessage({ type: "abort", sessionID, - queuedMessageIDs, }) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1a29297159..a631d4178b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1703,7 +1703,6 @@ export interface SendMessageRequest { export interface AbortRequest { type: "abort" sessionID: string - queuedMessageIDs?: string[] } export interface RevertSessionRequest { From 25dd19c7f78b6fa41e266921d5d9bdafd30a5f83 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 14:25:29 +0300 Subject: [PATCH 38/70] fix(vscode): reset abort counter when turn ends --- .../webview-ui/src/components/chat/ChatView.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 5114c5b441..f71ecaf6a0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -99,6 +99,17 @@ export const ChatView: Component = (props) => { }) }) + // Reset the double-Esc counter whenever the session returns to idle so a + // single Esc press from a prior turn cannot combine with a press in the next turn. + createEffect( + on( + () => session.status() === "idle", + (isIdle) => { + if (isIdle) resetAbortPress() + }, + ), + ) + // Listen for "Continue in Worktree" progress messages { const labels: Record = { From 3d7fb31307ebeb1fe0c726a624b506bad1e6c44b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 16:25:21 +0300 Subject: [PATCH 39/70] fix(vscode): restore single Escape to stop a running turn A single Escape in the Kilo Code sidebar stops the current turn again, the way it did before. Queued follow-up messages still stay visible after stopping so you can see what was waiting. --- .changeset/double-esc-abort.md | 5 -- .../tests/unit/session-abort-press.test.ts | 84 ------------------- .../src/components/chat/ChatView.tsx | 19 +---- .../src/components/chat/PromptInput.tsx | 3 +- .../src/context/session-abort-press.ts | 65 -------------- 5 files changed, 3 insertions(+), 173 deletions(-) delete mode 100644 .changeset/double-esc-abort.md delete mode 100644 packages/kilo-vscode/tests/unit/session-abort-press.test.ts delete mode 100644 packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts diff --git a/.changeset/double-esc-abort.md b/.changeset/double-esc-abort.md deleted file mode 100644 index 2e3658134f..0000000000 --- a/.changeset/double-esc-abort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Pressing Escape once in the Kilo Code sidebar no longer aborts the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI. Queued messages stay visible so you can see what was waiting in the queue. diff --git a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts deleted file mode 100644 index 44c997179d..0000000000 --- a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { createAbortPressForTest } from "../../webview-ui/src/context/session-abort-press" - -// Minimal fake timer harness: `set` returns an incrementing id and stores the -// callback; `advance` runs the callback if called. Matches the subset of timer -// behavior the helper depends on (set, clear, expiry). -function fakeTimers() { - const queue = new Map void>() - let nextId = 0 - return { - set: (fn: () => void) => { - nextId += 1 - queue.set(nextId, fn) - return nextId as unknown as ReturnType - }, - clear: (t: ReturnType) => { - queue.delete(t as unknown as number) - }, - expire: (t: ReturnType) => { - const fn = queue.get(t as unknown as number) - if (!fn) return false - queue.delete(t as unknown as number) - fn() - return true - }, - pending: () => queue.size, - } -} - -describe("session-abort-press", () => { - it("requires two presses to trigger", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - expect(gate.press()).toBe(false) - expect(gate.count).toBe(1) - expect(gate.hasTimer).toBe(true) - - expect(gate.press()).toBe(true) - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - }) - - it("resets after the window elapses", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - expect(gate.press()).toBe(false) - expect(timers.pending()).toBe(1) - - // Expire the timer — simulates the 5s window elapsing with no second press. - timers.expire(1 as unknown as ReturnType) - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - - // Next press restarts the counter from 1. - expect(gate.press()).toBe(false) - expect(gate.count).toBe(1) - }) - - it("re-arms the timer on every press", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - gate.press() - // Press again before the window closes — prior timer is cleared, new one started. - // Since this is the second press, it triggers and clears (no pending timer). - gate.press() - expect(timers.pending()).toBe(0) - }) - - it("reset() clears count and timer", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - gate.press() - expect(gate.hasTimer).toBe(true) - - gate.reset() - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - expect(timers.pending()).toBe(0) - }) -}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index f71ecaf6a0..7da3fa1dce 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -19,7 +19,6 @@ import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useWorktreeMode } from "../../context/worktree-mode" import { useServer } from "../../context/server" -import { registerAbortPress, resetAbortPress } from "../../context/session-abort-press" import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" interface ChatViewProps { @@ -90,26 +89,12 @@ export const ChatView: Component = (props) => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return e.preventDefault() - if (registerAbortPress()) session.abort() + session.abort() } document.addEventListener("keydown", handler) - onCleanup(() => { - document.removeEventListener("keydown", handler) - resetAbortPress() - }) + onCleanup(() => document.removeEventListener("keydown", handler)) }) - // Reset the double-Esc counter whenever the session returns to idle so a - // single Esc press from a prior turn cannot combine with a press in the next turn. - createEffect( - on( - () => session.status() === "idle", - (isIdle) => { - if (isIdle) resetAbortPress() - }, - ), - ) - // Listen for "Continue in Worktree" progress messages { const labels: Record = { 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 6148317b54..c9f6c2bec0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -17,7 +17,6 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { registerAbortPress } from "../../context/session-abort-press" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" @@ -554,7 +553,7 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape" && isBusy()) { e.preventDefault() e.stopPropagation() - if (registerAbortPress()) session.abort() + session.abort() return } if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts deleted file mode 100644 index 24b2c3e8bd..0000000000 --- a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Shared counter for the double-Esc-to-abort gesture. -// Mirrors the CLI's `store.interrupt` in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx: -// press increments a counter and (re)starts a 5s reset timer; the second press within the window -// triggers abort and resets. - -const WINDOW_MS = 5000 - -interface Timers { - set: (fn: () => void, ms: number) => ReturnType - clear: (t: ReturnType) => void -} - -function press(state: { count: number; timer: ReturnType | undefined }, timers: Timers): boolean { - state.count += 1 - if (state.timer) timers.clear(state.timer) - if (state.count >= 2) { - state.count = 0 - state.timer = undefined - return true - } - state.timer = timers.set(() => { - state.count = 0 - state.timer = undefined - }, WINDOW_MS) - return false -} - -const defaults: Timers = { - set: (fn, ms) => setTimeout(fn, ms), - clear: (t) => clearTimeout(t), -} - -const shared: { count: number; timer: ReturnType | undefined } = { count: 0, timer: undefined } - -// Registers an Esc press; returns true when this press is the second within the -// 5s window (and the caller should trigger abort). -export function registerAbortPress(): boolean { - return press(shared, defaults) -} - -// Resets the counter. Safe to call from anywhere (idle transitions, tests, etc.). -export function resetAbortPress(): void { - shared.count = 0 - if (shared.timer) defaults.clear(shared.timer) - shared.timer = undefined -} - -// Test-only factory: creates an isolated press-state with caller-supplied timers. -export function createAbortPressForTest(timers: Timers) { - const state = { count: 0, timer: undefined as ReturnType | undefined } - return { - press: () => press(state, timers), - reset: () => { - state.count = 0 - if (state.timer) timers.clear(state.timer) - state.timer = undefined - }, - get count() { - return state.count - }, - get hasTimer() { - return state.timer !== undefined - }, - } -} From db800c56527a188c84aa585068759546ef3c0039 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 09:36:52 -0400 Subject: [PATCH 40/70] refactor(jetbrains): extract SessionQueueCondenser into its own class Move condenser logic out of SessionUpdateQueue singleton object into a dedicated internal class with its own file, and update SessionUpdateQueue to hold an instance. The class doc describes the barrier-based algorithm and what event types are and are not merged. --- .../client/session/SessionQueueCondenser.kt | 66 +++++++++++ .../client/session/SessionUpdateQueue.kt | 66 +++++------ .../session/SessionQueueCondenserTest.kt | 106 ++++++++++++++++++ 3 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt new file mode 100644 index 0000000000..110d9d8ed1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt @@ -0,0 +1,66 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto + +/** + * Reduces a batch of queued [ChatEventDto] events before they are flushed to + * the model, by merging consecutive text [ChatEventDto.PartDelta] events that + * target the same part. + * + * ## Algorithm + * + * Events are scanned in arrival order. A temporary `deltas` map accumulates + * mergeable text deltas keyed by `(sessionId, messageId, partId, field)`. + * When a non-delta event arrives it acts as a **barrier** — all accumulated + * deltas are flushed into the output before the barrier event is appended. + * This preserves the original event ordering while collapsing N text chunks + * into one per part per batch. + * + * ## What is merged + * + * - `ChatEventDto.PartDelta` where `field == "text"` and same + * `(sessionId, messageId, partId, field)` key. + * + * ## What is not merged + * + * - `PartDelta` for non-text fields + * - `PartUpdated`, `MessageUpdated`, `SessionStatusChanged`, `SessionDiffChanged` + * and all other event types — these pass through unchanged + */ +internal class SessionQueueCondenser { + + fun condense(events: List): List { + if (events.size < 2) return events + val out = mutableListOf() + val deltas = LinkedHashMap() + + fun drain() { + if (deltas.isEmpty()) return + out.addAll(deltas.values) + deltas.clear() + } + + for (event in events) { + val delta = event as? ChatEventDto.PartDelta + val key = delta?.key() + if (key == null) { + drain() + out.add(event) + continue + } + val prev = deltas[key] + deltas[key] = if (prev != null) prev.merge(delta) else delta + } + + drain() + return out + } + + private fun ChatEventDto.PartDelta.key(): String? { + if (field != "text") return null + return "$sessionID:$messageID:$partID:$field" + } + + private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta = + ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt index 600adc9a2d..7a7709cfb2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -9,7 +11,7 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -internal const val EVENT_FLUSH_MS = 250L +internal const val EVENT_FLUSH_MS = 150L internal class SessionUpdateQueue( parent: Disposable, @@ -17,8 +19,14 @@ internal class SessionUpdateQueue( private val flushMs: Long = EVENT_FLUSH_MS, private val fire: (List) -> Unit, hold: Boolean, + private val sid: () -> String, ) : Disposable { + companion object { + private val LOG = KiloLog.create(SessionUpdateQueue::class.java) + } + private val app = ApplicationManager.getApplication() + private val condenser = SessionQueueCondenser() private val pending = mutableListOf() private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() private var last = 0L @@ -27,7 +35,7 @@ internal class SessionUpdateQueue( init { Disposer.register(parent, this) exec?.scheduleAtFixedRate( - { requestFlush(false) }, + { requestFlush(false, "tick") }, flushMs, flushMs, TimeUnit.MILLISECONDS, @@ -36,20 +44,25 @@ internal class SessionUpdateQueue( fun enqueue(event: ChatEventDto) { edt { + LOG.debug { "${ChatLogSummary.sid(sid())} enqueue pending=${pending.size + 1}" } pending.add(event) - flushNow(false) + flushNow(false, "enqueue") } } fun holdFlush(hold: Boolean) { - edt { this.hold = hold } + edt { + LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" } + this.hold = hold + } } - fun requestFlush(forced: Boolean) { - edt { flushNow(forced) } + fun requestFlush(forced: Boolean, source: String = "api") { + edt { flushNow(forced, source) } } override fun dispose() { + LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=${pending.size}" } exec?.shutdownNow() if (app.isDispatchThread) { pending.clear() @@ -58,15 +71,19 @@ internal class SessionUpdateQueue( app.invokeLater { pending.clear() } } - private fun flushNow(forced: Boolean) { + private fun flushNow(forced: Boolean, source: String) { if (hold) return if (!showing()) return if (pending.isEmpty()) return val now = System.currentTimeMillis() if (!forced && now - last < flushMs) return - val batch = condense(pending.toList()) + val before = pending.size + val types = pending.groupBy { it::class.simpleName } + .entries.joinToString(",") { (k, v) -> "$k:${v.size}" } + val batch = condenser.condense(pending.toList()) pending.clear() last = now + LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${batch.size} saved=${before - batch.size} types=$types" } fire(batch) } @@ -81,37 +98,4 @@ internal class SessionUpdateQueue( } } -private fun condense(events: List): List { - if (events.size < 2) return events - val out = mutableListOf() - val deltas = LinkedHashMap() - fun drain() { - if (deltas.isEmpty()) return - out.addAll(deltas.values) - deltas.clear() - } - - for (event in events) { - val delta = event as? ChatEventDto.PartDelta - val key = delta?.key() - if (key == null) { - drain() - out.add(event) - continue - } - val prev = deltas[key] - deltas[key] = if (prev != null) prev.merge(delta) else delta - } - - drain() - return out -} - -private fun ChatEventDto.PartDelta.key(): String? { - if (field != "text") return null - return "$sessionID:$messageID:$partID:$field" -} - -private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta = - ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt new file mode 100644 index 0000000000..47e6fa6014 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -0,0 +1,106 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto +import junit.framework.TestCase + +class SessionQueueCondenserTest : TestCase() { + + private val condenser = SessionQueueCondenser() + + private fun delta(msg: String, part: String, text: String) = + ChatEventDto.PartDelta("ses", msg, part, "text", text) + + private fun nonDelta(msg: String) = + ChatEventDto.TurnOpen(msg) + + fun `test empty list returns empty`() { + assertEquals(emptyList(), condenser.condense(emptyList())) + } + + fun `test single event returned unchanged`() { + val event = delta("m1", "p1", "hi") + assertEquals(listOf(event), condenser.condense(listOf(event))) + } + + fun `test two deltas for same part are merged`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "hello "), + delta("m1", "p1", "world"), + )) + assertEquals(1, result.size) + assertEquals("hello world", (result[0] as ChatEventDto.PartDelta).delta) + } + + fun `test many deltas for same part are all merged`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "a"), + delta("m1", "p1", "b"), + delta("m1", "p1", "c"), + )) + assertEquals(1, result.size) + assertEquals("abc", (result[0] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas for different parts are kept separate`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "foo"), + delta("m1", "p2", "bar"), + )) + assertEquals(2, result.size) + assertEquals("foo", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("bar", (result[1] as ChatEventDto.PartDelta).delta) + } + + fun `test non-text field deltas are not merged`() { + val d1 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk1") + val d2 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk2") + val result = condenser.condense(listOf(d1, d2)) + assertEquals(2, result.size) + } + + fun `test non-delta event flushes pending deltas before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + delta("m1", "p1", "x"), + delta("m1", "p1", "y"), + barrier, + delta("m1", "p1", "z"), + )) + assertEquals(3, result.size) + assertEquals("xy", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals(barrier, result[1]) + assertEquals("z", (result[2] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas after barrier are merged independently`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "a"), + nonDelta("t"), + delta("m1", "p1", "b"), + delta("m1", "p1", "c"), + )) + assertEquals(3, result.size) + assertEquals("a", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("bc", (result[2] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas for different messages are kept separate`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "hi"), + delta("m2", "p1", "there"), + )) + assertEquals(2, result.size) + assertEquals("hi", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("there", (result[1] as ChatEventDto.PartDelta).delta) + } + + fun `test merged delta uses session and part ids from last event`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "first"), + delta("m1", "p1", "second"), + )) as List + assertEquals("ses", result[0].sessionID) + assertEquals("m1", result[0].messageID) + assertEquals("p1", result[0].partID) + } +} From c4df6b80cbcfa2c59ce12765ebd83f3f58934a1f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:28:12 +0000 Subject: [PATCH 41/70] chore(cli): refine kilocode_change markers on encoding-preservation code Narrow overly-broad blocks, combine adjacent single-line markers, and expand inline comments to note what the replaced upstream code did. --- packages/opencode/src/patch/index.ts | 14 ++++++++------ packages/opencode/src/tool/apply_patch.ts | 8 +++----- packages/opencode/src/tool/edit.ts | 6 +++--- packages/opencode/src/tool/read.ts | 6 ++++-- packages/opencode/src/tool/write.ts | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index d26e0177a5..2b3e9c20b3 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -311,11 +311,13 @@ interface ApplyPatchFileUpdate { export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // Read original file content let originalContent: string - let encoding: string // kilocode_change + let encoding: string // kilocode_change - track detected encoding for round-trip write try { - const result = Encoding.readSync(filePath) // kilocode_change - encoding-aware read - originalContent = result.text // kilocode_change - encoding = result.encoding // kilocode_change + // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8") + const result = Encoding.readSync(filePath) + originalContent = result.text + encoding = result.encoding + // kilocode_change end } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } @@ -547,13 +549,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise { if (hunk.move_path) { // Handle file move - await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change + await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change + await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index d6a1be855e..d81511c551 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -211,25 +211,24 @@ export const ApplyPatchTool = Tool.define( // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] - // kilocode_change start - encoding-aware writes (EncodedIO.write mkdirs recursively) for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) switch (change.type) { case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) + yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) + yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -241,7 +240,6 @@ export const ApplyPatchTool = Tool.define( updates.push({ file: change.filePath, event: "unlink" }) break } - // kilocode_change end if (edited) { yield* format.file(edited) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 64dcd4435d..f7e17553b6 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -115,7 +115,7 @@ export const EditTool = Tool.define( filediff: cachedFilediff, // kilocode_change }, }) - yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change + yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs yield* format.file(filePath) yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { @@ -160,14 +160,14 @@ export const EditTool = Tool.define( }, }) - yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change + yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs yield* format.file(filePath) yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) - contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change + contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change - re-read via encoding-aware helper; replaces afs.readFileString diff = trimDiff( createTwoFilesPatch( filePath, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 9a5e284578..1ed37fd495 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -235,8 +235,10 @@ export const ReadTool = Tool.define( // kilocode_change start export async function lines(filepath: string, opts: { limit: number; offset: number }) { // kilocode_change end - const encoded = await Encoding.read(filepath) // kilocode_change - decode with detected encoding - const stream = Readable.from([encoded.text]) // kilocode_change - replaces createReadStream + // kilocode_change start - decode with detected encoding; replaces createReadStream(filepath, { encoding: "utf8" }) + const encoded = await Encoding.read(filepath) + const stream = Readable.from([encoded.text]) + // kilocode_change end const rl = createInterface({ input: stream, // Note: we use the crlfDelay option to recognize all instances of CR LF diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index f6eb77ebb2..2bfcf850d2 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -60,7 +60,7 @@ export const WriteTool = Tool.define( }, }) - yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change + yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change - preserve encoding; replaces fs.writeWithDirs yield* format.file(filepath) yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { From eccf6b0841fc4603782a3fc2a20e65d15ba67b74 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:28:52 +0000 Subject: [PATCH 42/70] docs: add file encoding preservation page --- packages/kilo-docs/lib/nav/code-with-ai.ts | 1 + .../code-with-ai/features/file-encoding.md | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/kilo-docs/pages/code-with-ai/features/file-encoding.md diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index e1d7b8f039..3668c18ef7 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -91,6 +91,7 @@ export const CodeWithAiNav: NavSection[] = [ children: "Task Todo List", }, { href: "/code-with-ai/features/checkpoints", children: "Checkpoints" }, + { href: "/code-with-ai/features/file-encoding", children: "File Encoding" }, ], }, ], diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md new file mode 100644 index 0000000000..cdff793ebd --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -0,0 +1,50 @@ +# File Encoding Preservation + +Kilo detects the text encoding of files before reading or editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. + +Previously every tool assumed UTF-8. Reading a Shift_JIS or Windows-1251 file would surface garbled text to the model, and editing it would corrupt the file on disk. + +## How It Works + +1. Files are read as raw bytes. +2. UTF-8 is tried first — if the bytes decode as valid UTF-8, the file is treated as UTF-8 (with the BOM tracked separately when present). +3. Otherwise, [jschardet](https://github.com/aadsm/jschardet) runs a statistical analysis to identify the encoding. +4. The detected encoding flows through `read_file`, `edit`, `write_to_file`, and `apply_patch`. +5. On write, [iconv-lite](https://github.com/ashtuchkin/iconv-lite) re-encodes to the original encoding and restores the BOM if one was present. +6. New files are created as UTF-8 without BOM. Detection only applies when reading or overwriting an existing file. + +Binary detection now consults the detected encoding first, so UTF-16 files (which contain null bytes) and CJK-encoded files are no longer incorrectly rejected as binary. + +## Supported Encodings + +- UTF-8 (with or without BOM) +- UTF-16 LE/BE **with BOM** +- Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR +- Windows-1251, KOI8-R +- ISO-8859 family +- Other legacy encodings recognized by jschardet + +## Not Supported + +- UTF-16 without BOM (ambiguous with other byte-oriented encodings) +- UTF-32 + +{% callout type="info" %} +Detection is statistical. Very short files, or files whose byte distribution is ambiguous, may be detected as a different encoding than the one they were saved with. +{% /callout %} + +## Reporting Issues + +If Kilo reads a file as garbled text or writes it back in a different encoding, please open an issue at [github.com/Kilo-Org/kilocode/issues](https://github.com/Kilo-Org/kilocode/issues) and include: + +- **A file that reproduces the issue.** Attach the actual file; don't paste its contents into the issue body, since that will change the encoding. +- **The exact name of the encoding** the file is saved in (for example `Shift_JIS`, `windows-1251`, `UTF-16 LE with BOM`). +- **A hash of the file** so we can verify it wasn't corrupted in transit. On macOS and Linux: + ```bash + shasum -a 256 path/to/file + ``` + On Windows: + ```powershell + Get-FileHash path\to\file -Algorithm SHA256 + ``` +- **The model and provider** you were using when the issue occurred (for example `anthropic/claude-sonnet-4.5` via Kilo Gateway, or `gpt-4o` via OpenAI). From a4340ac1ac473807792e7a22f5c924f827cc6318 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 23 Apr 2026 14:35:04 +0000 Subject: [PATCH 43/70] 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 9e0f175bd5..4df7173a04 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-y/4I69G/gb0ZyPRdZx7+h/pTyv9TWZIhM1w9ZJ7Ac48=", - "aarch64-linux": "sha256-RmFPbU09RDOER+KgmknLpKsUO008JeMRRlCK4XkAXwo=", - "aarch64-darwin": "sha256-WtA8nnntNaOI8RpQYoGyItofNJBiF3f58r++iggkrUg=", - "x86_64-darwin": "sha256-WSg/P8mAkc0nH59LYer/56xKFuZz/8sXr+6HoZEH/pc=" + "x86_64-linux": "sha256-PnZh7bJ97lE8BsAiICc653OV468GjehAs9lXV11DKXw=", + "aarch64-linux": "sha256-VMcH747sR0wDCOXXDbnHQQid9WeigWr/HtRrj2yHxuU=", + "aarch64-darwin": "sha256-lsNiZcz7NjGmuls22z2VCbXwsr1h3Q0DlnI46r5RxeA=", + "x86_64-darwin": "sha256-z4yto3ZjuXpKP5vP5ppl2oX7T0EleSQAByyMqPO0uiE=" } } From 397c98ab31933eebad0722f3baa871f8a3da5c93 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:36:56 +0000 Subject: [PATCH 44/70] docs: add frontmatter and correct binary-detection claim --- .../kilo-docs/pages/code-with-ai/features/file-encoding.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md index cdff793ebd..fe4729f870 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -1,3 +1,8 @@ +--- +title: "File Encoding" +description: "How Kilo detects and preserves file encodings when reading and editing files" +--- + # File Encoding Preservation Kilo detects the text encoding of files before reading or editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. @@ -13,7 +18,7 @@ Previously every tool assumed UTF-8. Reading a Shift_JIS or Windows-1251 file wo 5. On write, [iconv-lite](https://github.com/ashtuchkin/iconv-lite) re-encodes to the original encoding and restores the BOM if one was present. 6. New files are created as UTF-8 without BOM. Detection only applies when reading or overwriting an existing file. -Binary detection now consults the detected encoding first, so UTF-16 files (which contain null bytes) and CJK-encoded files are no longer incorrectly rejected as binary. +The binary-file heuristic also skips UTF-16 BOM files, so files containing legitimate null bytes are no longer rejected as binary. ## Supported Encodings From 9a985229de4b3cd917d8ec8c358a91513a9dea07 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:48:41 +0000 Subject: [PATCH 45/70] docs: refine file-encoding page for end-user focus --- .../code-with-ai/features/file-encoding.md | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md index fe4729f870..80b6939aa9 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -1,55 +1,50 @@ --- title: "File Encoding" -description: "How Kilo detects and preserves file encodings when reading and editing files" +description: "How Kilo handles text file encodings when reading and editing files" --- -# File Encoding Preservation +# File Encoding -Kilo detects the text encoding of files before reading or editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. - -Previously every tool assumed UTF-8. Reading a Shift_JIS or Windows-1251 file would surface garbled text to the model, and editing it would corrupt the file on disk. - -## How It Works - -1. Files are read as raw bytes. -2. UTF-8 is tried first — if the bytes decode as valid UTF-8, the file is treated as UTF-8 (with the BOM tracked separately when present). -3. Otherwise, [jschardet](https://github.com/aadsm/jschardet) runs a statistical analysis to identify the encoding. -4. The detected encoding flows through `read_file`, `edit`, `write_to_file`, and `apply_patch`. -5. On write, [iconv-lite](https://github.com/ashtuchkin/iconv-lite) re-encodes to the original encoding and restores the BOM if one was present. -6. New files are created as UTF-8 without BOM. Detection only applies when reading or overwriting an existing file. - -The binary-file heuristic also skips UTF-16 BOM files, so files containing legitimate null bytes are no longer rejected as binary. +Kilo automatically detects the text encoding of each file it reads and preserves that encoding when writing changes back. You can work with source files in any supported encoding without worrying about Kilo corrupting them or showing the model garbled text. ## Supported Encodings -- UTF-8 (with or without BOM) -- UTF-16 LE/BE **with BOM** +- UTF-8, with or without BOM +- UTF-16 LE and UTF-16 BE, **with a BOM** - Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR - Windows-1251, KOI8-R -- ISO-8859 family -- Other legacy encodings recognized by jschardet +- The ISO-8859 family +- Other common legacy Latin and CJK encodings + +New files Kilo creates are always UTF-8 without a BOM. Encoding detection only runs when Kilo reads or overwrites an existing file. ## Not Supported -- UTF-16 without BOM (ambiguous with other byte-oriented encodings) -- UTF-32 +- **UTF-16 without a BOM.** The byte pattern is ambiguous and cannot be distinguished reliably from other encodings. Save the file with a BOM or convert it to UTF-8. +- **UTF-32.** Extremely rare in practice; convert to UTF-8 if you need Kilo to work with it. {% callout type="info" %} -Detection is statistical. Very short files, or files whose byte distribution is ambiguous, may be detected as a different encoding than the one they were saved with. +Encoding detection is statistical. Very short files, or files whose byte patterns happen to look like a different encoding, may occasionally be misidentified. If that happens, converting the file to UTF-8 is the most reliable workaround. {% /callout %} ## Reporting Issues -If Kilo reads a file as garbled text or writes it back in a different encoding, please open an issue at [github.com/Kilo-Org/kilocode/issues](https://github.com/Kilo-Org/kilocode/issues) and include: +If Kilo displays a file as garbled text, or writes it back in a different encoding than it was saved in, please open an issue at [github.com/Kilo-Org/kilocode/issues](https://github.com/Kilo-Org/kilocode/issues) and include all of the following: + +- **A file that reproduces the issue.** Attach the actual file to the issue — do not paste its contents into the issue body, since the web form will re-encode the text. +- **The exact name of the encoding** the file is saved in, for example `Shift_JIS`, `windows-1251`, or `UTF-16 LE with BOM`. +- **A SHA-256 hash of the attached file** so we can confirm it wasn't corrupted when uploaded. + + On macOS or Linux: -- **A file that reproduces the issue.** Attach the actual file; don't paste its contents into the issue body, since that will change the encoding. -- **The exact name of the encoding** the file is saved in (for example `Shift_JIS`, `windows-1251`, `UTF-16 LE with BOM`). -- **A hash of the file** so we can verify it wasn't corrupted in transit. On macOS and Linux: ```bash shasum -a 256 path/to/file ``` + On Windows: + ```powershell Get-FileHash path\to\file -Algorithm SHA256 ``` -- **The model and provider** you were using when the issue occurred (for example `anthropic/claude-sonnet-4.5` via Kilo Gateway, or `gpt-4o` via OpenAI). + +- **The model and provider** you were using when the issue occurred, for example `claude-sonnet-4.5` via Kilo Gateway, or `gpt-4o` via OpenAI. From bde1a4ae88264d2bc1901d4a4592bc72d9cb2f6a Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:49:05 +0000 Subject: [PATCH 46/70] test(cli): expand encoding preservation coverage Add unit tests for the Encoding namespace and extend the tool integration suite with ApplyPatch delete and EditTool replaceAll paths for non-UTF-8 files. --- .../opencode/test/kilocode/encoding.test.ts | 245 ++++++++++++++++++ .../test/kilocode/tool-encoding.test.ts | 51 ++++ 2 files changed, 296 insertions(+) create mode 100644 packages/opencode/test/kilocode/encoding.test.ts diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts new file mode 100644 index 0000000000..bd00379c12 --- /dev/null +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -0,0 +1,245 @@ +// kilocode_change - new file +// Unit tests for the Encoding namespace. These complement tool-encoding.test.ts +// by exercising detect/decode/encode/read/write/readSync directly, without +// going through the Effect runtime, agent harness, or tool pipeline. They are +// cheap, fast, and cover the internal branches (BOM handling, ASCII/UTF-8 +// normalization, jschardet fallback, unsupported encoding rejection) that the +// integration tests cannot hit deterministically. + +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import iconv from "iconv-lite" +import { Encoding } from "../../src/kilocode/encoding" + +const BOM = { + utf8: Buffer.from([0xef, 0xbb, 0xbf]), + utf16le: Buffer.from([0xff, 0xfe]), + utf16be: Buffer.from([0xfe, 0xff]), +} + +async function tmp(body: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-encoding-")) + try { + return await body(dir) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe("Encoding.detect", () => { + test("empty buffer falls back to utf-8", () => { + expect(Encoding.detect(Buffer.alloc(0))).toBe(Encoding.DEFAULT) + }) + + test("plain ASCII is normalized to utf-8 (not 'ascii')", () => { + // jschardet reports "ascii" for pure-ASCII input; the namespace treats + // that as UTF-8 because UTF-8 is an ASCII superset and iconv-lite doesn't + // expose an "ascii" label that round-trips identically. + expect(Encoding.detect(Buffer.from("plain ascii text\n"))).toBe("utf-8") + }) + + test("valid UTF-8 without BOM detects as utf-8", () => { + expect(Encoding.detect(Buffer.from("Hello — 世界", "utf-8"))).toBe("utf-8") + }) + + test("UTF-8 with BOM is reported as the distinct utf-8-bom variant", () => { + const bytes = Buffer.concat([BOM.utf8, Buffer.from("hello", "utf-8")]) + expect(Encoding.detect(bytes)).toBe(Encoding.UTF8_BOM) + }) + + test("BOM-less UTF-8 containing multi-byte chars is not misdetected", () => { + // Regression guard: bytes that are valid UTF-8 must skip the jschardet + // branch. jschardet has been known to misfire on short CJK samples. + expect(Encoding.detect(Buffer.from("한글 テスト 中文", "utf-8"))).toBe("utf-8") + }) + + test("UTF-16 LE with BOM detects as utf-16le", () => { + const bytes = Buffer.concat([BOM.utf16le, iconv.encode("hello world", "utf-16le")]) + expect(Encoding.detect(bytes)).toBe("utf-16le") + }) + + test("UTF-16 BE with BOM detects as utf-16be", () => { + const bytes = Buffer.concat([BOM.utf16be, iconv.encode("hello world", "utf-16be")]) + expect(Encoding.detect(bytes)).toBe("utf-16be") + }) + + test("UTF-32 (detected by jschardet) is rejected and falls back to utf-8", () => { + // Build a sample starting with a UTF-32 LE BOM. jschardet will report + // UTF-32*; the namespace explicitly strips that because iconv-lite can't + // round-trip it. + const bytes = Buffer.concat([Buffer.from([0xff, 0xfe, 0x00, 0x00]), Buffer.alloc(32)]) + expect(Encoding.detect(bytes)).toBe(Encoding.DEFAULT) + }) + + test("Shift_JIS bytes detect as Shift_JIS (case-insensitive, iconv-compatible label)", () => { + const bytes = iconv.encode("こんにちは、世界!日本語のテストです。", "Shift_JIS") + const detected = Encoding.detect(bytes) + expect(detected.toLowerCase()).toBe("shift_jis") + // The returned label must be accepted by iconv-lite so downstream decode + // works without a second normalization step. + expect(iconv.encodingExists(detected)).toBe(true) + }) + + test("Windows-1251 bytes detect as windows-1251", () => { + const bytes = iconv.encode("Привет, мир! Это тест кириллицы.", "windows-1251") + expect(Encoding.detect(bytes)).toBe("windows-1251") + }) +}) + +describe("Encoding.decode / Encoding.encode", () => { + const cases: Array<[string, string, string]> = [ + ["utf-8", "utf-8", "Hello — £100"], + ["utf-8-bom synthetic label", Encoding.UTF8_BOM, "hello"], + ["utf-16le", "utf-16le", "Hello 世界"], + ["utf-16be", "utf-16be", "Hello 世界"], + ["Shift_JIS", "Shift_JIS", "日本語"], + ["windows-1251", "windows-1251", "Привет"], + ["gb2312", "gb2312", "你好"], + ["big5", "big5", "繁體"], + ["euc-kr", "euc-kr", "한국어"], + ["koi8-r", "koi8-r", "Привет"], + ["iso-8859-1", "iso-8859-1", "Hëllo Wörld"], + ] + + for (const [label, encoding, text] of cases) { + test(`round-trips ${label}`, () => { + const bytes = Encoding.encode(text, encoding) + expect(Encoding.decode(bytes, encoding)).toBe(text) + }) + } + + test("utf-8-bom encode emits exactly one BOM even if input starts with U+FEFF", () => { + // Regression guard: writers may hand us text that was previously decoded + // and still carries U+FEFF. The encoder must strip it to avoid doubling. + const bytes = Encoding.encode("\uFEFFhello", Encoding.UTF8_BOM) + expect(bytes.subarray(0, 3).equals(BOM.utf8)).toBe(true) + expect(bytes.subarray(3, 6).equals(BOM.utf8)).toBe(false) + expect(Encoding.decode(bytes, Encoding.UTF8_BOM)).toBe("hello") + }) + + test("utf-16le encode emits exactly one BOM even if input starts with U+FEFF", () => { + const bytes = Encoding.encode("\uFEFFhi", "utf-16le") + expect(bytes.subarray(0, 2).equals(BOM.utf16le)).toBe(true) + // Next two bytes must be the 'h' code unit (0x68 0x00), not another BOM. + expect(bytes[2]).toBe(0x68) + expect(bytes[3]).toBe(0x00) + }) + + test("utf-16be encode emits exactly one BOM even if input starts with U+FEFF", () => { + const bytes = Encoding.encode("\uFEFFhi", "utf-16be") + expect(bytes.subarray(0, 2).equals(BOM.utf16be)).toBe(true) + expect(bytes[2]).toBe(0x00) + expect(bytes[3]).toBe(0x68) + }) + + test("decode of utf-8-bom produces text without leading U+FEFF", () => { + // iconv-lite's utf-8 codec is documented to strip BOMs; guard against + // regressions if the underlying behaviour changes. + const bytes = Buffer.concat([BOM.utf8, Buffer.from("abc", "utf-8")]) + expect(Encoding.decode(bytes, Encoding.UTF8_BOM)).toBe("abc") + }) +}) + +describe("Encoding.hasUtf16Bom", () => { + test("detects LE BOM", () => { + expect(Encoding.hasUtf16Bom(BOM.utf16le)).toBe(true) + }) + test("detects BE BOM", () => { + expect(Encoding.hasUtf16Bom(BOM.utf16be)).toBe(true) + }) + test("returns false for UTF-8 BOM", () => { + expect(Encoding.hasUtf16Bom(BOM.utf8)).toBe(false) + }) + test("returns false for plain ASCII", () => { + expect(Encoding.hasUtf16Bom(Buffer.from("ab"))).toBe(false) + }) + test("respects an explicit limit smaller than the buffer", () => { + // Passing limit<2 must treat the sample as too short to contain a BOM, + // even if the underlying buffer starts with one. This matches the binary + // detection call site which reads a bounded sample. + expect(Encoding.hasUtf16Bom(BOM.utf16le, 1)).toBe(false) + expect(Encoding.hasUtf16Bom(BOM.utf16le, 2)).toBe(true) + }) + test("returns false for a one-byte buffer", () => { + expect(Encoding.hasUtf16Bom(Buffer.from([0xff]))).toBe(false) + }) +}) + +describe("Encoding.read / Encoding.readSync / Encoding.write", () => { + test("read detects and decodes Shift_JIS asynchronously", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "sj.txt") + const text = "日本語テスト" + await fs.writeFile(filepath, iconv.encode(text, "Shift_JIS")) + const result = await Encoding.read(filepath) + expect(result.text).toBe(text) + expect(result.encoding.toLowerCase()).toBe("shift_jis") + }) + }) + + test("readSync mirrors read for the same input", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "sj.txt") + const text = "日本語テスト" + await fs.writeFile(filepath, iconv.encode(text, "Shift_JIS")) + const sync = Encoding.readSync(filepath) + const async_ = await Encoding.read(filepath) + expect(sync).toEqual(async_) + }) + }) + + test("read preserves UTF-8 BOM as a distinct encoding label", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "bom.txt") + await fs.writeFile(filepath, Buffer.concat([BOM.utf8, Buffer.from("hi", "utf-8")])) + const result = await Encoding.read(filepath) + expect(result.encoding).toBe(Encoding.UTF8_BOM) + expect(result.text).toBe("hi") + }) + }) + + test("write creates missing parent directories", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "nested", "deeply", "file.txt") + await Encoding.write(filepath, "hello", "utf-8") + const bytes = await fs.readFile(filepath) + expect(bytes.equals(Buffer.from("hello", "utf-8"))).toBe(true) + }) + }) + + test("write defaults to utf-8 when encoding is omitted", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "default.txt") + await Encoding.write(filepath, "héllo") + const bytes = await fs.readFile(filepath) + expect(bytes.equals(Buffer.from("héllo", "utf-8"))).toBe(true) + }) + }) + + test("write round-trips Shift_JIS bytes exactly", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "sj.txt") + const text = "日本語" + await Encoding.write(filepath, text, "Shift_JIS") + const bytes = await fs.readFile(filepath) + expect(bytes.equals(iconv.encode(text, "Shift_JIS"))).toBe(true) + // Must not be UTF-8 — regression guard against silent promotion. + expect(bytes.equals(Buffer.from(text, "utf-8"))).toBe(false) + }) + }) + + test("write + read round-trips utf-16le with BOM", async () => { + await tmp(async (dir) => { + const filepath = path.join(dir, "u16.txt") + const text = "Hello 世界" + await Encoding.write(filepath, text, "utf-16le") + const bytes = await fs.readFile(filepath) + expect(bytes.subarray(0, 2).equals(BOM.utf16le)).toBe(true) + const result = await Encoding.read(filepath) + expect(result.encoding).toBe("utf-16le") + expect(result.text).toBe(text) + }) + }) +}) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 54cf201d3e..378fe0b27b 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -323,6 +323,57 @@ describe("tool encoding preservation", () => { }), ), ) + + // Deletes exercise a code path in patch/index.ts that doesn't write bytes + // back — verify it still works when the target file is non-UTF-8, because + // the deletion code has to decode the old contents to confirm match. + it.live("deletes a Windows-1251 file without UTF-8 corruption errors", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "legacy.txt") + yield* putEncoded(filepath, samples.windows1251, "windows-1251") + const patch = ["*** Begin Patch", "*** Delete File: legacy.txt", "*** End Patch"].join("\n") + yield* runPatch({ patchText: patch }) + const exists = yield* Effect.promise(() => + fs + .access(filepath) + .then(() => true) + .catch(() => false), + ) + expect(exists).toBe(false) + }), + ), + ) + }) + + // EditTool's replaceAll path rewrites the entire buffer and re-encodes it + // in one shot — regression guard that re-encoding a multi-occurrence edit in + // a legacy encoding yields byte-exact output. + describe("EditTool replaceAll preserves non-UTF-8 encoding", () => { + it.live("replaces every occurrence in Shift_JIS", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "doc.txt") + // Pad with additional Shift_JIS text so jschardet has enough bytes + // to confidently identify the encoding. + const pad = samples.shiftJis + "\n" + const original = pad + "日本語\n日本語\n日本語\n" + pad + yield* putEncoded(filepath, original, "Shift_JIS") + yield* markRead(filepath) + + yield* runEdit({ filePath: filepath, oldString: "日本語", newString: "ニホンゴ", replaceAll: true }) + + const expected = + pad.replaceAll("日本語", "ニホンゴ") + + "ニホンゴ\nニホンゴ\nニホンゴ\n" + + pad.replaceAll("日本語", "ニホンゴ") + const decoded = yield* loadDecoded(filepath, "Shift_JIS") + expect(decoded).toBe(expected) + const bytes = yield* loadBytes(filepath) + expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true) + }), + ), + ) }) }) From 6ee160f89c10293d635990798779988d34b092b4 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 17:56:56 +0300 Subject: [PATCH 47/70] fix(cli): preserve prompt text on dismiss --- .../preserve-input-across-blocking-overlays.md | 5 +++++ packages/opencode/src/cli/cmd/tui/plugin/slots.tsx | 14 +++++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/preserve-input-across-blocking-overlays.md diff --git a/.changeset/preserve-input-across-blocking-overlays.md b/.changeset/preserve-input-across-blocking-overlays.md new file mode 100644 index 0000000000..fe4a59786a --- /dev/null +++ b/.changeset/preserve-input-across-blocking-overlays.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve typed text in the main prompt when a blocking question, suggestion, permission, or network overlay is shown and then dismissed. diff --git a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx index a6d5ba7f95..d9bc05d726 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx @@ -1,5 +1,6 @@ import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@kilocode/plugin/tui" import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid" +import { children } from "solid-js" import { isRecord } from "@/util/record" type RuntimeSlotMap = TuiSlotMap> @@ -21,7 +22,18 @@ function empty(_props: TuiSlotProps) { let view: Slot = empty -export const Slot: Slot = (props) => view(props) +export const Slot = (props: TuiSlotProps) => { + // kilocode_change start - stabilize fallback children so replace-mode slots + // don't recreate stateful defaults like the session prompt on prop changes. + const value = children(() => props.children) + return view({ + ...props, + get children() { + return value() + }, + } as TuiSlotProps) + // kilocode_change end +} function isHostSlotPlugin(value: unknown): value is HostSlotPlugin> { if (!isRecord(value)) return false 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 001f66c130..b9cc4bce5e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1295,7 +1295,8 @@ export function Session() { {/* kilocode_change end */} - + {/* kilocode_change start */} + + {/* kilocode_change end */} From 5e50fcb8bb18ac2093828222eab1cd8a45c7faa5 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:06:27 +0000 Subject: [PATCH 48/70] docs: name encoding detection and decoding libraries --- packages/kilo-docs/pages/code-with-ai/features/file-encoding.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md index 80b6939aa9..d3cffb4038 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -14,7 +14,7 @@ Kilo automatically detects the text encoding of each file it reads and preserves - Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR - Windows-1251, KOI8-R - The ISO-8859 family -- Other common legacy Latin and CJK encodings +- Other common legacy Latin and CJK encodings detected by [jschardet](https://github.com/aadsm/jschardet) and decoded by [iconv-lite](https://github.com/ashtuchkin/iconv-lite) New files Kilo creates are always UTF-8 without a BOM. Encoding detection only runs when Kilo reads or overwrites an existing file. From 69313d3e59fa730b192fa5b7392f6d6782bdc14e Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:11:36 +0000 Subject: [PATCH 49/70] docs: ask reporters for Kilo version, drop gpt-4o example --- .../kilo-docs/pages/code-with-ai/features/file-encoding.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md index d3cffb4038..a6d22e2c3c 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -47,4 +47,5 @@ If Kilo displays a file as garbled text, or writes it back in a different encodi Get-FileHash path\to\file -Algorithm SHA256 ``` -- **The model and provider** you were using when the issue occurred, for example `claude-sonnet-4.5` via Kilo Gateway, or `gpt-4o` via OpenAI. +- **The model and provider** you were using when the issue occurred, for example `claude-sonnet-4.5` via Kilo Gateway. +- **The exact Kilo version** you are running. For the CLI, run `kilo --version`. For the VS Code extension, open the Extensions view and check the version next to "Kilo Code". From 852eb58c758227b2c4ea32804f19db5ec1950950 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 18:12:49 +0300 Subject: [PATCH 50/70] fix(cli): dismiss hidden autocomplete --- .../cmd/tui/component/prompt/autocomplete.tsx | 18 ++++++++++++++++-- .../src/cli/cmd/tui/component/prompt/index.tsx | 8 ++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index f94b37f339..08c1376dc1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -52,6 +52,8 @@ export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void onCursorChange: () => void + // kilocode_change - let the prompt close autocomplete without mutating draft text + dismiss: () => void visible: false | "@" | "/" } @@ -487,6 +489,13 @@ export function Autocomplete(props: { }) } + // kilocode_change start - keep slash text intact when overlays hide the prompt, + // but still allow normal autocomplete dismissal to clean it up. + function dismiss() { + command.keybinds(true) + setStore("visible", false) + } + function hide() { const text = props.input().plainText if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { @@ -497,15 +506,20 @@ export function Autocomplete(props: { draft.input = props.input().plainText }) } - command.keybinds(true) - setStore("visible", false) + dismiss() } + // kilocode_change end onMount(() => { props.ref({ get visible() { return store.visible }, + // kilocode_change start + dismiss() { + dismiss() + }, + // kilocode_change end onInput(value) { if (store.visible) { if ( diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 3e6873a3c3..ccea4dc35c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -439,6 +439,14 @@ export function Prompt(props: PromptProps) { props.ref?.(undefined) }) + createEffect(() => { + // kilocode_change start - close autocomplete while blocking overlays hide the prompt + if (props.visible === false || props.disabled) { + auto()?.dismiss() + } + // kilocode_change end + }) + createEffect(() => { if (!input || input.isDestroyed) return if (props.visible === false || dialog.stack.length > 0) { From 1233d081c5451763bcf77aa4509f65146a9569be Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 11:24:22 -0400 Subject: [PATCH 51/70] feat(jetbrains): coalesce PartUpdated, MessageUpdated, SessionStatusChanged, SessionDiffChanged in condenser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend SessionQueueCondenser with latest-snapshot-wins coalescing for PartUpdated (by messageId/partId), MessageUpdated (by messageId), SessionStatusChanged (by sessionId), and SessionDiffChanged (by sessionId). State events drain before part updates which drain before text deltas, preserving the message-before-part ordering guarantee. PartDelta and PartUpdated act as mutual barriers. TurnOpen/TurnClose and other lifecycle events remain barriers that split accumulation groups. Production logs show 53% event reduction on mixed tool+state flush batches (17 pending → 8 condensed). Previously these state-event batches always showed saved=0. --- .../client/session/SessionController.kt | 2 +- .../client/session/SessionQueueCondenser.kt | 109 ++++++-- .../session/SessionControllerTestBase.kt | 4 + .../session/SessionQueueCondenserTest.kt | 253 +++++++++++++++++- .../client/session/SessionUpdateQueueTest.kt | 99 +++++++ 5 files changed, 442 insertions(+), 25 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 8ac3c55c5d..c597e4bd7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -72,7 +72,7 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory - private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, id != null) + private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, id != null) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt index 110d9d8ed1..cb6b85d766 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt @@ -4,28 +4,40 @@ import ai.kilocode.rpc.dto.ChatEventDto /** * Reduces a batch of queued [ChatEventDto] events before they are flushed to - * the model, by merging consecutive text [ChatEventDto.PartDelta] events that - * target the same part. + * the model, by merging consecutive same-key snapshot and text-delta events. * * ## Algorithm * - * Events are scanned in arrival order. A temporary `deltas` map accumulates - * mergeable text deltas keyed by `(sessionId, messageId, partId, field)`. - * When a non-delta event arrives it acts as a **barrier** — all accumulated - * deltas are flushed into the output before the barrier event is appended. - * This preserves the original event ordering while collapsing N text chunks - * into one per part per batch. + * Events are scanned in arrival order. Three ordered accumulators hold + * mergeable events keyed by their identity. Any non-mergeable event acts as a + * **barrier** — all accumulated events are flushed into the output before the + * barrier event is appended. This preserves the original ordering while + * collapsing N updates into one per key per batch. * * ## What is merged * * - `ChatEventDto.PartDelta` where `field == "text"` and same - * `(sessionId, messageId, partId, field)` key. + * `(sessionId, messageId, partId, field)` key. Text is concatenated. + * - `ChatEventDto.PartUpdated` for the same `(sessionId, messageId, partId)`. + * Latest snapshot wins. + * - `ChatEventDto.MessageUpdated` for the same `messageId`. + * Latest snapshot wins. + * - `ChatEventDto.SessionStatusChanged` for the same `sessionId`. + * Latest snapshot wins. + * - `ChatEventDto.SessionDiffChanged` for the same `sessionId`. + * Latest snapshot wins. * * ## What is not merged * * - `PartDelta` for non-text fields - * - `PartUpdated`, `MessageUpdated`, `SessionStatusChanged`, `SessionDiffChanged` - * and all other event types — these pass through unchanged + * - `PartDelta` and `PartUpdated` do not merge across each other + * - No event merges across a barrier (TurnOpen, TurnClose, Error, etc.) + * + * ## Drain order + * + * When a barrier is encountered or the batch ends, accumulators drain in this + * order: state events first, then part updates, then text deltas. This ensures + * a message is always flushed before the part updates that depend on it. */ internal class SessionQueueCondenser { @@ -33,23 +45,77 @@ internal class SessionQueueCondenser { if (events.size < 2) return events val out = mutableListOf() val deltas = LinkedHashMap() + val parts = LinkedHashMap() + val states = LinkedHashMap() - fun drain() { + fun drainDeltas() { if (deltas.isEmpty()) return out.addAll(deltas.values) deltas.clear() } + fun drainParts() { + if (parts.isEmpty()) return + out.addAll(parts.values) + parts.clear() + } + + fun drainStates() { + if (states.isEmpty()) return + out.addAll(states.values) + states.clear() + } + + fun drain() { + drainStates() + drainParts() + drainDeltas() + } + for (event in events) { - val delta = event as? ChatEventDto.PartDelta - val key = delta?.key() - if (key == null) { - drain() - out.add(event) - continue + when (event) { + is ChatEventDto.PartDelta -> { + val key = event.key() + if (key == null) { + drain() + out.add(event) + continue + } + drainParts() + drainStates() + val prev = deltas[key] + deltas[key] = if (prev != null) prev.merge(event) else event + } + + is ChatEventDto.PartUpdated -> { + drainDeltas() + drainStates() + parts[event.key()] = event + } + + is ChatEventDto.MessageUpdated -> { + drainDeltas() + drainParts() + states["MU:${event.info.id}"] = event + } + + is ChatEventDto.SessionStatusChanged -> { + drainDeltas() + drainParts() + states["SC:${event.sessionID}"] = event + } + + is ChatEventDto.SessionDiffChanged -> { + drainDeltas() + drainParts() + states["SDC:${event.sessionID}"] = event + } + + else -> { + drain() + out.add(event) + } } - val prev = deltas[key] - deltas[key] = if (prev != null) prev.merge(delta) else delta } drain() @@ -61,6 +127,9 @@ internal class SessionQueueCondenser { return "$sessionID:$messageID:$partID:$field" } + private fun ChatEventDto.PartUpdated.key(): String = + "$sessionID:${part.messageID}:${part.id}" + private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta = ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 8c33d4f6ce..8426a028b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -214,6 +214,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { type: String, text: String? = null, tool: String? = null, + state: String? = null, + title: String? = null, ) = PartDto( id = id, sessionID = sid, @@ -221,6 +223,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { type = type, text = text, tool = tool, + state = state, + title = title, ) protected fun workspaceReady( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt index 47e6fa6014..0e5cf03e17 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -1,6 +1,11 @@ package ai.kilocode.client.session import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionStatusDto import junit.framework.TestCase class SessionQueueCondenserTest : TestCase() { @@ -10,6 +15,19 @@ class SessionQueueCondenserTest : TestCase() { private fun delta(msg: String, part: String, text: String) = ChatEventDto.PartDelta("ses", msg, part, "text", text) + private fun updated( + msg: String, + part: String, + type: String, + text: String? = null, + tool: String? = null, + state: String? = null, + title: String? = null, + ) = ChatEventDto.PartUpdated( + "ses", + PartDto(part, "ses", msg, type, text = text, tool = tool, state = state, title = title), + ) + private fun nonDelta(msg: String) = ChatEventDto.TurnOpen(msg) @@ -98,9 +116,236 @@ class SessionQueueCondenserTest : TestCase() { val result = condenser.condense(listOf( delta("m1", "p1", "first"), delta("m1", "p1", "second"), - )) as List - assertEquals("ses", result[0].sessionID) - assertEquals("m1", result[0].messageID) - assertEquals("p1", result[0].partID) + )) + val event = result.single() as ChatEventDto.PartDelta + assertEquals("ses", event.sessionID) + assertEquals("m1", event.messageID) + assertEquals("p1", event.partID) } + + fun `test consecutive same part updates keep only latest snapshot`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash", state = "pending"), + updated("m1", "p1", "tool", tool = "bash", state = "completed", title = "Install deps"), + )) + + assertEquals(1, result.size) + val event = result.single() as ChatEventDto.PartUpdated + assertEquals("completed", event.part.state) + assertEquals("Install deps", event.part.title) + } + + fun `test consecutive text part updates keep final text`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "text", text = "hel"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(1, result.size) + val event = result.single() as ChatEventDto.PartUpdated + assertEquals("hello", event.part.text) + } + + fun `test part updates for different parts are kept separate`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash"), + updated("m1", "p2", "tool", tool = "edit"), + )) + + assertEquals(2, result.size) + assertEquals("p1", (result[0] as ChatEventDto.PartUpdated).part.id) + assertEquals("p2", (result[1] as ChatEventDto.PartUpdated).part.id) + } + + fun `test part updates for different messages are kept separate`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash"), + updated("m2", "p1", "tool", tool = "bash"), + )) + + assertEquals(2, result.size) + assertEquals("m1", (result[0] as ChatEventDto.PartUpdated).part.messageID) + assertEquals("m2", (result[1] as ChatEventDto.PartUpdated).part.messageID) + } + + fun `test barrier flushes pending part updates before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash", state = "pending"), + updated("m1", "p1", "tool", tool = "bash", state = "running"), + barrier, + updated("m1", "p1", "tool", tool = "bash", state = "completed"), + )) + + assertEquals(3, result.size) + assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state) + assertEquals(barrier, result[1]) + assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state) + } + + fun `test delta acts as barrier for part updates`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "text", text = "he"), + delta("m1", "p1", "l"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(3, result.size) + assertEquals("he", (result[0] as ChatEventDto.PartUpdated).part.text) + assertEquals("l", (result[1] as ChatEventDto.PartDelta).delta) + assertEquals("hello", (result[2] as ChatEventDto.PartUpdated).part.text) + } + + fun `test merged part update matches latest payload exactly`() { + val first = updated("m1", "p1", "tool", tool = "bash", state = "pending") + val last = updated("m1", "p1", "tool", tool = "edit", state = "running", title = "Apply patch") + + val result = condenser.condense(listOf(first, last)) + + assertEquals(listOf(last), result) + } + + // ------ MessageUpdated coalescing ------ + + fun `test consecutive message updates for same id keep only latest`() { + val first = msgUpdated("m1", role = "assistant") + val last = msgUpdated("m1", role = "assistant", cost = 0.02) + + val result = condenser.condense(listOf(first, last)) + + assertEquals(1, result.size) + assertEquals(last, result[0]) + } + + fun `test message updates for different ids are kept separate`() { + val result = condenser.condense(listOf( + msgUpdated("m1"), + msgUpdated("m2"), + )) + + assertEquals(2, result.size) + assertEquals("m1", (result[0] as ChatEventDto.MessageUpdated).info.id) + assertEquals("m2", (result[1] as ChatEventDto.MessageUpdated).info.id) + } + + fun `test barrier flushes pending message updates before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + msgUpdated("m1"), + barrier, + msgUpdated("m1", cost = 0.05), + )) + + assertEquals(3, result.size) + assertNull((result[0] as ChatEventDto.MessageUpdated).info.cost) + assertEquals(barrier, result[1]) + assertEquals(0.05, (result[2] as ChatEventDto.MessageUpdated).info.cost) + } + + // ------ SessionStatusChanged coalescing ------ + + fun `test consecutive status changes keep only latest`() { + val busy = statusChanged("busy") + val idle = statusChanged("idle") + + val result = condenser.condense(listOf(busy, idle)) + + assertEquals(1, result.size) + assertEquals("idle", (result[0] as ChatEventDto.SessionStatusChanged).status.type) + } + + fun `test status changes for different sessions kept separate`() { + val result = condenser.condense(listOf( + ChatEventDto.SessionStatusChanged("ses1", SessionStatusDto("busy")), + ChatEventDto.SessionStatusChanged("ses2", SessionStatusDto("idle")), + )) + + assertEquals(2, result.size) + assertEquals("ses1", (result[0] as ChatEventDto.SessionStatusChanged).sessionID) + assertEquals("ses2", (result[1] as ChatEventDto.SessionStatusChanged).sessionID) + } + + fun `test barrier flushes pending status change before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + statusChanged("busy"), + barrier, + statusChanged("idle"), + )) + + assertEquals(3, result.size) + assertEquals("busy", (result[0] as ChatEventDto.SessionStatusChanged).status.type) + assertEquals(barrier, result[1]) + assertEquals("idle", (result[2] as ChatEventDto.SessionStatusChanged).status.type) + } + + // ------ SessionDiffChanged coalescing ------ + + fun `test consecutive diff changes keep only latest`() { + val first = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("a.kt", 1, 0))) + val last = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("b.kt", 2, 1))) + + val result = condenser.condense(listOf(first, last)) + + assertEquals(1, result.size) + assertEquals(last, result[0]) + } + + // ------ State-event / content-event drain ordering ------ + + fun `test mixed batch with two message updates same and status change is condensed`() { + val result = condenser.condense(listOf( + msgUpdated("m1"), + msgUpdated("m1", cost = 0.02), + statusChanged("busy"), + statusChanged("idle"), + ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("x.kt", 1, 0))), + )) + + // 2 MU → 1, 2 SSC → 1, 1 SDC → 1 = 3 total + assertEquals(3, result.size) + assertEquals(0.02, (result[0] as ChatEventDto.MessageUpdated).info.cost) + assertEquals("idle", (result[1] as ChatEventDto.SessionStatusChanged).status.type) + assertTrue(result[2] is ChatEventDto.SessionDiffChanged) + } + + fun `test message update is emitted before part update for same message`() { + // Server always sends MessageUpdated before PartUpdated for a new message. + // Condensing must preserve that semantic ordering. + val result = condenser.condense(listOf( + msgUpdated("m1"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(2, result.size) + assertTrue(result[0] is ChatEventDto.MessageUpdated) + assertTrue(result[1] is ChatEventDto.PartUpdated) + } + + fun `test part updates for same part coalesce across interleaved message update`() { + // Both PUs are for the same part but separated by a MU. + // MU drains and flushes the first PU, so they do NOT merge. + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", state = "running"), + msgUpdated("m1", cost = 0.01), + updated("m1", "p1", "tool", state = "completed"), + )) + + // running is flushed when MU arrives, completed is a new batch → cannot merge + assertEquals(3, result.size) + assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state) + assertNotNull(result[1] as? ChatEventDto.MessageUpdated) + assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state) + } + + // ------ helpers ------ + + private fun msgUpdated(id: String, role: String = "assistant", cost: Double? = null) = + ChatEventDto.MessageUpdated( + "ses", + MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), cost = cost), + ) + + private fun statusChanged(type: String) = + ChatEventDto.SessionStatusChanged("ses", SessionStatusDto(type)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index b59272c575..f1076e7f09 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto @@ -78,4 +80,101 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged }) assertTrue(m.model.state is SessionState.Busy) } + + fun `test buffered part updates for new part collapse to one content add`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false) + settle() + flush() + + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded }) + assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentUpdated }) + val tool = m.model.message("msg1")!!.parts["prt1"] as Tool + assertEquals(ToolExecState.COMPLETED, tool.state) + } + + fun `test buffered part updates for existing part collapse to one content update`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Install deps")), flush = false) + settle() + flush() + + assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentAdded }) + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentUpdated }) + val tool = m.model.message("msg1")!!.parts["prt1"] as Tool + assertEquals(ToolExecState.COMPLETED, tool.state) + assertEquals("Install deps", tool.title) + } + + fun `test buffered same part tool updates keep only final busy text`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "read", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + settle() + flush() + + val busy = modelEvents.filterIsInstance() + .filter { it.state is SessionState.Busy } + assertEquals(1, busy.size) + val state = busy.single().state as SessionState.Busy + assertTrue(state.text.contains("commands", ignoreCase = true)) + } + + fun `test barrier prevents part update merge across turn close`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false) + settle() + flush() + + assertModelEvents(""" + ContentAdded msg1/prt1 + StateChanged Busy + StateChanged Idle + ContentUpdated msg1/prt1 + """, modelEvents) + assertEquals(SessionState.Idle, m.model.state) + } } From 1b95ef8539938b752179937a5cd0f2551924e237 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 12:33:35 -0400 Subject: [PATCH 52/70] test(jetbrains): add condense toggle and parity test for condensed vs raw event delivery Add a condense: Boolean = true flag to SessionUpdateQueue and expose it as an optional param on SessionController so tests can run both modes. Default remains true, preserving existing production behaviour. Add SessionControllerTestBase.snapshot() which captures final model state (transcript, turns, diff, todos, compactionCount) for assertion. Add a large corpus parity test that feeds 49 events through a condensed and a raw controller, then asserts both reach identical final state. --- .../client/session/SessionController.kt | 3 +- .../client/session/SessionUpdateQueue.kt | 4 +- .../session/SessionControllerTestBase.kt | 34 ++++++++- .../client/session/SessionUpdateQueueTest.kt | 73 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index c597e4bd7c..bdc6303385 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -57,6 +57,7 @@ class SessionController( private val cs: CoroutineScope, comp: java.awt.Component? = null, private val flushMs: Long = EVENT_FLUSH_MS, + private val condense: Boolean = true, ) : Disposable { companion object { @@ -72,7 +73,7 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory - private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, id != null) { sessionId ?: "pending" } + private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt index 7a7709cfb2..dc9d92cc72 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -18,6 +18,7 @@ internal class SessionUpdateQueue( private val comp: Component?, private val flushMs: Long = EVENT_FLUSH_MS, private val fire: (List) -> Unit, + private val condense: Boolean = true, hold: Boolean, private val sid: () -> String, ) : Disposable { @@ -80,7 +81,7 @@ internal class SessionUpdateQueue( val before = pending.size val types = pending.groupBy { it::class.simpleName } .entries.joinToString(",") { (k, v) -> "$k:${v.size}" } - val batch = condenser.condense(pending.toList()) + val batch = if (condense) condenser.condense(pending.toList()) else pending.toList() pending.clear() last = now LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${batch.size} saved=${before - batch.size} types=$types" } @@ -98,4 +99,3 @@ internal class SessionUpdateQueue( } } - diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 8426a028b7..b59e60ac05 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -41,6 +42,24 @@ import kotlinx.coroutines.runBlocking */ abstract class SessionControllerTestBase : BasePlatformTestCase() { + protected data class Snapshot( + val body: String, + val turns: String, + val state: SessionState, + val diff: List, + val todos: List, + val compacted: Int, + ) { + override fun toString(): String = buildString { + appendLine("state=$state") + appendLine("turns=$turns") + appendLine("diff=$diff") + appendLine("todos=$todos") + appendLine("compacted=$compacted") + append("body=\n$body") + } + } + private class Root : javax.swing.JPanel() { private var shown = true override fun isShowing(): Boolean = shown @@ -93,8 +112,12 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) protected fun controller(id: String? = null, flushMs: Long): SessionController { + return controller(id, flushMs, true) + } + + protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { val root = Root() - val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs) + val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense) controllers.add(m) roots[m] = root return m @@ -198,6 +221,15 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) } + protected fun snapshot(c: SessionController) = Snapshot( + body = c.model.toString().trim(), + turns = c.model.toTurnsString().trim(), + state = c.model.state, + diff = c.model.diff.toList(), + todos = c.model.todos.toList(), + compacted = c.model.compactionCount, + ) + // ------ DTO factories ------ protected fun msg(id: String, sid: String, role: String) = MessageDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index f1076e7f09..8be8c905a7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -5,6 +5,9 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.TodoDto class SessionUpdateQueueTest : SessionControllerTestBase() { @@ -177,4 +180,74 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { """, modelEvents) assertEquals(SessionState.Idle, m.model.state) } + + fun `test condensed and raw controller end with same final state on large corpus`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + + val events = corpus() + val condensed = runCorpus(events, true) + val raw = runCorpus(events, false) + val a = snapshot(condensed) + val b = snapshot(raw) + + if (a != b) fail("condensed=\n$a\nraw=\n$b") + assertEquals(SessionState.Idle, a.state) + assertTrue(a.body.contains("assistant#msg1")) + assertTrue(a.body.contains("assistant#msg2")) + assertTrue(a.body.contains("diff: src/A.kt src/B.kt")) + assertTrue(a.body.contains("todo: [completed] ship feature")) + assertEquals(4, a.compacted) + } + + private fun corpus(): List = buildList { + add(ChatEventDto.TurnOpen("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.01))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.02))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running", title = "Read files"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "completed", title = "Read files"))) + add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "he"))) + repeat(8) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i")) + } + add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "hello"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("retry", message = "retrying", attempt = 2, next = 10L))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("offline", message = "offline", requestID = "req1"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 1, 0)))) + add(ChatEventDto.SessionDiffChanged("ses_test", emptyList())) + add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 4, 0)))) + add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("draft plan", "in_progress", "high")))) + add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("ship feature", "completed", "high")))) + add(ChatEventDto.SessionCompacted("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant").copy(cost = 0.02))) + add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "running"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "completed", title = "Patch file"))) + repeat(6) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg2", "txt2", "text", " body$i")) + } + add(ChatEventDto.TurnClose("ses_test", "completed")) + add(ChatEventDto.TurnOpen("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant").copy(cost = 0.03))) + add(ChatEventDto.PartUpdated("ses_test", part("tail", "ses_test", "msg3", "text", text = "tail start"))) + repeat(5) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg3", "tail", "text", " extra$i")) + } + add(ChatEventDto.SessionCompacted("ses_test")) + add(ChatEventDto.SessionIdle("ses_test")) + } + + private fun runCorpus(events: List, condense: Boolean): SessionController { + val m = controller("ses_test", flushMs = Long.MAX_VALUE, condense = condense) + flush() + for (event in events) emit(event, flush = false) + settle() + flush() + return m + } } From 004f97a6cc9170e7e445b846cb4936300bfe3c1c Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 12:38:32 -0400 Subject: [PATCH 53/70] feat(jetbrains): read condense and flushMs from IntelliJ registry in SessionUi Register kilo.session.condense (bool, default true) and kilo.session.flushMs (int, default 150) as platform registry keys so developers can tune or disable event condensing at runtime without recompiling. --- .../main/kotlin/ai/kilocode/client/session/SessionUi.kt | 7 ++++++- .../src/main/resources/kilo.jetbrains.frontend.xml | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 4bf70fa7d5..90672de6f9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.SessionPanel import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane @@ -52,7 +53,11 @@ class SessionUi( private val LOG = KiloLog.create(SessionUi::class.java) } - private val controller = SessionController(this, null, sessions, workspace, app, cs, this) + private val controller = SessionController( + this, null, sessions, workspace, app, cs, this, + flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()).toLong(), + condense = Registry.`is`("kilo.session.condense", true), + ) // ------ card switch ------ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index d181565666..cc1a61b564 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -11,6 +11,13 @@ anchor="left" icon="/icons/kilo.svg" factoryClass="ai.kilocode.client.KiloToolWindowFactory"/> + + + From d11905fce42af9c2a0142d1a43d68b266b8f47f9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 12:55:33 -0400 Subject: [PATCH 54/70] feat(jetbrains): mark session registry keys as runtime-tunable Mark kilo.session.condense and kilo.session.flushMs as non-restart registry keys so queue tuning can be adjusted live while profiling session update behavior in the JetBrains client. --- .../src/main/resources/kilo.jetbrains.frontend.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index cc1a61b564..5922e16ebd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -14,10 +14,14 @@ + defaultValue="true" + restartRequired="false" + overrides="false"/> + defaultValue="150" + restartRequired="false" + overrides="false"/> From 38b07b779b54e430e21a60102a7adb5ce6002f8d Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 13:17:40 -0400 Subject: [PATCH 55/70] fix(jetbrains): guard registry flush config and condense hidden queues Clamp non-positive kilo.session.flushMs values back to the default hardcoded cadence so invalid registry overrides cannot break session UI construction. Condense pending hidden-session events in memory without flushing them, which caps backlog growth while preserving the existing visibility gate. Add tests that verify hidden controllers still avoid model delivery until shown, while benefiting from pre-flush condensation. --- .../ai/kilocode/client/session/SessionUi.kt | 7 +++- .../client/session/SessionUpdateQueue.kt | 12 +++++- .../client/session/SessionUpdateQueueTest.kt | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 90672de6f9..b9b4856c4d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -53,9 +53,14 @@ class SessionUi( private val LOG = KiloLog.create(SessionUi::class.java) } + private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) + .takeIf { it > 0 } + ?.toLong() + ?: EVENT_FLUSH_MS + private val controller = SessionController( this, null, sessions, workspace, app, cs, this, - flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()).toLong(), + flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt index dc9d92cc72..3496d68ba1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -74,6 +74,7 @@ internal class SessionUpdateQueue( private fun flushNow(forced: Boolean, source: String) { if (hold) return + condenseHidden() if (!showing()) return if (pending.isEmpty()) return val now = System.currentTimeMillis() @@ -88,6 +89,16 @@ internal class SessionUpdateQueue( fire(batch) } + private fun condenseHidden() { + if (!condense) return + if (showing()) return + if (pending.size < 2) return + val batch = condenser.condense(pending.toList()) + if (batch.size == pending.size) return + pending.clear() + pending.addAll(batch) + } + private fun showing(): Boolean = comp?.isShowing ?: true private fun edt(block: () -> Unit) { @@ -98,4 +109,3 @@ internal class SessionUpdateQueue( app.invokeLater(block) } } - diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index 8be8c905a7..8b5d56e0f0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -39,6 +39,48 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Busy) } + fun `test hidden controller condenses while hidden but does not flush`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + repeat(4) { i -> + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Run build")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + flush() + + assertModelEvents(""" + MessageAdded msg1 + TurnAdded msg1 [msg1] + ContentAdded msg1/txt1 + ContentDelta msg1/txt1 + ContentAdded msg1/tool1 + """, modelEvents) + assertModel( + """ + assistant#msg1 + text#txt1: + chunk0 chunk1 chunk2 chunk3 + tool#tool1 bash [COMPLETED] Run build + """, + m, + ) + } + fun `test buffered deltas coalesce into one model delta`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() From fd9d5ca739fdd5e133486dbd78535ae82b3eb692 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 13:22:53 -0400 Subject: [PATCH 56/70] fix(cli): add local jschardet module declaration for typecheck Add a minimal ambient module declaration for jschardet so the repo pre-push typecheck hook passes in worktrees where upstream dependency typings are not resolved by tsgo. --- packages/opencode/src/jschardet.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/opencode/src/jschardet.d.ts diff --git a/packages/opencode/src/jschardet.d.ts b/packages/opencode/src/jschardet.d.ts new file mode 100644 index 0000000000..7e819af9cc --- /dev/null +++ b/packages/opencode/src/jschardet.d.ts @@ -0,0 +1,14 @@ +declare module "jschardet" { + export interface Result { + encoding?: string + confidence?: number + } + + export function detect(input: ArrayLike): Result + + const api: { + detect(input: ArrayLike): Result + } + + export default api +} From 0a39e11a042c9eff9fa62edd211a00d8eff5287c Mon Sep 17 00:00:00 2001 From: Scuttle Bot Date: Thu, 23 Apr 2026 13:35:25 -0400 Subject: [PATCH 57/70] docs: document auto-detect models for custom providers and settings export/import (#7824) * docs: add auto-detect models for custom providers, update settings export/import - openai-compatible.md: document automatic model detection from /v1/models endpoint when setting up custom OpenAI-compatible providers (#7793) - settings/index.md: update VSCode tab to describe the new Export/Import buttons in the About Kilo Code settings tab (#7794) * Update packages/kilo-docs/pages/getting-started/settings/index.md Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --------- Co-authored-by: scuttlebot Co-authored-by: Brendan O'Leary Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Co-authored-by: Johnny Amancio --- .../pages/ai-providers/openai-compatible.md | 14 +++++++++++++- .../pages/getting-started/settings/index.md | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 07498e8d57..d8999c3d3b 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -53,13 +53,25 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon - **Display name** — A human-readable name shown in the UI. - **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. - **API key** — Your API key. Optional — leave empty if authentication is handled via headers. -- **Models** — Add models manually or select from the auto-fetched list. +- **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below). - **Headers** (optional) — Custom HTTP headers as key-value pairs. 4. Click **Submit** to save. The provider's models appear in the model picker. For additional model configuration (token limits, tool calling, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab or the [Custom Models](/docs/code-with-ai/agents/custom-models) guide. +### Automatic Model Detection + +When configuring a custom OpenAI-compatible provider, Kilo Code can automatically detect available models from your provider's `/v1/models` endpoint. + +Once you enter a valid **Base URL** and **API Key**, Kilo Code will query the provider and present a searchable model picker with all available models. You can: + +- **Search** with fuzzy matching (e.g., typing "gpt4o" finds "gpt-4o-mini") +- **Select** individual models to add to the provider configuration +- **Edit** an existing custom provider to add or remove models later + +This eliminates the need to manually look up and type model IDs. If auto-detection fails (for example, if the provider doesn't support the `/v1/models` endpoint), you can still enter model IDs manually. + {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 1d78324d15..9f66c73005 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -47,7 +47,12 @@ If you check config files into version control, make sure they do not contain AP ### Export and Import -Config files are plain-text and portable — copy them between machines and you're done. +You can export and import settings from the **About Kilo Code** tab in the Settings UI: + +- **Export**: Saves your global config as a `kilo-settings.json` file. Review it before sharing, because config values are exported as-is. +- **Import**: Loads a previously exported JSON file into the settings draft. Changes are not applied immediately — you can review them and click Save or Discard, just like any manual edit. + +Config files are also plain-text and portable — you can copy `~/.config/kilo/kilo.jsonc` between machines directly. {% /tab %} {% tab label="CLI" %} From 0a142c5473f04ec5dbc9e286043d31e4c2429bf3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 14:55:52 -0400 Subject: [PATCH 58/70] fix(jetbrains): stop hidden sessions from loading the EDT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a session panel is hidden, enqueue and timer-tick paths no longer schedule EDT runnables. Events accumulate off-EDT behind a lock, and a single visibility listener forces one forced flush when the component becomes showing again. Removes the per-enqueue condenseHidden O(n²) rebuild entirely. Closes #9437 --- .../client/session/SessionUpdateQueue.kt | 69 ++++++++++++------- .../session/SessionControllerTestBase.kt | 11 +++ .../client/session/SessionUpdateQueueTest.kt | 63 ++++++++++++++++- 3 files changed, 115 insertions(+), 28 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt index 3496d68ba1..2c02e7b334 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -7,9 +7,12 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import java.awt.Component +import java.awt.event.HierarchyEvent +import java.awt.event.HierarchyListener import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean internal const val EVENT_FLUSH_MS = 150L @@ -29,14 +32,26 @@ internal class SessionUpdateQueue( private val app = ApplicationManager.getApplication() private val condenser = SessionQueueCondenser() private val pending = mutableListOf() + private val lock = Any() private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() + private val visible = AtomicBoolean(comp?.isShowing ?: true) + private val watch = comp?.let { + HierarchyListener { event -> + if (event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@HierarchyListener + onVisible(it.isShowing) + } + } private var last = 0L private var hold = hold init { Disposer.register(parent, this) + if (comp != null && watch != null) comp.addHierarchyListener(watch) exec?.scheduleAtFixedRate( - { requestFlush(false, "tick") }, + { + if (!visible.get()) return@scheduleAtFixedRate + requestFlush(false, "tick") + }, flushMs, flushMs, TimeUnit.MILLISECONDS, @@ -44,11 +59,13 @@ internal class SessionUpdateQueue( } fun enqueue(event: ChatEventDto) { - edt { - LOG.debug { "${ChatLogSummary.sid(sid())} enqueue pending=${pending.size + 1}" } + val size = synchronized(lock) { pending.add(event) - flushNow(false, "enqueue") + pending.size } + LOG.debug { "${ChatLogSummary.sid(sid())} enqueue pending=$size visible=${visible.get()}" } + if (!visible.get()) return + requestFlush(false, "enqueue") } fun holdFlush(hold: Boolean) { @@ -59,48 +76,48 @@ internal class SessionUpdateQueue( } fun requestFlush(forced: Boolean, source: String = "api") { + if (!forced && !visible.get()) return edt { flushNow(forced, source) } } override fun dispose() { - LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=${pending.size}" } + val size = synchronized(lock) { pending.size } + LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" } exec?.shutdownNow() + if (comp != null && watch != null) comp.removeHierarchyListener(watch) if (app.isDispatchThread) { - pending.clear() + synchronized(lock) { pending.clear() } return } - app.invokeLater { pending.clear() } + app.invokeLater { synchronized(lock) { pending.clear() } } } private fun flushNow(forced: Boolean, source: String) { if (hold) return - condenseHidden() - if (!showing()) return - if (pending.isEmpty()) return + if (!visible.get()) return val now = System.currentTimeMillis() if (!forced && now - last < flushMs) return - val before = pending.size - val types = pending.groupBy { it::class.simpleName } + val batch = synchronized(lock) { + if (pending.isEmpty()) return + pending.toList().also { pending.clear() } + } + val before = batch.size + val types = batch.groupBy { it::class.simpleName } .entries.joinToString(",") { (k, v) -> "$k:${v.size}" } - val batch = if (condense) condenser.condense(pending.toList()) else pending.toList() - pending.clear() + val out = if (condense) condenser.condense(batch) else batch last = now - LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${batch.size} saved=${before - batch.size} types=$types" } - fire(batch) + LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${out.size} saved=${before - out.size} types=$types" } + fire(out) } - private fun condenseHidden() { - if (!condense) return - if (showing()) return - if (pending.size < 2) return - val batch = condenser.condense(pending.toList()) - if (batch.size == pending.size) return - pending.clear() - pending.addAll(batch) + private fun onVisible(show: Boolean) { + val prev = visible.getAndSet(show) + if (prev == show) return + LOG.debug { "${ChatLogSummary.sid(sid())} visible=$show" } + if (!show) return + requestFlush(true, "visible") } - private fun showing(): Boolean = comp?.isShowing ?: true - private fun edt(block: () -> Unit) { if (app.isDispatchThread) { block() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index b59e60ac05..8e79cec7dc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -28,6 +28,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil +import java.awt.event.HierarchyEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -64,7 +65,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { private var shown = true override fun isShowing(): Boolean = shown fun showState(show: Boolean) { + val prev = shown shown = show + if (prev == show) return + val event = HierarchyEvent( + this, + HierarchyEvent.HIERARCHY_CHANGED, + this, + this.parent, + HierarchyEvent.SHOWING_CHANGED.toLong(), + ) + hierarchyListeners.forEach { it.hierarchyChanged(event) } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index 8b5d56e0f0..04dc2ad4f3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -29,7 +29,6 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { show(m) settle() - flush() assertModelEvents(""" StateChanged Busy @@ -61,7 +60,6 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { show(m) settle() - flush() assertModelEvents(""" MessageAdded msg1 @@ -81,6 +79,67 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { ) } + fun `test hidden cadence does not flush until shown`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 50L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + + assertModelEvents(""" + StateChanged Busy + MessageAdded msg1 + TurnAdded msg1 [msg1] + """, modelEvents) + } + + fun `test hidden controller flushes on show without new event`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "world"), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + + show(m) + settle() + + assertModelEvents(""" + MessageAdded msg1 + TurnAdded msg1 [msg1] + ContentAdded msg1/txt1 + ContentDelta msg1/txt1 + """, modelEvents) + assertModel( + """ + assistant#msg1 + text#txt1: + hello world + """, + m, + ) + } + fun `test buffered deltas coalesce into one model delta`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() From 9bbef8756ec5c42b64c8f240b574a3e7ce647634 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 23 Apr 2026 18:58:18 +0000 Subject: [PATCH 59/70] release: v7.2.21 --- .changeset/agent-manager-terminal-tabs.md | 5 ---- .changeset/cli-suggest-inline.md | 5 ---- .changeset/cli-suggest-non-blocking.md | 5 ---- .changeset/infinite-compaction-loop-cap.md | 5 ---- .changeset/jetbrains-session-logging.md | 5 ---- .changeset/preserve-file-encoding.md | 9 ------ .changeset/question-dock-explicit-submit.md | 5 ---- .changeset/restore-hot-inject.md | 5 ---- .changeset/snapshot-diff-freeze.md | 5 ---- .changeset/streaming-perf.md | 7 ----- .changeset/suggest-review-narrower.md | 5 ---- bun.lock | 32 ++++++++++----------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++---- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 22 ++++++++++++++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 20 +++++++++++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 34 files changed, 83 insertions(+), 102 deletions(-) delete mode 100644 .changeset/agent-manager-terminal-tabs.md delete mode 100644 .changeset/cli-suggest-inline.md delete mode 100644 .changeset/cli-suggest-non-blocking.md delete mode 100644 .changeset/infinite-compaction-loop-cap.md delete mode 100644 .changeset/jetbrains-session-logging.md delete mode 100644 .changeset/preserve-file-encoding.md delete mode 100644 .changeset/question-dock-explicit-submit.md delete mode 100644 .changeset/restore-hot-inject.md delete mode 100644 .changeset/snapshot-diff-freeze.md delete mode 100644 .changeset/streaming-perf.md delete mode 100644 .changeset/suggest-review-narrower.md diff --git a/.changeset/agent-manager-terminal-tabs.md b/.changeset/agent-manager-terminal-tabs.md deleted file mode 100644 index 9fdf957394..0000000000 --- a/.changeset/agent-manager-terminal-tabs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Open xterm.js-powered terminal tabs in the Agent Manager. Click the chevron next to the `+` tab button and pick "New Terminal" (or press `Cmd+Shift+T` / `Ctrl+Shift+T`) to spawn a real shell in the selected worktree or Local directory. Terminals render as proper tabs alongside agent sessions, support mixed drag-reorder with session tabs, and persist their position across webview reloads. The existing VS Code integrated terminal shortcut (`Cmd+/`) is unchanged. diff --git a/.changeset/cli-suggest-inline.md b/.changeset/cli-suggest-inline.md deleted file mode 100644 index eb3f325100..0000000000 --- a/.changeset/cli-suggest-inline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -CLI suggestions now render inline in the conversation at the position of the suggest tool call, instead of as a separate bar above the prompt input. The inline bar renders as a single full-width row with a subtle background and clickable action buttons, matching the VS Code extension. Dismissal happens automatically when you send a new prompt. Blocking suggestions still use the above-prompt overlay. diff --git a/.changeset/cli-suggest-non-blocking.md b/.changeset/cli-suggest-non-blocking.md deleted file mode 100644 index cb6f54fef4..0000000000 --- a/.changeset/cli-suggest-non-blocking.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -CLI suggestions now render above an active input prompt. You can keep typing and submit a new message while a suggestion is on screen — sending a message auto-dismisses the pending suggestion, matching the VS Code extension behavior. The redundant "Dismiss" row has been removed; click an option to accept, or press Esc to dismiss. diff --git a/.changeset/infinite-compaction-loop-cap.md b/.changeset/infinite-compaction-loop-cap.md deleted file mode 100644 index 23c9e0e6cd..0000000000 --- a/.changeset/infinite-compaction-loop-cap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever. diff --git a/.changeset/jetbrains-session-logging.md b/.changeset/jetbrains-session-logging.md deleted file mode 100644 index a36ec48477..0000000000 --- a/.changeset/jetbrains-session-logging.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add the initial JetBrains session chat UI and improve sandbox debug logging for tracing chat events across frontend and backend. diff --git a/.changeset/preserve-file-encoding.md b/.changeset/preserve-file-encoding.md deleted file mode 100644 index 2bbfe07f03..0000000000 --- a/.changeset/preserve-file-encoding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. New files are still created as UTF-8 without BOM — detection only applies when overwriting or editing an existing file. - -Supported: UTF-8 (with or without BOM), UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). - -Not supported: UTF-16 without BOM, UTF-32. diff --git a/.changeset/question-dock-explicit-submit.md b/.changeset/question-dock-explicit-submit.md deleted file mode 100644 index 96e5645120..0000000000 --- a/.changeset/question-dock-explicit-submit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer. diff --git a/.changeset/restore-hot-inject.md b/.changeset/restore-hot-inject.md deleted file mode 100644 index 48daa9a34a..0000000000 --- a/.changeset/restore-hot-inject.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish. diff --git a/.changeset/snapshot-diff-freeze.md b/.changeset/snapshot-diff-freeze.md deleted file mode 100644 index 4f2ffb136a..0000000000 --- a/.changeset/snapshot-diff-freeze.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session. diff --git a/.changeset/streaming-perf.md b/.changeset/streaming-perf.md deleted file mode 100644 index 21d8e0bd98..0000000000 --- a/.changeset/streaming-perf.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"kilo-code": patch -"@opencode-ai/ui": patch -"@kilocode/kilo-ui": patch ---- - -Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame. diff --git a/.changeset/suggest-review-narrower.md b/.changeset/suggest-review-narrower.md deleted file mode 100644 index 6d5e9821a9..0000000000 --- a/.changeset/suggest-review-narrower.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Narrow when the CLI suggests a local code review so it no longer surfaces after PR-comment replies, reactive fixes (CI/lint failures, reported issues), trivial edits, non-implementation work (research, commits, docs), or review-adjacent turns. diff --git a/bun.lock b/bun.lock index d50f694259..22b3be0bbe 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -88,7 +88,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -121,7 +121,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -172,7 +172,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -201,7 +201,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -237,7 +237,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.20", + "version": "7.2.21", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -250,7 +250,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -270,7 +270,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -305,7 +305,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -365,7 +365,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.20", + "version": "7.2.21", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -520,7 +520,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -545,7 +545,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "semver": "^7.6.3", }, @@ -556,7 +556,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "cross-spawn": "catalog:", }, @@ -571,7 +571,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.20", + "version": "7.2.21", "bin": { "opencode": "./bin/opencode", }, @@ -595,7 +595,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.20", + "version": "7.2.21", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -618,7 +618,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.20", + "version": "7.2.21", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 8c5db2e769..65e4b09e91 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.20", + "version": "7.2.21", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 0085e686d0..e31590e58f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.20", + "version": "7.2.21", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index b8ba98e055..fca8dfed75 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 7c314c348d..373d476c35 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 312487e7eb..ff10d64a20 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.4.9" +version = "7.2.21" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index c69de58f63..1c9da59a88 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.20", + "version": "7.2.21", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 53fabe773b..b88e9b8c86 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index c795106e09..2050bb2137 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index f05fea3b9f..4d3198aa57 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 26369e4288..1f9e278dca 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index b0b3bfb122..844a56ead9 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,27 @@ # kilo-code +## 7.2.21 + +### Minor Changes + +- [#9268](https://github.com/Kilo-Org/kilocode/pull/9268) [`48c0553`](https://github.com/Kilo-Org/kilocode/commit/48c0553bb7b8abfa06fb352ad7a9cdc7f1af4bc5) - Open xterm.js-powered terminal tabs in the Agent Manager. Click the chevron next to the `+` tab button and pick "New Terminal" (or press `Cmd+Shift+T` / `Ctrl+Shift+T`) to spawn a real shell in the selected worktree or Local directory. Terminals render as proper tabs alongside agent sessions, support mixed drag-reorder with session tabs, and persist their position across webview reloads. The existing VS Code integrated terminal shortcut (`Cmd+/`) is unchanged. + +- [#9336](https://github.com/Kilo-Org/kilocode/pull/9336) [`85c578e`](https://github.com/Kilo-Org/kilocode/commit/85c578ed844eba7350ce915cff6b4a98f3eb1bbf) - Add the initial JetBrains session chat UI and improve sandbox debug logging for tracing chat events across frontend and backend. + +### Patch Changes + +- [#9335](https://github.com/Kilo-Org/kilocode/pull/9335) [`6015ac6`](https://github.com/Kilo-Org/kilocode/commit/6015ac6e7d85fc110a99562674484d3d00167525) - Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer. + +- [#9332](https://github.com/Kilo-Org/kilocode/pull/9332) [`0bda9d1`](https://github.com/Kilo-Org/kilocode/commit/0bda9d15ed5ef99fe149fd680a813ca3b4c1d050) - Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish. + +- [#9119](https://github.com/Kilo-Org/kilocode/pull/9119) [`8e75084`](https://github.com/Kilo-Org/kilocode/commit/8e750846da39c6e78478b468b68fdefcaa37f44f) - Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session. + +- [#9341](https://github.com/Kilo-Org/kilocode/pull/9341) [`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c) - Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame. + +- Updated dependencies [[`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c)]: + - @opencode-ai/ui@7.2.21 + - @kilocode/kilo-ui@7.2.21 + ## 7.2.19 ## 7.2.18 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index aaba2409f1..76d1149366 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.20", + "version": "7.2.21", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 10fa407975..a38583587e 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,25 @@ # @kilocode/cli +## 7.2.21 + +### Minor Changes + +- [#8587](https://github.com/Kilo-Org/kilocode/pull/8587) [`010a946`](https://github.com/Kilo-Org/kilocode/commit/010a94698e449bdd9270f44e53aa209dd4c7a248) - The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. New files are still created as UTF-8 without BOM — detection only applies when overwriting or editing an existing file. + + Supported: UTF-8 (with or without BOM), UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others). + + Not supported: UTF-16 without BOM, UTF-32. + +### Patch Changes + +- [#9298](https://github.com/Kilo-Org/kilocode/pull/9298) [`8d06a08`](https://github.com/Kilo-Org/kilocode/commit/8d06a083bce0d87ad55adeb57b043cc5607979eb) - CLI suggestions now render inline in the conversation at the position of the suggest tool call, instead of as a separate bar above the prompt input. The inline bar renders as a single full-width row with a subtle background and clickable action buttons, matching the VS Code extension. Dismissal happens automatically when you send a new prompt. Blocking suggestions still use the above-prompt overlay. + +- [#9298](https://github.com/Kilo-Org/kilocode/pull/9298) [`2ba203b`](https://github.com/Kilo-Org/kilocode/commit/2ba203b6bdad1b759b26501e74d278d13f77f69b) - CLI suggestions now render above an active input prompt. You can keep typing and submit a new message while a suggestion is on screen — sending a message auto-dismisses the pending suggestion, matching the VS Code extension behavior. The redundant "Dismiss" row has been removed; click an option to accept, or press Esc to dismiss. + +- [#9344](https://github.com/Kilo-Org/kilocode/pull/9344) [`c032fc2`](https://github.com/Kilo-Org/kilocode/commit/c032fc2021c55589ff7aee747d8f8a871e77bc56) - Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever. + +- [#9408](https://github.com/Kilo-Org/kilocode/pull/9408) [`c214d63`](https://github.com/Kilo-Org/kilocode/commit/c214d63afb426df0b3499b5240fe5ce525561497) - Narrow when the CLI suggests a local code review so it no longer surfaces after PR-comment replies, reactive fixes (CI/lint failures, reported issues), trivial edits, non-implementation work (research, commits, docs), or review-adjacent turns. + ## 7.2.19 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c6002d4f88..f235dce02a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.20", + "version": "7.2.21", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 6ada5323f7..530ce31acf 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 5ea351240f..d293a75474 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.20", + "version": "7.2.21", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 486bf4ebb4..45b54d2fa8 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 1c31b908bd..74376ccadb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.20", + "version": "7.2.21", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 3d381be5fe..4d6e40e177 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.20", + "version": "7.2.21", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 60c33fc490..4ba49c8f7e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.20", + "version": "7.2.21", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index c1ba8caf36..d874e5cb68 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.20", + "version": "7.2.21", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 60def23562..804acb46c6 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.20", + "version": "7.2.21", "publisher": "sst-dev", "repository": { "type": "git", From fa9be90915d7bd35947f0559184da9ba0d655d31 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 23 Apr 2026 17:48:04 -0400 Subject: [PATCH 60/70] feat(cli): allowlist gpt-5.5 in Codex OAuth plugin --- packages/opencode/src/plugin/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index ae89564422..731eb85bdb 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -380,6 +380,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.5", ]) for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue From 8b526008360acaf1b31e16980028a42bc8481e21 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 05:13:34 +0300 Subject: [PATCH 61/70] chore(cli): fix annotation markers --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 3 ++- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- packages/opencode/src/cli/cmd/tui/plugin/slots.tsx | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 08c1376dc1..93f1c499eb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -52,8 +52,9 @@ export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void onCursorChange: () => void - // kilocode_change - let the prompt close autocomplete without mutating draft text + // kilocode_change start - let the prompt close autocomplete without mutating draft text dismiss: () => void + // kilocode_change end visible: false | "@" | "/" } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ccea4dc35c..12d5ec5610 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -439,13 +439,13 @@ export function Prompt(props: PromptProps) { props.ref?.(undefined) }) + // kilocode_change start - close autocomplete while blocking overlays hide the prompt createEffect(() => { - // kilocode_change start - close autocomplete while blocking overlays hide the prompt if (props.visible === false || props.disabled) { auto()?.dismiss() } - // kilocode_change end }) + // kilocode_change end createEffect(() => { if (!input || input.isDestroyed) return diff --git a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx index d9bc05d726..4d18f6257a 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx @@ -1,6 +1,6 @@ import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@kilocode/plugin/tui" import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid" -import { children } from "solid-js" +import { children } from "solid-js" // kilocode_change import { isRecord } from "@/util/record" type RuntimeSlotMap = TuiSlotMap> @@ -22,9 +22,9 @@ function empty(_props: TuiSlotProps) { let view: Slot = empty +// kilocode_change start - stabilize fallback children so replace-mode slots +// don't recreate stateful defaults like the session prompt on prop changes. export const Slot = (props: TuiSlotProps) => { - // kilocode_change start - stabilize fallback children so replace-mode slots - // don't recreate stateful defaults like the session prompt on prop changes. const value = children(() => props.children) return view({ ...props, @@ -32,8 +32,8 @@ export const Slot = (props: TuiSlotProps) => { return value() }, } as TuiSlotProps) - // kilocode_change end } +// kilocode_change end function isHostSlotPlugin(value: unknown): value is HostSlotPlugin> { if (!isRecord(value)) return false From 33b90038f25a87d59d556860563a86cd7ae4c22a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 06:04:36 +0300 Subject: [PATCH 62/70] fix(cli): guard autocomplete dismiss --- .../opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 93f1c499eb..e949d3f28f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -493,6 +493,7 @@ export function Autocomplete(props: { // kilocode_change start - keep slash text intact when overlays hide the prompt, // but still allow normal autocomplete dismissal to clean it up. function dismiss() { + if (!store.visible) return command.keybinds(true) setStore("visible", false) } From 567ca0d34178a6a896aa58c10cc946565c116d4e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 09:43:41 +0300 Subject: [PATCH 63/70] fix(cli): remove unreachable `return true` in sync.ready Drop a debug short-circuit shipped upstream in #23037 that flipped `sync.ready` before sync actually finished, producing a 1-2s delay before home content (agents, news, tips) appeared in the TUI. Bisected from origin/main. --- .changeset/cli-sync-ready-debug-leftover.md | 5 +++++ packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-sync-ready-debug-leftover.md diff --git a/.changeset/cli-sync-ready-debug-leftover.md b/.changeset/cli-sync-ready-debug-leftover.md new file mode 100644 index 0000000000..e20d82336d --- /dev/null +++ b/.changeset/cli-sync-ready-debug-leftover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix a 1-2 second startup delay before home content (agents, news, tips) appears in the TUI. diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a52748d8be..99c783d24a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -630,7 +630,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return store.status }, get ready() { - return true + // kilocode_change - drop unreachable `return true` debug leftover from upstream #23037; caused the TUI to flip `ready` before sync finished, producing a 1-2s startup delay before home content (agents/news/tips) appears. if (process.env.KILO_FAST_BOOT) return true return store.status !== "loading" }, From 3d6db134f0b61152fdc84051094bfa934cd471be Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 10:19:58 +0300 Subject: [PATCH 64/70] docs(cli): keep ready debug note --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 99c783d24a..7864bdd29f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -630,7 +630,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return store.status }, get ready() { - // kilocode_change - drop unreachable `return true` debug leftover from upstream #23037; caused the TUI to flip `ready` before sync finished, producing a 1-2s startup delay before home content (agents/news/tips) appears. + // return true // kilocode_change - upstream #23037 left this debug path enabled; keep it commented so future merges do not restore eager ready state. if (process.env.KILO_FAST_BOOT) return true return store.status !== "loading" }, From b4579203015d2411a48c81ab62645fb52f2f10e4 Mon Sep 17 00:00:00 2001 From: "hdcode.dev" Date: Fri, 24 Apr 2026 09:40:45 +0200 Subject: [PATCH 65/70] refactor(vscode): split webview messages.ts into per-domain files (#9445) * refactor(vscode): split webview messages.ts into per-domain files Splits the 2755-line webview-ui/src/types/messages.ts into 14 domain files under types/messages/ with a barrel index. Type definitions are preserved byte-identically; consumer imports are unchanged thanks to the barrel. * style(vscode): apply prettier to split message files * chore: remove pr-desc.md --- .../tests/unit/message-contract.test.ts | 16 +- .../tests/unit/settings-io.test.ts | 4 +- .../webview-ui/src/types/messages.ts | 2755 ----------------- .../src/types/messages/agent-manager.ts | 191 ++ .../webview-ui/src/types/messages/agents.ts | 42 + .../webview-ui/src/types/messages/config.ts | 81 + .../src/types/messages/connection.ts | 30 + .../src/types/messages/extension-messages.ts | 906 ++++++ .../webview-ui/src/types/messages/index.ts | 17 + .../src/types/messages/migration.ts | 150 + .../webview-ui/src/types/messages/parts.ts | 94 + .../src/types/messages/permissions.ts | 39 + .../webview-ui/src/types/messages/profile.ts | 29 + .../src/types/messages/providers.ts | 54 + .../src/types/messages/questions.ts | 57 + .../webview-ui/src/types/messages/sessions.ts | 65 + .../src/types/messages/webview-messages.ts | 1048 +++++++ 17 files changed, 2817 insertions(+), 2761 deletions(-) delete mode 100644 packages/kilo-vscode/webview-ui/src/types/messages.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/agents.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/config.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/connection.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/index.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/migration.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/parts.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/profile.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/providers.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/questions.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts create mode 100644 packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts diff --git a/packages/kilo-vscode/tests/unit/message-contract.test.ts b/packages/kilo-vscode/tests/unit/message-contract.test.ts index ed7a93cc35..2cb426ed14 100644 --- a/packages/kilo-vscode/tests/unit/message-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/message-contract.test.ts @@ -14,7 +14,7 @@ import fs from "node:fs" import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") -const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts") +const MESSAGES_DIR = path.join(ROOT, "webview-ui/src/types/messages") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts") // Some wire types (partUpdated, partsUpdated) live in a file shared by the @@ -25,13 +25,21 @@ function readFile(filePath: string): string { return fs.readFileSync(filePath, "utf-8") } +function readMessagesDir(): string { + return fs + .readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".ts")) + .map((f) => readFile(path.join(MESSAGES_DIR, f))) + .join("\n") +} + function readMessageTypeSources(): string { - return readFile(MESSAGES_FILE) + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE) + return readMessagesDir() + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE) } describe("ExtensionMessage type members", () => { it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => { - const content = readFile(MESSAGES_FILE) + const content = readMessagesDir() // Extract ExtensionMessage union members const unionMatch = content.match( @@ -54,7 +62,7 @@ describe("ExtensionMessage type members", () => { }) it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => { - const content = readFile(MESSAGES_FILE) + const content = readMessagesDir() const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/) if (!unionMatch) { diff --git a/packages/kilo-vscode/tests/unit/settings-io.test.ts b/packages/kilo-vscode/tests/unit/settings-io.test.ts index 142f1e82ad..81e68cc740 100644 --- a/packages/kilo-vscode/tests/unit/settings-io.test.ts +++ b/packages/kilo-vscode/tests/unit/settings-io.test.ts @@ -381,10 +381,10 @@ describe("constants", () => { }) it("KNOWN_KEYS matches all keys in the Config interface (drift guard)", async () => { - // Read the Config interface from messages.ts and extract its keys. + // Read the Config interface from messages/config.ts and extract its keys. // If someone adds a new field to Config, this test fails as a reminder // to also add it to KNOWN_KEYS in settings-io.ts. - const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages.ts")).text() + const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages/config.ts")).text() const match = src.match(/export interface Config \{([^}]+)\}/) expect(match).not.toBeNull() const body = match![1] diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts deleted file mode 100644 index fe803a43c1..0000000000 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ /dev/null @@ -1,2755 +0,0 @@ -/** - * Types for extension <-> webview message communication - */ - -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client" -import type { PartBatch, PartUpdate } from "../../../src/shared/stream-messages" - -// Connection states -export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" - -// Session status (simplified from backend) -export type SessionStatus = "idle" | "busy" | "retry" | "offline" - -// Rich status info for retry countdown and future extensions -export type SessionStatusInfo = - | { type: "idle" } - | { type: "busy" } - | { type: "retry"; attempt: number; message: string; next: number } - | { type: "offline"; message: string } - -// Tool state for tool parts -export type ToolState = - | { status: "pending"; input: Record } - | { status: "running"; input: Record; title?: string } - | { status: "completed"; input: Record; output: string; title: string } - | { status: "error"; input: Record; error: string } - -// Base part interface - all parts have these fields -export interface BasePart { - id: string - sessionID?: string - messageID?: string -} - -// Part types from the backend -export interface TextPart extends BasePart { - type: "text" - text: string -} - -export interface FilePartSource { - type: "file" - path: string - text: { - value: string - start: number - end: number - } -} - -export interface FilePart extends BasePart { - type: "file" - mime: string - url: string - filename?: string - source?: FilePartSource -} - -export interface ToolPart extends BasePart { - type: "tool" - tool: string - state: ToolState -} - -export interface ReasoningPart extends BasePart { - type: "reasoning" - text: string -} - -// Step parts from the backend -export interface StepStartPart extends BasePart { - type: "step-start" -} - -export interface StepFinishPart extends BasePart { - type: "step-finish" - reason?: string - cost?: number - tokens?: { - input: number - output: number - reasoning?: number - cache?: { read: number; write: number } - } -} - -export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart - -// Part delta for streaming updates -export interface PartDelta { - type: "text-delta" - textDelta?: string -} - -// Token usage for assistant messages -export interface TokenUsage { - input: number - output: number - reasoning?: number - cache?: { read: number; write: number } -} - -// Context usage derived from the last assistant message's tokens -export interface ContextUsage { - tokens: number - percentage: number | null -} - -// Message structure (simplified for webview) -export interface Message { - id: string - sessionID: string - role: "user" | "assistant" - content?: string - parts?: Part[] - createdAt: string - time?: { created: number; completed?: number } - agent?: string - model?: { providerID: string; modelID: string } - providerID?: string - modelID?: string - mode?: string - parentID?: string - path?: { cwd: string; root: string } - error?: { name: string; data?: Record } - summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean - cost?: number - tokens?: TokenUsage - finish?: string -} - -// File diff info (matches Snapshot.FileDiff from CLI backend) -export interface SessionFileDiff { - file: string - before: string - after: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -// Session info (simplified for webview) -export interface SessionInfo { - id: string - parentID?: string | null - title?: string - createdAt: string - updatedAt: string - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - summary?: { - additions: number - deletions: number - files: number - diffs?: SessionFileDiff[] - } | null -} - -// Cloud session info (from Kilo cloud API) -export interface CloudSessionInfo { - session_id: string - title: string | null - created_at: string - updated_at: string -} - -// Permission request -export interface PermissionFileDiff { - file: string - patch?: string - before?: string - after?: string - additions: number - deletions: number -} - -export interface PermissionRequest { - id: string - sessionID: string - toolName: string - patterns: string[] - always: string[] - args: Record & { - rules?: string[] - diff?: string - filepath?: string - filediff?: PermissionFileDiff - } - message?: string - tool?: { messageID: string; callID: string } -} - -// Todo item -export interface TodoItem { - id: string - content: string - status: "pending" | "in_progress" | "completed" -} - -// Question types -export interface QuestionOption { - label: string - description: string - mode?: string - // Optional i18n keys — the backend fills these for strings it wants translated in the webview. - // The canonical English `label` stays on the reply wire, so server-side matching is unaffected. - labelKey?: string - descriptionKey?: string -} - -export interface QuestionInfo { - question: string - header: string - options: QuestionOption[] - multiple?: boolean - custom?: boolean - // Optional i18n keys for question text and header (see QuestionOption for details). - questionKey?: string - headerKey?: string -} - -export interface QuestionRequest { - id: string - sessionID: string - questions: QuestionInfo[] - blocking?: boolean - tool?: { - messageID: string - callID: string - } -} - -export interface SuggestionAction { - label: string - description?: string - prompt: string -} - -export interface SuggestionRequest { - id: string - sessionID: string - text: string - actions: SuggestionAction[] - blocking?: boolean - tool?: { - messageID: string - callID: string - } -} - -// Skill info from CLI backend -export interface SkillInfo { - name: string - description: string - location: string -} - -// Slash command info from CLI backend -export interface SlashCommandInfo { - name: string - description?: string - source?: "command" | "mcp" | "skill" - hints: string[] -} - -// A single resolved permission rule from the CLI backend (matches PermissionNext.Rule) -export interface PermissionRuleItem { - permission: string - pattern: string - action: PermissionLevel -} - -// Agent/mode info from CLI backend -export interface AgentInfo { - name: string - displayName?: string - description?: string - mode: "subagent" | "primary" | "all" - native?: boolean - hidden?: boolean - deprecated?: boolean - color?: string - permission?: PermissionRuleItem[] -} - -// Server info -export interface ServerInfo { - port: number - version?: string -} - -// Device auth flow status -export type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" - -// Device auth state -export interface DeviceAuthState { - status: DeviceAuthStatus - code?: string - verificationUrl?: string - expiresIn?: number - error?: string -} - -// Kilo notification types (mirrored from kilo-gateway) -export interface KilocodeNotificationAction { - actionText: string - actionURL: string -} - -export interface KilocodeNotification { - id: string - title: string - message: string - action?: KilocodeNotificationAction - showIn?: string[] - suggestModelId?: string -} - -// Profile types from kilo-gateway -export interface KilocodeBalance { - balance: number -} - -export interface ProfileData { - profile: { - email: string - name?: string - organizations?: Array<{ id: string; name: string; role: string }> - } - balance: KilocodeBalance | null - currentOrgId: string | null -} - -// Provider/model types for model selector - -export interface ProviderModel { - id: string - name: string - inputPrice?: number - outputPrice?: number - contextLength?: number - releaseDate?: string - latest?: boolean - // Actual shape returned by the server (Provider.Model) - limit?: { context: number; input?: number; output: number } - variants?: Record> - capabilities?: { - reasoning: boolean - input?: { text: boolean; image: boolean; audio: boolean; video: boolean; pdf: boolean } - } - options?: { description?: string } - recommendedIndex?: number - isFree?: boolean - cost?: { - input: number - output: number - cache?: { - read: number - write: number - } - } -} - -export interface Provider { - id: string - name: string - models: Record - source?: "env" | "config" | "custom" | "api" - env?: string[] -} - -export interface ModelSelection { - providerID: string - modelID: string -} - -export type ProviderAuthState = "api" | "oauth" | "wellknown" - -// ============================================ -// Backend Config Types (mirrored for webview) -// ============================================ - -export type PermissionLevel = "allow" | "ask" | "deny" - -/** null in a PermissionRule object is a delete sentinel — removes the key from the config */ -export type PermissionRule = PermissionLevel | Record - -export type PermissionConfig = Partial> - -export interface AgentConfig { - model?: string | null - prompt?: string - description?: string - mode?: "subagent" | "primary" | "all" - hidden?: boolean - disable?: boolean - temperature?: number - top_p?: number - steps?: number - permission?: PermissionConfig -} - -export interface ProviderConfig { - name?: string - api_key?: string - base_url?: string - models?: Record - npm?: string - env?: string[] - options?: Record -} - -export interface McpConfig { - type?: "local" | "remote" - command?: string[] | string - args?: string[] - env?: Record - environment?: Record - url?: string - headers?: Record - enabled?: boolean -} - -export interface CommandConfig { - template: string - description?: string - agent?: string - model?: string -} - -export interface SkillsConfig { - paths?: string[] - urls?: string[] -} - -export interface CompactionConfig { - auto?: boolean - prune?: boolean -} - -export interface WatcherConfig { - ignore?: string[] -} - -export interface ExperimentalConfig { - disable_paste_summary?: boolean - batch_tool?: boolean - codebase_search?: boolean - primary_tools?: string[] - continue_loop_on_deny?: boolean - mcp_timeout?: number -} - -export interface CommitMessageConfig { - prompt?: string -} - -export interface Config { - permission?: PermissionConfig - model?: string | null - small_model?: string | null - default_agent?: string - agent?: Record - provider?: Record - disabled_providers?: string[] - enabled_providers?: string[] - mcp?: Record - command?: Record - instructions?: string[] - skills?: SkillsConfig - snapshot?: boolean - remote_control?: boolean - share?: "manual" | "auto" | "disabled" - username?: string - watcher?: WatcherConfig - formatter?: false | Record - lsp?: false | Record - compaction?: CompactionConfig - commit_message?: CommitMessageConfig - tools?: Record - layout?: "auto" | "stretch" - experimental?: ExperimentalConfig -} - -// ============================================ -// Messages FROM extension TO webview -// ============================================ - -export interface ReadyMessage { - type: "ready" - serverInfo?: ServerInfo - extensionVersion?: string - vscodeLanguage?: string - languageOverride?: string - workspaceDirectory?: string -} - -export interface GitStatusMessage { - type: "gitStatus" - repo: boolean -} - -export interface WorkspaceDirectoryChangedMessage { - type: "workspaceDirectoryChanged" - directory: string -} - -export interface LanguageChangedMessage { - type: "languageChanged" - locale: string -} - -export interface ConnectionStateMessage { - type: "connectionState" - state: ConnectionState - error?: string - userMessage?: string - userDetails?: string -} - -export interface ErrorMessage { - type: "error" - message: string - code?: string - sessionID?: string -} - -export interface SendMessageFailedMessage { - type: "sendMessageFailed" - error: string - text: string - sessionID?: string - draftID?: string - messageID?: string - files?: FileAttachment[] -} - -// Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the -// webview's concrete union. -export type PartUpdatedMessage = PartUpdate -export type PartsUpdatedMessage = PartBatch - -export interface SessionStatusMessage { - type: "sessionStatus" - sessionID: string - status: SessionStatus - // Retry fields (present when status === "retry") - attempt?: number - message?: string - next?: number -} - -export interface SessionErrorMessage { - type: "sessionError" - sessionID?: string - error?: { name: string; data?: Record } -} - -export interface PermissionRequestMessage { - type: "permissionRequest" - permission: PermissionRequest -} - -export interface PermissionResolvedMessage { - type: "permissionResolved" - permissionID: string -} - -export interface PermissionErrorMessage { - type: "permissionError" - permissionID: string -} - -export interface TodoUpdatedMessage { - type: "todoUpdated" - sessionID: string - items: TodoItem[] -} - -export interface SessionCreatedMessage { - type: "sessionCreated" - session: SessionInfo - draftID?: string -} - -export interface SessionForkedMessage { - type: "sessionForked" - sessionID: string -} - -export interface SessionUpdatedMessage { - type: "sessionUpdated" - session: SessionInfo -} - -export interface SessionDeletedMessage { - type: "sessionDeleted" - sessionID: string -} - -export interface MessageRemovedMessage { - type: "messageRemoved" - sessionID: string - messageID: string -} - -export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" - -export interface MessagesLoadedMessage { - type: "messagesLoaded" - sessionID: string - messages: Message[] - mode?: Exclude - cursor?: string - hasMore?: boolean -} - -export interface MessageCreatedMessage { - type: "messageCreated" - message: Message -} - -export interface SessionsLoadedMessage { - type: "sessionsLoaded" - sessions: SessionInfo[] - preserveSessionIds?: string[] -} - -export interface CloudSessionsLoadedMessage { - type: "cloudSessionsLoaded" - sessions: CloudSessionInfo[] - nextCursor: string | null -} - -export interface GitRemoteUrlLoadedMessage { - type: "gitRemoteUrlLoaded" - gitUrl: string | null -} - -export interface CloudSessionDataLoadedMessage { - type: "cloudSessionDataLoaded" - cloudSessionId: string - title: string - messages: Message[] -} - -export interface CloudSessionImportedMessage { - type: "cloudSessionImported" - cloudSessionId: string - session: SessionInfo -} - -export interface CloudSessionImportFailedMessage { - type: "cloudSessionImportFailed" - cloudSessionId: string - error: string -} - -export interface OpenCloudSessionMessage { - type: "openCloudSession" - sessionId: string -} - -export interface ActionMessage { - type: "action" - action: string -} - -export interface SetChatBoxMessage { - type: "setChatBoxMessage" - text: string -} - -export interface AppendChatBoxMessage { - type: "appendChatBoxMessage" - text: string -} - -export interface ReviewComment { - id: string - file: string - side: "additions" | "deletions" - line: number - comment: string - selectedText: string -} - -export interface AppendReviewCommentsMessage { - type: "appendReviewComments" - comments: ReviewComment[] - autoSend?: boolean -} - -export interface TriggerTaskMessage { - type: "triggerTask" - text: string -} - -export interface ProfileDataMessage { - type: "profileData" - data: ProfileData | null -} - -export interface DeviceAuthStartedMessage { - type: "deviceAuthStarted" - code?: string - verificationUrl: string - expiresIn: number -} - -export interface DeviceAuthCompleteMessage { - type: "deviceAuthComplete" -} - -export interface DeviceAuthFailedMessage { - type: "deviceAuthFailed" - error: string -} - -export interface DeviceAuthCancelledMessage { - type: "deviceAuthCancelled" -} - -export interface NavigateMessage { - type: "navigate" - view: "newTask" | "marketplace" | "history" | "profile" | "settings" | "subAgentViewer" - tab?: string -} - -export interface ProvidersLoadedMessage { - type: "providersLoaded" - providers: Record - connected: string[] - defaults: Record - defaultSelection: ModelSelection - authMethods: Record - authStates: Record -} - -export interface AgentsLoadedMessage { - type: "agentsLoaded" - agents: AgentInfo[] - allAgents: AgentInfo[] - defaultAgent: string -} - -export interface SkillsLoadedMessage { - type: "skillsLoaded" - skills: SkillInfo[] -} - -export interface CommandsLoadedMessage { - type: "commandsLoaded" - commands: SlashCommandInfo[] -} - -export interface AutocompleteSettingsLoadedMessage { - type: "autocompleteSettingsLoaded" - settings: { - enableAutoTrigger: boolean - enableSmartInlineTaskKeybinding: boolean - enableChatAutocomplete: boolean - } -} - -export interface ChatCompletionResultMessage { - type: "chatCompletionResult" - text: string - requestId: string -} - -export interface FileSearchItem { - path: string - type: "file" | "folder" -} - -export interface FileSearchResultMessage { - type: "fileSearchResult" - paths: string[] - items?: FileSearchItem[] - dir: string - requestId: string -} - -export interface TerminalContextResultMessage { - type: "terminalContextResult" - requestId: string - content: string - truncated?: boolean -} - -export interface TerminalContextErrorMessage { - type: "terminalContextError" - requestId: string - error: string -} - -export interface QuestionRequestMessage { - type: "questionRequest" - question: QuestionRequest -} - -export interface QuestionResolvedMessage { - type: "questionResolved" - requestID: string -} - -export interface QuestionErrorMessage { - type: "questionError" - requestID: string -} - -export interface SuggestionRequestMessage { - type: "suggestionRequest" - suggestion: SuggestionRequest -} - -export interface SuggestionResolvedMessage { - type: "suggestionResolved" - requestID: string -} - -export interface SuggestionErrorMessage { - type: "suggestionError" - requestID: string -} - -export interface BrowserSettings { - enabled: boolean - useSystemChrome: boolean - headless: boolean -} - -export interface BrowserSettingsLoadedMessage { - type: "browserSettingsLoaded" - settings: BrowserSettings -} - -export interface ConfigLoadedMessage { - type: "configLoaded" - config: Config -} - -export interface ConfigUpdatedMessage { - type: "configUpdated" - config: Config -} - -export interface ConfigUpdateFailedMessage { - type: "configUpdateFailed" - message: string - details?: string -} - -export interface GlobalConfigLoadedMessage { - type: "globalConfigLoaded" - config: Config -} - -export interface NotificationSettingsLoadedMessage { - type: "notificationSettingsLoaded" - settings: { - notifyAgent: boolean - notifyPermissions: boolean - notifyErrors: boolean - soundAgent: string - soundPermissions: string - soundErrors: string - } -} - -export interface TimelineSettingLoadedMessage { - type: "timelineSettingLoaded" - visible: boolean -} - -export interface NotificationsLoadedMessage { - type: "notificationsLoaded" - notifications: KilocodeNotification[] - dismissedIds: string[] -} - -// Agent Manager worktree session metadata -export interface AgentManagerSessionMetaMessage { - type: "agentManager.sessionMeta" - sessionId: string - mode: import("../context/worktree-mode").SessionMode - branch?: string - path?: string - parentBranch?: string -} - -// Agent Manager repo info (current branch of the main workspace) -export interface AgentManagerRepoInfoMessage { - type: "agentManager.repoInfo" - branch: string - defaultBranch?: string -} - -export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" - -// Agent Manager worktree setup progress -export interface AgentManagerWorktreeSetupMessage { - type: "agentManager.worktreeSetup" - status: "creating" | "starting" | "ready" | "error" - message: string - sessionId?: string - branch?: string - worktreeId?: string - errorCode?: WorktreeErrorCode -} - -// Agent Manager worktree state types (mirrored from WorktreeStateManager) -export interface WorktreeState { - id: string - branch: string - path: string - /** Bare branch name (e.g. "main"), without remote prefix. */ - parentBranch: string - /** Remote name (e.g. "origin"). */ - remote?: string - createdAt: string - /** Shared identifier for worktrees created together via multi-version mode. */ - groupId?: string - /** User-provided display name for the worktree. */ - label?: string - /** Cached PR number for instant badge display on reload. */ - prNumber?: number - /** Cached PR URL for instant badge display on reload. */ - prUrl?: string - /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ - prState?: string - /** Section this worktree belongs to, or undefined for ungrouped. */ - sectionId?: string -} - -export interface SectionState { - id: string - name: string - /** Color label (e.g. "Red", "Blue") or null for default. */ - color: string | null - order: number - collapsed: boolean -} - -// --------------------------------------------------------------------------- -// PR status types (mirrored from extension types.ts) -// --------------------------------------------------------------------------- - -export type PRState = "open" | "draft" | "merged" | "closed" -export type ReviewDecision = "approved" | "changes_requested" | "pending" -export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" -export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" - -export interface PRCheck { - name: string - status: CheckStatus - url?: string - duration?: string -} - -export interface PRComment { - id: string - author: string - avatar?: string - body: string - file?: string - line?: number - url?: string - resolved: boolean - createdAt?: number -} - -export interface PRStatus { - number: number - title: string - url: string - state: PRState - review: ReviewDecision | null - checks: { - status: AggregateCheckStatus - total: number - passed: number - failed: number - pending: number - items: PRCheck[] - } - comments?: { - total: number - unresolved: number - items: PRComment[] - } - additions: number - deletions: number - files: number -} - -export type RunState = "idle" | "running" | "stopping" - -export interface RunStatus { - worktreeId: string - state: RunState - exitCode?: number - signal?: string - startedAt?: string - finishedAt?: string - error?: string -} - -export interface ManagedSessionState { - id: string - worktreeId: string | null - createdAt: string -} - -// Agent Manager session added to an existing worktree (no setup overlay needed) -export interface AgentManagerSessionAddedMessage { - type: "agentManager.sessionAdded" - sessionId: string - worktreeId: string -} - -// Agent Manager session forked from an existing session -export interface AgentManagerSessionForkedMessage { - type: "agentManager.sessionForked" - sessionId: string - forkedFromId: string - worktreeId?: string -} - -// Full state push from extension to webview -export interface AgentManagerStateMessage { - type: "agentManager.state" - worktrees: WorktreeState[] - sessions: ManagedSessionState[] - sections?: SectionState[] - staleWorktreeIds?: string[] - tabOrder?: Record - worktreeOrder?: string[] - sessionsCollapsed?: boolean - reviewDiffStyle?: "unified" | "split" - isGitRepo?: boolean - defaultBaseBranch?: string - runStatuses?: RunStatus[] - runScriptConfigured?: boolean - runScriptPath?: string -} - -// --------------------------------------------------------------------------- -// Agent Manager terminal messages -// --------------------------------------------------------------------------- - -export interface AgentManagerTerminalCreatedMessage { - type: "agentManager.terminal.created" - /** null for LOCAL, worktree id otherwise */ - worktreeId: string | null - terminalId: string - title: string - wsUrl: string -} - -export interface AgentManagerTerminalClosedMessage { - type: "agentManager.terminal.closed" - terminalId: string -} - -export interface AgentManagerTerminalErrorMessage { - type: "agentManager.terminal.error" - terminalId?: string - message: string -} - -export interface AgentManagerRunStatusMessage extends RunStatus { - type: "agentManager.runStatus" -} - -// Resolved keybindings for agent manager actions -export interface AgentManagerKeybindingsMessage { - type: "agentManager.keybindings" - bindings: Record -} - -// Multi-version creation progress (extension → webview) -export interface AgentManagerMultiVersionProgressMessage { - type: "agentManager.multiVersionProgress" - status: "creating" | "done" - total: number - completed: number - groupId?: string -} - -// Stored variant selections loaded from extension globalState (extension → webview) -export interface VariantsLoadedMessage { - type: "variantsLoaded" - variants: Record -} - -export interface RecentsLoadedMessage { - type: "recentsLoaded" - recents: ModelSelection[] -} - -export interface FavoritesLoadedMessage { - type: "favoritesLoaded" - favorites: ModelSelection[] -} - -// Per-mode model selections loaded from model.json (extension → webview) -export interface ModelSelectionsLoadedMessage { - type: "modelSelectionsLoaded" - selections: Record -} - -export interface BranchInfo { - name: string - isLocal: boolean - isRemote: boolean - isDefault: boolean - lastCommitDate?: string - isCheckedOut?: boolean -} - -export interface AgentManagerBranchesMessage { - type: "agentManager.branches" - branches: BranchInfo[] - defaultBranch: string -} - -// Agent Manager Import tab: external worktrees (extension → webview) -export interface ExternalWorktreeInfo { - path: string - branch: string -} - -export interface AgentManagerExternalWorktreesMessage { - type: "agentManager.externalWorktrees" - worktrees: ExternalWorktreeInfo[] -} - -// Agent Manager Import tab: result feedback (extension → webview) -export interface AgentManagerImportResultMessage { - type: "agentManager.importResult" - success: boolean - message: string - errorCode?: WorktreeErrorCode -} - -// Shared FileDiff shape (matches Snapshot.FileDiff from CLI backend) -export interface WorktreeFileDiff { - file: string - before: string - after: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - tracked?: boolean - generatedLike?: boolean - summarized?: boolean - stamp?: string -} - -// Agent Manager: Diff data push (extension → webview) -export interface AgentManagerWorktreeDiffMessage { - type: "agentManager.worktreeDiff" - sessionId: string - diffs: WorktreeFileDiff[] -} - -export interface AgentManagerWorktreeDiffFileMessage { - type: "agentManager.worktreeDiffFile" - sessionId: string - file: string - diff: WorktreeFileDiff | null -} - -// Agent Manager: Diff loading state (extension → webview) -export interface AgentManagerWorktreeDiffLoadingMessage { - type: "agentManager.worktreeDiffLoading" - sessionId: string - loading: boolean -} - -export type AgentManagerApplyWorktreeDiffStatus = "checking" | "applying" | "success" | "conflict" | "error" - -export interface AgentManagerApplyWorktreeDiffConflict { - file?: string - reason: string -} - -export interface AgentManagerApplyWorktreeDiffResultMessage { - type: "agentManager.applyWorktreeDiffResult" - worktreeId: string - status: AgentManagerApplyWorktreeDiffStatus - message: string - conflicts?: AgentManagerApplyWorktreeDiffConflict[] -} - -// Agent Manager: Revert single file result (extension → webview) -export interface AgentManagerRevertWorktreeFileResultMessage { - type: "agentManager.revertWorktreeFileResult" - sessionId: string - file: string - status: "success" | "error" - message: string -} - -// Per-worktree git stats: diff additions/deletions and ahead/behind counts -export interface WorktreeGitStats { - worktreeId: string - files: number - additions: number - deletions: number - ahead: number - behind: number -} - -// Agent Manager: Worktree git stats push (extension → webview) -export interface AgentManagerWorktreeStatsMessage { - type: "agentManager.worktreeStats" - stats: WorktreeGitStats[] -} - -// Per-local-workspace git stats: branch name, diff additions/deletions, ahead/behind counts -export interface LocalGitStats { - branch: string - files: number - additions: number - deletions: number - ahead: number - behind: number -} - -// Agent Manager: Local workspace git stats push (extension → webview) -export interface AgentManagerLocalStatsMessage { - type: "agentManager.localStats" - stats: LocalGitStats -} - -// Agent Manager: PR status push (extension → webview) -export interface AgentManagerPRStatusMessage { - type: "agentManager.prStatus" - worktreeId: string - pr: PRStatus | null - error?: "gh_missing" | "gh_auth" | "fetch_failed" -} - -// Sidebar: Live worktree diff stats (extension → webview) -export interface WorktreeStatsLoadedMessage { - type: "worktreeStatsLoaded" - files: number - additions: number - deletions: number -} - -// Set the model for a session (extension → webview, used during multi-version creation) -export interface AgentManagerSetSessionModelMessage { - type: "agentManager.setSessionModel" - sessionId: string - providerID: string - modelID: string -} - -// Request webview to send initial prompt to a newly created session (extension → webview) -export interface AgentManagerSendInitialMessage { - type: "agentManager.sendInitialMessage" - sessionId: string - worktreeId: string - text?: string - providerID?: string - modelID?: string - agent?: string - files?: Array<{ mime: string; url: string }> -} - -// legacy-migration start -export interface MigrationProviderInfo { - profileName: string - provider: string - model?: string - hasApiKey: boolean - supported: boolean - newProviderName?: string -} - -export interface MigrationMcpServerInfo { - name: string - type: string -} - -export interface MigrationCustomModeInfo { - name: string - slug: string -} - -export interface LegacyAutocompleteSettings { - enableAutoTrigger?: boolean - enableSmartInlineTaskKeybinding?: boolean - enableChatAutocomplete?: boolean -} - -export interface LegacySettings { - autoApprovalEnabled?: boolean - allowedCommands?: string[] - deniedCommands?: string[] - // Fine-grained auto-approval (legacy globalState keys — no prefix) - alwaysAllowReadOnly?: boolean - alwaysAllowReadOnlyOutsideWorkspace?: boolean - alwaysAllowWrite?: boolean - alwaysAllowExecute?: boolean - alwaysAllowMcp?: boolean - alwaysAllowModeSwitch?: boolean - alwaysAllowSubtasks?: boolean - language?: string - autocomplete?: LegacyAutocompleteSettings -} - -export interface MigrationSessionInfo { - id: string - title: string - directory: string - time: number -} - -export interface MigrationResultItem { - item: string - category: "provider" | "mcpServer" | "customMode" | "session" | "defaultModel" | "settings" - status: "success" | "warning" | "error" - message?: string -} - -export interface MigrationStateMessage { - type: "migrationState" - needed: boolean - data?: { - providers: MigrationProviderInfo[] - mcpServers: MigrationMcpServerInfo[] - customModes: MigrationCustomModeInfo[] - sessions?: MigrationSessionInfo[] - defaultModel?: { provider: string; model: string } - settings?: LegacySettings - } -} - -export interface LegacyMigrationDataMessage { - type: "legacyMigrationData" - data: { - providers: MigrationProviderInfo[] - mcpServers: MigrationMcpServerInfo[] - customModes: MigrationCustomModeInfo[] - sessions?: MigrationSessionInfo[] - defaultModel?: { provider: string; model: string } - settings?: LegacySettings - } -} - -export interface LegacyMigrationProgressMessage { - type: "legacyMigrationProgress" - item: string - status: "migrating" | "success" | "warning" | "error" - message?: string -} - -export type LegacyMigrationSessionPhase = "preparing" | "storing" | "skipped" | "done" | "summary" | "error" - -export interface LegacyMigrationSessionProgressMessage { - type: "legacyMigrationSessionProgress" - session: MigrationSessionInfo - index: number - total: number - phase: LegacyMigrationSessionPhase - error?: string -} - -export interface LegacyMigrationCompleteMessage { - type: "legacyMigrationComplete" - results: MigrationResultItem[] -} - -export interface RequestLegacyMigrationDataMessage { - type: "requestLegacyMigrationData" -} - -export interface MigrationAutoApprovalSelections { - commandRules: boolean - readPermission: boolean - writePermission: boolean - executePermission: boolean - mcpPermission: boolean - taskPermission: boolean -} - -export interface MigrationSessionSelection { - id: string - force?: boolean -} - -export interface StartLegacyMigrationMessage { - type: "startLegacyMigration" - selections: { - providers: string[] - mcpServers: string[] - customModes: string[] - sessions?: MigrationSessionSelection[] - defaultModel: boolean - settings: { - autoApproval: MigrationAutoApprovalSelections - language: boolean - autocomplete: boolean - } - } -} - -export interface SkipLegacyMigrationMessage { - type: "skipLegacyMigration" -} - -export interface ClearLegacyDataMessage { - type: "clearLegacyData" -} - -export interface FinalizeLegacyMigrationMessage { - type: "finalizeLegacyMigration" -} -// legacy-migration end - -// Enhance prompt result (extension → webview) -export interface EnhancePromptResultMessage { - type: "enhancePromptResult" - text: string - requestId: string -} - -// Enhance prompt error (extension → webview) -export interface EnhancePromptErrorMessage { - type: "enhancePromptError" - error: string - requestId: string -} - -// Sub-agent viewer: open a child session in read-only mode (extension → webview) -export interface ViewSubAgentSessionMessage { - type: "viewSubAgentSession" - sessionID: string -} - -export interface DiffViewerDiffsMessage { - type: "diffViewer.diffs" - diffs: WorktreeFileDiff[] -} - -export interface DiffViewerLoadingMessage { - type: "diffViewer.loading" - loading: boolean -} - -export interface DiffViewerRevertFileResultMessage { - type: "diffViewer.revertFileResult" - file: string - status: "success" | "error" - message: string -} - -export interface ClearPendingPromptsMessage { - type: "clearPendingPrompts" -} - -export interface ExtensionDataReadyMessage { - type: "extensionDataReady" -} - -// ============================================ -// Marketplace Messages -// ============================================ - -import type { - MarketplaceItem, - MarketplaceInstalledMetadata, - InstallMarketplaceItemOptions, - MarketplaceFilters, -} from "./marketplace" - -export interface MarketplaceDataMessage { - type: "marketplaceData" - marketplaceItems: MarketplaceItem[] - marketplaceInstalledMetadata: MarketplaceInstalledMetadata - errors?: string[] -} - -export interface MarketplaceInstallResultMessage { - type: "marketplaceInstallResult" - success: boolean - slug: string - error?: string -} - -export interface MarketplaceRemoveResultMessage { - type: "marketplaceRemoveResult" - success: boolean - slug: string - error?: string -} - -export interface FetchMarketplaceDataMessage { - type: "fetchMarketplaceData" -} - -export interface FilterMarketplaceItemsMessage { - type: "filterMarketplaceItems" - filters: MarketplaceFilters -} - -export interface InstallMarketplaceItemMessage { - type: "installMarketplaceItem" - mpItem: MarketplaceItem - mpInstallOptions: InstallMarketplaceItemOptions -} - -export interface RemoveInstalledMarketplaceItemMessage { - type: "removeInstalledMarketplaceItem" - mpItem: MarketplaceItem - mpInstallOptions: InstallMarketplaceItemOptions -} - -export interface ProviderOAuthReadyMessage { - type: "providerOAuthReady" - requestId: string - providerID: string - authorization: ProviderAuthAuthorization -} - -export interface ProviderConnectedMessage { - type: "providerConnected" - requestId: string - providerID: string -} - -export interface ProviderDisconnectedMessage { - type: "providerDisconnected" - requestId: string - providerID: string -} - -export interface ProviderActionErrorMessage { - type: "providerActionError" - requestId: string - providerID: string - action: "authorize" | "connect" | "disconnect" - message: string -} - -export interface CustomProviderModelsFetchedMessage { - type: "customProviderModelsFetched" - requestId: string - models?: Array<{ id: string; name: string }> - error?: string - /** True when error was HTTP 401/403 — hints the user to check their API key */ - auth?: boolean -} - -export type ExtensionMessage = - | ReadyMessage - | GitStatusMessage - | ConnectionStateMessage - | ErrorMessage - | SendMessageFailedMessage - | PartUpdatedMessage - | PartsUpdatedMessage - | SessionStatusMessage - | SessionErrorMessage - | PermissionRequestMessage - | PermissionResolvedMessage - | PermissionErrorMessage - | TodoUpdatedMessage - | SessionCreatedMessage - | SessionForkedMessage - | SessionUpdatedMessage - | SessionDeletedMessage - | MessageRemovedMessage - | MessagesLoadedMessage - | MessageCreatedMessage - | SessionsLoadedMessage - | CloudSessionsLoadedMessage - | GitRemoteUrlLoadedMessage - | ActionMessage - | ProfileDataMessage - | DeviceAuthStartedMessage - | DeviceAuthCompleteMessage - | DeviceAuthFailedMessage - | DeviceAuthCancelledMessage - | NavigateMessage - | ProvidersLoadedMessage - | AgentsLoadedMessage - | SkillsLoadedMessage - | CommandsLoadedMessage - | AutocompleteSettingsLoadedMessage - | ChatCompletionResultMessage - | FileSearchResultMessage - | TerminalContextResultMessage - | TerminalContextErrorMessage - | QuestionRequestMessage - | QuestionResolvedMessage - | QuestionErrorMessage - | SuggestionRequestMessage - | SuggestionResolvedMessage - | SuggestionErrorMessage - | BrowserSettingsLoadedMessage - | ClaudeCompatSettingLoadedMessage - | ConfigLoadedMessage - | ConfigUpdatedMessage - | ConfigUpdateFailedMessage - | GlobalConfigLoadedMessage - | NotificationSettingsLoadedMessage - | TimelineSettingLoadedMessage - | NotificationsLoadedMessage - | AgentManagerSessionMetaMessage - | AgentManagerRepoInfoMessage - | AgentManagerWorktreeSetupMessage - | AgentManagerSessionAddedMessage - | AgentManagerSessionForkedMessage - | AgentManagerStateMessage - | AgentManagerRunStatusMessage - | AgentManagerKeybindingsMessage - | AgentManagerMultiVersionProgressMessage - | AgentManagerSetSessionModelMessage - | AgentManagerSendInitialMessage - | SetChatBoxMessage - | AppendChatBoxMessage - | AppendReviewCommentsMessage - | TriggerTaskMessage - | VariantsLoadedMessage - | CloudSessionDataLoadedMessage - | CloudSessionImportedMessage - | CloudSessionImportFailedMessage - | OpenCloudSessionMessage - | AgentManagerBranchesMessage - | AgentManagerExternalWorktreesMessage - | AgentManagerImportResultMessage - | WorkspaceDirectoryChangedMessage - | AgentManagerWorktreeDiffMessage - | AgentManagerWorktreeDiffFileMessage - | AgentManagerWorktreeDiffLoadingMessage - | AgentManagerApplyWorktreeDiffResultMessage - | AgentManagerRevertWorktreeFileResultMessage - | AgentManagerWorktreeStatsMessage - | AgentManagerLocalStatsMessage - | AgentManagerPRStatusMessage - | AgentManagerTerminalCreatedMessage - | AgentManagerTerminalClosedMessage - | AgentManagerTerminalErrorMessage - // legacy-migration start - | MigrationStateMessage - | LegacyMigrationDataMessage - | LegacyMigrationProgressMessage - | LegacyMigrationSessionProgressMessage - | LegacyMigrationCompleteMessage - // legacy-migration end - | EnhancePromptResultMessage - | EnhancePromptErrorMessage - | ViewSubAgentSessionMessage - | DiffViewerDiffsMessage - | DiffViewerLoadingMessage - | DiffViewerRevertFileResultMessage - | MarketplaceDataMessage - | MarketplaceInstallResultMessage - | MarketplaceRemoveResultMessage - | ProviderOAuthReadyMessage - | ProviderConnectedMessage - | ProviderDisconnectedMessage - | ProviderActionErrorMessage - | CustomProviderModelsFetchedMessage - | RecentsLoadedMessage - | FavoritesLoadedMessage - | ModelSelectionsLoadedMessage - | LanguageChangedMessage - | ContinueInWorktreeProgressMessage - | WorktreeStatsLoadedMessage - | McpStatusLoadedMessage - | ClearPendingPromptsMessage - | ExtensionDataReadyMessage - | RemoteStatusMessage - -// ============================================ -// Messages FROM webview TO extension -// ============================================ - -export interface FileAttachment { - mime: string - url: string - filename?: string - source?: FilePartSource -} - -export interface SendMessageRequest { - type: "sendMessage" - text: string - messageID?: string - sessionID?: string - draftID?: string - providerID?: string - modelID?: string - agent?: string - variant?: string - files?: FileAttachment[] -} - -export interface AbortRequest { - type: "abort" - sessionID: string -} - -export interface RevertSessionRequest { - type: "revertSession" - sessionID: string - messageID: string -} - -export interface UnrevertSessionRequest { - type: "unrevertSession" - sessionID: string -} - -export interface PermissionResponseRequest { - type: "permissionResponse" - permissionId: string - sessionID: string - response: "once" | "always" | "reject" - approvedAlways: string[] - deniedAlways: string[] -} - -export interface CreateSessionRequest { - type: "createSession" -} - -export interface ClearSessionRequest { - type: "clearSession" -} - -export interface LoadMessagesRequest { - type: "loadMessages" - sessionID: string - mode?: MessageLoadMode - before?: string - limit?: number -} - -export interface LoadSessionsRequest { - type: "loadSessions" -} - -export interface RequestCloudSessionsMessage { - type: "requestCloudSessions" - cursor?: string - limit?: number - gitUrl?: string -} - -export interface RequestGitRemoteUrlMessage { - type: "requestGitRemoteUrl" -} - -export interface RequestCloudSessionDataMessage { - type: "requestCloudSessionData" - sessionId: string -} - -export interface ImportAndSendMessage { - type: "importAndSend" - cloudSessionId: string - text: string - messageID?: string - providerID?: string - modelID?: string - agent?: string - variant?: string - files?: FileAttachment[] - command?: string - commandArgs?: string -} - -export interface LoginRequest { - type: "login" -} - -export interface LogoutRequest { - type: "logout" -} - -export interface RefreshProfileRequest { - type: "refreshProfile" -} - -export interface OpenExternalRequest { - type: "openExternal" - url: string -} - -export interface OpenFileRequest { - type: "openFile" - filePath: string - line?: number - column?: number -} - -export interface CancelLoginRequest { - type: "cancelLogin" -} - -export interface SetOrganizationRequest { - type: "setOrganization" - organizationId: string | null -} - -export interface WebviewReadyRequest { - type: "webviewReady" -} - -export interface RequestProvidersMessage { - type: "requestProviders" -} - -export interface CompactRequest { - type: "compact" - sessionID: string - providerID?: string - modelID?: string -} - -export interface OpenSettingsPanelRequest { - type: "openSettingsPanel" - tab?: string -} - -export interface OpenVSCodeSettingsRequest { - type: "openVSCodeSettings" - query: string -} - -export interface OpenMarketplacePanelRequest { - type: "openMarketplacePanel" -} - -export interface RequestAgentsMessage { - type: "requestAgents" -} - -export interface RequestSkillsMessage { - type: "requestSkills" -} - -export interface RequestCommandsMessage { - type: "requestCommands" -} - -export interface SendCommandRequest { - type: "sendCommand" - command: string - arguments: string - messageID?: string - sessionID?: string - draftID?: string - providerID?: string - modelID?: string - agent?: string - variant?: string - files?: FileAttachment[] -} - -export interface RemoveSkillMessage { - type: "removeSkill" - location: string -} - -export interface RemoveModeMessage { - type: "removeMode" - name: string -} - -export interface RemoveMcpMessage { - type: "removeMcp" - name: string -} - -export interface RequestMcpStatusMessage { - type: "requestMcpStatus" -} - -export interface ConnectMcpMessage { - type: "connectMcp" - name: string -} - -export interface DisconnectMcpMessage { - type: "disconnectMcp" - name: string -} - -export interface McpStatusEntry { - status: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration" - error?: string -} - -export interface McpStatusLoadedMessage { - type: "mcpStatusLoaded" - status: Record -} - -export interface SetLanguageRequest { - type: "setLanguage" - locale: string -} - -export interface QuestionReplyRequest { - type: "questionReply" - requestID: string - sessionID?: string - answers: string[][] -} - -export interface QuestionRejectRequest { - type: "questionReject" - requestID: string - sessionID?: string -} - -export interface SuggestionAcceptRequest { - type: "suggestionAccept" - requestID: string - sessionID: string - index: number -} - -export interface SuggestionDismissRequest { - type: "suggestionDismiss" - requestID: string - sessionID: string -} - -export interface DeleteSessionRequest { - type: "deleteSession" - sessionID: string -} - -export interface RenameSessionRequest { - type: "renameSession" - sessionID: string - title: string -} - -export interface RequestAutocompleteSettingsMessage { - type: "requestAutocompleteSettings" -} - -export interface UpdateAutocompleteSettingMessage { - type: "updateAutocompleteSetting" - key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete" - value: boolean -} - -export interface RequestChatCompletionMessage { - type: "requestChatCompletion" - text: string - requestId: string -} - -export interface RequestFileSearchMessage { - type: "requestFileSearch" - query: string - requestId: string - sessionID?: string -} - -export interface RequestTerminalContextMessage { - type: "requestTerminalContext" - requestId: string - sessionID?: string -} - -export interface ChatCompletionAcceptedMessage { - type: "chatCompletionAccepted" - suggestionLength?: number -} -export interface UpdateSettingRequest { - type: "updateSetting" - key: string - value: unknown -} - -export interface RequestTimelineSettingMessage { - type: "requestTimelineSetting" -} - -export interface RequestBrowserSettingsMessage { - type: "requestBrowserSettings" -} - -export interface RequestClaudeCompatSettingMessage { - type: "requestClaudeCompatSetting" -} - -export interface ClaudeCompatSettingLoadedMessage { - type: "claudeCompatSettingLoaded" - enabled: boolean -} - -export interface RequestConfigMessage { - type: "requestConfig" -} - -export interface RequestGlobalConfigMessage { - type: "requestGlobalConfig" -} - -export interface UpdateConfigMessage { - type: "updateConfig" - config: Partial -} - -export interface RequestNotificationSettingsMessage { - type: "requestNotificationSettings" -} - -export interface ResetAllSettingsRequest { - type: "resetAllSettings" -} - -export interface SettingsTabChangedMessage { - type: "settingsTabChanged" - tab: string -} - -export interface RequestNotificationsMessage { - type: "requestNotifications" -} - -export interface DismissNotificationMessage { - type: "dismissNotification" - notificationId: string -} - -export interface SyncSessionRequest { - type: "syncSession" - sessionID: string - parentSessionID?: string -} - -// Agent Manager worktree messages -export interface CreateWorktreeSessionRequest { - type: "agentManager.createWorktreeSession" - text: string - providerID?: string - modelID?: string - agent?: string - files?: FileAttachment[] -} - -export interface TelemetryRequest { - type: "telemetry" - event: string - properties?: Record -} - -// Create a new worktree (with auto-created first session) -export interface CreateWorktreeRequest { - type: "agentManager.createWorktree" - baseBranch?: string - branchName?: string - variant?: string -} - -// Delete a worktree and dissociate its sessions -export interface DeleteWorktreeRequest { - type: "agentManager.deleteWorktree" - worktreeId: string -} - -// Remove a stale worktree entry from state without touching disk -export interface RemoveStaleWorktreeRequest { - type: "agentManager.removeStaleWorktree" - worktreeId: string -} - -// Promote a session: create a worktree and move the session into it -export interface PromoteSessionRequest { - type: "agentManager.promoteSession" - sessionId: string -} - -// Open an unassigned session locally (clear any worktree directory override) -export interface OpenLocallyRequest { - type: "agentManager.openLocally" - sessionId: string -} - -// Add a new session to an existing worktree -export interface AddSessionToWorktreeRequest { - type: "agentManager.addSessionToWorktree" - worktreeId: string -} - -// Fork an existing session (copies conversation history) -export interface ForkSessionRequest { - type: "agentManager.forkSession" - sessionId: string - worktreeId?: string - messageId?: string -} - -export interface SidebarForkSessionRequest { - type: "forkSession" - sessionId: string - messageId?: string -} - -// Close (remove) a session from its worktree -export interface CloseSessionRequest { - type: "agentManager.closeSession" - sessionId: string -} - -/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */ -export interface PersistSessionRequest { - type: "agentManager.persistSession" - sessionId: string -} - -/** Remove a non-worktree session from agent-manager.json. */ -export interface ForgetSessionRequest { - type: "agentManager.forgetSession" - sessionId: string -} - -// Rename a worktree's display label -export interface RenameWorktreeRequest { - type: "agentManager.renameWorktree" - worktreeId: string - label: string -} - -export interface RequestRepoInfoMessage { - type: "agentManager.requestRepoInfo" -} - -export interface RequestStateMessage { - type: "agentManager.requestState" -} - -// Configure worktree setup script -export interface ConfigureSetupScriptRequest { - type: "agentManager.configureSetupScript" -} - -export interface ConfigureRunScriptRequest { - type: "agentManager.configureRunScript" -} - -export interface RunScriptRequest { - type: "agentManager.runScript" - worktreeId: string -} - -export interface StopRunScriptRequest { - type: "agentManager.stopRunScript" - worktreeId: string -} - -// Show terminal for a session -export interface ShowTerminalRequest { - type: "agentManager.showTerminal" - sessionId: string -} - -// Show terminal for the local workspace (when no session is active) -export interface ShowLocalTerminalRequest { - type: "agentManager.showLocalTerminal" -} - -// Open a worktree directory in VS Code -export interface OpenWorktreeRequest { - type: "agentManager.openWorktree" - worktreeId: string -} - -// Copy text to the system clipboard via the extension host -export interface CopyToClipboardRequest { - type: "agentManager.copyToClipboard" - text: string -} - -// Show existing local terminal when switching to local context (no-op if none exists) -export interface ShowExistingLocalTerminalRequest { - type: "agentManager.showExistingLocalTerminal" -} - -// Create a new xterm terminal tab in the given worktree context (null = local) -export interface AgentManagerTerminalCreateRequest { - type: "agentManager.terminal.create" - worktreeId: string | null -} - -// Close a terminal tab -export interface AgentManagerTerminalCloseRequest { - type: "agentManager.terminal.close" - terminalId: string -} - -// Notify the extension of an xterm resize so it can update the backend PTY dimensions -export interface AgentManagerTerminalResizeRequest { - type: "agentManager.terminal.resize" - terminalId: string - cols: number - rows: number -} - -// Open a file in the selected worktree for a specific session -export interface AgentManagerOpenFileRequest { - type: "agentManager.openFile" - sessionId: string - filePath: string - line?: number - column?: number -} - -/** - * Maximum number of parallel worktree versions for multi-version mode. - * Keep in sync with MAX_MULTI_VERSIONS in src/agent-manager/constants.ts. - */ -export const MAX_MULTI_VERSIONS = 4 - -// Per-version model allocation for multi-model comparison mode -export interface ModelAllocation { - providerID: string - modelID: string - count: number -} - -// Create multiple worktree sessions for the same prompt (multi-version mode) -export interface CreateMultiVersionRequest { - type: "agentManager.createMultiVersion" - text?: string - name?: string - versions: number - providerID?: string - modelID?: string - agent?: string - files?: FileAttachment[] - baseBranch?: string - branchName?: string - // Per-version model allocations for multi-model comparison mode. - // When set, each entry expands to `count` versions with that model. - // Overrides `versions`, `providerID`, and `modelID`. - variant?: string - modelAllocations?: ModelAllocation[] -} - -// Persist tab order for a context (worktree ID or "local") -export interface SetTabOrderRequest { - type: "agentManager.setTabOrder" - key: string - order: string[] -} - -// Persist sidebar worktree order -export interface SetWorktreeOrderRequest { - type: "agentManager.setWorktreeOrder" - order: string[] -} - -// Persist sessions collapsed state -export interface SetSessionsCollapsedRequest { - type: "agentManager.setSessionsCollapsed" - collapsed: boolean -} - -// Persist review diff style preference -export interface SetReviewDiffStyleRequest { - type: "agentManager.setReviewDiffStyle" - style: "unified" | "split" -} - -export interface RequestBranchesMessage { - type: "agentManager.requestBranches" -} - -export interface RequestExternalWorktreesMessage { - type: "agentManager.requestExternalWorktrees" -} - -export interface ImportFromBranchRequest { - type: "agentManager.importFromBranch" - branch: string -} - -export interface ImportFromPRRequest { - type: "agentManager.importFromPR" - url: string -} - -export interface ImportExternalWorktreeRequest { - type: "agentManager.importExternalWorktree" - path: string - branch: string -} - -export interface ImportAllExternalWorktreesRequest { - type: "agentManager.importAllExternalWorktrees" -} - -// Agent Manager: Request one-shot diff fetch (webview → extension) -export interface RequestWorktreeDiffMessage { - type: "agentManager.requestWorktreeDiff" - sessionId: string -} - -export interface RequestWorktreeDiffFileMessage { - type: "agentManager.requestWorktreeDiffFile" - sessionId: string - file: string -} - -// Agent Manager: Start polling for live diff updates (webview → extension) -export interface StartDiffWatchMessage { - type: "agentManager.startDiffWatch" - sessionId: string -} - -// Agent Manager: Stop polling for diff updates (webview → extension) -export interface StopDiffWatchMessage { - type: "agentManager.stopDiffWatch" -} - -// Agent Manager: PR messages (webview → extension) -export interface RefreshPRMessage { - type: "agentManager.refreshPR" - worktreeId: string -} - -export interface OpenPRMessage { - type: "agentManager.openPR" - worktreeId: string -} - -export interface ApplyWorktreeDiffMessage { - type: "agentManager.applyWorktreeDiff" - worktreeId: string - selectedFiles?: string[] -} - -// Agent Manager: Revert a single file in a worktree (webview → extension) -export interface RevertWorktreeFileMessage { - type: "agentManager.revertWorktreeFile" - sessionId: string - file: string -} - -// Variant persistence (webview → extension) -export interface PersistVariantRequest { - type: "persistVariant" - key: string - value: string -} - -// Request stored variants from extension (webview → extension) -export interface RequestVariantsMessage { - type: "requestVariants" -} - -// Enhance prompt request (webview → extension) -export interface EnhancePromptRequest { - type: "enhancePrompt" - text: string - requestId: string -} - -// Open the standalone changes viewer tab from the sidebar -export interface OpenChangesRequest { - type: "openChanges" -} - -// Open diff virtual (permission diff) in the lightweight diff virtual panel -export interface OpenDiffVirtualRequest { - type: "openDiffVirtual" - diff: PermissionFileDiff -} - -export interface RetryConnectionRequest { - type: "retryConnection" -} - -// Open a sub-agent session in a read-only editor panel -export interface OpenSubAgentViewerRequest { - type: "openSubAgentViewer" - sessionID: string - title?: string -} - -// Preview an image attachment in VS Code's built-in image viewer -export interface PreviewImageRequest { - type: "previewImage" - dataUrl: string - filename: string -} - -// Set default base branch (webview → extension) -export interface SetDefaultBaseBranchRequest { - type: "agentManager.setDefaultBaseBranch" - branch?: string -} - -// Report all open session IDs to extension for heartbeat (webview → extension) -export interface AgentManagerOpenSessionsMessage { - type: "agentManager.openSessions" - sessionIDs: string[] -} - -export interface RemoteStatusMessage { - type: "remoteStatus" - enabled: boolean - connected: boolean -} - -export interface ToggleRemoteMessage { - type: "toggleRemote" -} - -export interface SetRemoteEnabledMessage { - type: "setRemoteEnabled" - enabled: boolean -} - -export interface RequestRemoteStatusMessage { - type: "requestRemoteStatus" -} - -export interface ConnectProviderMessage { - type: "connectProvider" - requestId: string - providerID: string - apiKey: string -} - -export interface AuthorizeProviderOAuthMessage { - type: "authorizeProviderOAuth" - requestId: string - providerID: string - method: number -} - -export interface CompleteProviderOAuthMessage { - type: "completeProviderOAuth" - requestId: string - providerID: string - method: number - code?: string -} - -export interface DisconnectProviderMessage { - type: "disconnectProvider" - requestId: string - providerID: string -} - -export interface SaveCustomProviderMessage { - type: "saveCustomProvider" - requestId: string - providerID: string - config: ProviderConfig - apiKey?: string - apiKeyChanged?: boolean -} - -export interface FetchCustomProviderModelsMessage { - type: "fetchCustomProviderModels" - requestId: string - baseURL: string - apiKey?: string - headers?: Record -} - -export interface PersistRecentsRequest { - type: "persistRecents" - recents: ModelSelection[] -} - -export interface RequestRecentsMessage { - type: "requestRecents" -} - -export interface ToggleFavoriteRequest { - type: "toggleFavorite" - action: "add" | "remove" - providerID: string - modelID: string -} - -export interface RequestFavoritesMessage { - type: "requestFavorites" -} - -// Per-mode model selection persistence (webview → extension) -export interface PersistModelSelectionRequest { - type: "persistModelSelection" - agent: string - providerID: string - modelID: string -} - -export interface ClearModelSelectionRequest { - type: "clearModelSelection" - agent: string -} - -export interface RequestModelSelectionsMessage { - type: "requestModelSelections" -} - -// Continue in Worktree: transfer sidebar session + git state to an isolated worktree -export interface ContinueInWorktreeRequest { - type: "continueInWorktree" - sessionId: string -} - -// Section CRUD messages (webview → extension) -export interface CreateSectionRequest { - type: "agentManager.createSection" - name: string - color?: string - worktreeIds?: string[] -} - -export interface RenameSectionRequest { - type: "agentManager.renameSection" - sectionId: string - name: string -} - -export interface DeleteSectionRequest { - type: "agentManager.deleteSection" - sectionId: string -} - -export interface SetSectionColorRequest { - type: "agentManager.setSectionColor" - sectionId: string - color: string | null -} - -export interface ToggleSectionCollapsedRequest { - type: "agentManager.toggleSectionCollapsed" - sectionId: string -} - -export interface MoveToSectionRequest { - type: "agentManager.moveToSection" - worktreeIds: string[] - sectionId: string | null -} - -export interface MoveSectionRequest { - type: "agentManager.moveSection" - sectionId: string - dir: -1 | 1 -} - -export type ContinueInWorktreeStatus = - | "capturing" - | "creating" - | "setup" - | "transferring" - | "forking" - | "done" - | "error" - -// Continue in Worktree: progress updates (extension → webview) -export interface ContinueInWorktreeProgressMessage { - type: "continueInWorktreeProgress" - status: ContinueInWorktreeStatus - detail?: string - error?: string -} - -export type WebviewMessage = - | SendMessageRequest - | AbortRequest - | RevertSessionRequest - | UnrevertSessionRequest - | PermissionResponseRequest - | CreateSessionRequest - | ClearSessionRequest - | LoadMessagesRequest - | LoadSessionsRequest - | RequestCloudSessionsMessage - | RequestGitRemoteUrlMessage - | LoginRequest - | LogoutRequest - | RefreshProfileRequest - | OpenExternalRequest - | OpenSettingsPanelRequest - | OpenVSCodeSettingsRequest - | OpenMarketplacePanelRequest - | OpenFileRequest - | CancelLoginRequest - | SetOrganizationRequest - | WebviewReadyRequest - | RequestProvidersMessage - | CompactRequest - | RequestAgentsMessage - | RequestSkillsMessage - | RequestCommandsMessage - | SendCommandRequest - | RemoveSkillMessage - | RemoveModeMessage - | RemoveMcpMessage - | RequestMcpStatusMessage - | ConnectMcpMessage - | DisconnectMcpMessage - | SetLanguageRequest - | QuestionReplyRequest - | QuestionRejectRequest - | SuggestionAcceptRequest - | SuggestionDismissRequest - | DeleteSessionRequest - | RenameSessionRequest - | RequestAutocompleteSettingsMessage - | UpdateAutocompleteSettingMessage - | RequestChatCompletionMessage - | RequestFileSearchMessage - | RequestTerminalContextMessage - | ChatCompletionAcceptedMessage - | UpdateSettingRequest - | RequestTimelineSettingMessage - | RequestBrowserSettingsMessage - | RequestClaudeCompatSettingMessage - | RequestConfigMessage - | RequestGlobalConfigMessage - | UpdateConfigMessage - | RequestNotificationSettingsMessage - | ResetAllSettingsRequest - | SettingsTabChangedMessage - | SyncSessionRequest - | CreateWorktreeSessionRequest - | RequestNotificationsMessage - | DismissNotificationMessage - | CreateWorktreeRequest - | DeleteWorktreeRequest - | RemoveStaleWorktreeRequest - | PromoteSessionRequest - | OpenLocallyRequest - | AddSessionToWorktreeRequest - | ForkSessionRequest - | SidebarForkSessionRequest - | CloseSessionRequest - | PersistSessionRequest - | ForgetSessionRequest - | RenameWorktreeRequest - | TelemetryRequest - | RequestRepoInfoMessage - | RequestStateMessage - | ConfigureSetupScriptRequest - | ConfigureRunScriptRequest - | RunScriptRequest - | StopRunScriptRequest - | ShowTerminalRequest - | ShowLocalTerminalRequest - | OpenWorktreeRequest - | CopyToClipboardRequest - | ShowExistingLocalTerminalRequest - | AgentManagerOpenFileRequest - | CreateMultiVersionRequest - | SetTabOrderRequest - | SetWorktreeOrderRequest - | SetSessionsCollapsedRequest - | SetReviewDiffStyleRequest - | PersistVariantRequest - | RequestVariantsMessage - | RequestCloudSessionDataMessage - | ImportAndSendMessage - | RequestBranchesMessage - | RequestExternalWorktreesMessage - | ImportFromBranchRequest - | ImportFromPRRequest - | ImportExternalWorktreeRequest - | ImportAllExternalWorktreesRequest - | RequestWorktreeDiffMessage - | RequestWorktreeDiffFileMessage - | StartDiffWatchMessage - | StopDiffWatchMessage - | RefreshPRMessage - | OpenPRMessage - // legacy-migration start - | RequestLegacyMigrationDataMessage - | StartLegacyMigrationMessage - | SkipLegacyMigrationMessage - | ClearLegacyDataMessage - | FinalizeLegacyMigrationMessage - // legacy-migration end - | ApplyWorktreeDiffMessage - | RevertWorktreeFileMessage - | EnhancePromptRequest - | OpenChangesRequest - | OpenDiffVirtualRequest - | RetryConnectionRequest - | OpenSubAgentViewerRequest - | PreviewImageRequest - | SetDefaultBaseBranchRequest - | AgentManagerOpenSessionsMessage - | FetchMarketplaceDataMessage - | FilterMarketplaceItemsMessage - | InstallMarketplaceItemMessage - | RemoveInstalledMarketplaceItemMessage - | ConnectProviderMessage - | AuthorizeProviderOAuthMessage - | CompleteProviderOAuthMessage - | DisconnectProviderMessage - | SaveCustomProviderMessage - | FetchCustomProviderModelsMessage - | PersistRecentsRequest - | RequestRecentsMessage - | ToggleFavoriteRequest - | RequestFavoritesMessage - | PersistModelSelectionRequest - | ClearModelSelectionRequest - | RequestModelSelectionsMessage - | ToggleRemoteMessage - | SetRemoteEnabledMessage - | RequestRemoteStatusMessage - | ContinueInWorktreeRequest - | CreateSectionRequest - | RenameSectionRequest - | DeleteSectionRequest - | SetSectionColorRequest - | ToggleSectionCollapsedRequest - | MoveToSectionRequest - | MoveSectionRequest - | AgentManagerTerminalCreateRequest - | AgentManagerTerminalCloseRequest - | AgentManagerTerminalResizeRequest - -// ============================================ -// VS Code API type -// ============================================ - -export interface VSCodeAPI { - postMessage(message: WebviewMessage): void - getState(): unknown - setState(state: unknown): void -} - -declare global { - function acquireVsCodeApi(): VSCodeAPI -} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts new file mode 100644 index 0000000000..6a5a21e3cc --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -0,0 +1,191 @@ +export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" + +// Agent Manager worktree state types (mirrored from WorktreeStateManager) +export interface WorktreeState { + id: string + branch: string + path: string + /** Bare branch name (e.g. "main"), without remote prefix. */ + parentBranch: string + /** Remote name (e.g. "origin"). */ + remote?: string + createdAt: string + /** Shared identifier for worktrees created together via multi-version mode. */ + groupId?: string + /** User-provided display name for the worktree. */ + label?: string + /** Cached PR number for instant badge display on reload. */ + prNumber?: number + /** Cached PR URL for instant badge display on reload. */ + prUrl?: string + /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ + prState?: string + /** Section this worktree belongs to, or undefined for ungrouped. */ + sectionId?: string +} + +export interface SectionState { + id: string + name: string + /** Color label (e.g. "Red", "Blue") or null for default. */ + color: string | null + order: number + collapsed: boolean +} + +// --------------------------------------------------------------------------- +// PR status types (mirrored from extension types.ts) +// --------------------------------------------------------------------------- + +export type PRState = "open" | "draft" | "merged" | "closed" +export type ReviewDecision = "approved" | "changes_requested" | "pending" +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" +export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" + +export interface PRCheck { + name: string + status: CheckStatus + url?: string + duration?: string +} + +export interface PRComment { + id: string + author: string + avatar?: string + body: string + file?: string + line?: number + url?: string + resolved: boolean + createdAt?: number +} + +export interface PRStatus { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + checks: { + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + } + comments?: { + total: number + unresolved: number + items: PRComment[] + } + additions: number + deletions: number + files: number +} + +export type RunState = "idle" | "running" | "stopping" + +export interface RunStatus { + worktreeId: string + state: RunState + exitCode?: number + signal?: string + startedAt?: string + finishedAt?: string + error?: string +} + +export interface ManagedSessionState { + id: string + worktreeId: string | null + createdAt: string +} + +export interface BranchInfo { + name: string + isLocal: boolean + isRemote: boolean + isDefault: boolean + lastCommitDate?: string + isCheckedOut?: boolean +} + +// Agent Manager Import tab: external worktrees (extension → webview) +export interface ExternalWorktreeInfo { + path: string + branch: string +} + +// Shared FileDiff shape (matches Snapshot.FileDiff from CLI backend) +export interface WorktreeFileDiff { + file: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} + +export type AgentManagerApplyWorktreeDiffStatus = "checking" | "applying" | "success" | "conflict" | "error" + +export interface AgentManagerApplyWorktreeDiffConflict { + file?: string + reason: string +} + +// Per-worktree git stats: diff additions/deletions and ahead/behind counts +export interface WorktreeGitStats { + worktreeId: string + files: number + additions: number + deletions: number + ahead: number + behind: number +} + +// Per-local-workspace git stats: branch name, diff additions/deletions, ahead/behind counts +export interface LocalGitStats { + branch: string + files: number + additions: number + deletions: number + ahead: number + behind: number +} + +export interface ReviewComment { + id: string + file: string + side: "additions" | "deletions" + line: number + comment: string + selectedText: string +} + +/** + * Maximum number of parallel worktree versions for multi-version mode. + * Keep in sync with MAX_MULTI_VERSIONS in src/agent-manager/constants.ts. + */ +export const MAX_MULTI_VERSIONS = 4 + +// Per-version model allocation for multi-model comparison mode +export interface ModelAllocation { + providerID: string + modelID: string + count: number +} + +export type ContinueInWorktreeStatus = + | "capturing" + | "creating" + | "setup" + | "transferring" + | "forking" + | "done" + | "error" diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts new file mode 100644 index 0000000000..d69af69426 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts @@ -0,0 +1,42 @@ +import type { PermissionConfig, PermissionRuleItem } from "./permissions" + +// Skill info from CLI backend +export interface SkillInfo { + name: string + description: string + location: string +} + +// Slash command info from CLI backend +export interface SlashCommandInfo { + name: string + description?: string + source?: "command" | "mcp" | "skill" + hints: string[] +} + +// Agent/mode info from CLI backend +export interface AgentInfo { + name: string + displayName?: string + description?: string + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + deprecated?: boolean + color?: string + permission?: PermissionRuleItem[] +} + +export interface AgentConfig { + model?: string | null + prompt?: string + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + disable?: boolean + temperature?: number + top_p?: number + steps?: number + permission?: PermissionConfig +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts new file mode 100644 index 0000000000..7fb3b1fec1 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -0,0 +1,81 @@ +import type { PermissionConfig } from "./permissions" +import type { AgentConfig } from "./agents" +import type { ProviderConfig } from "./providers" + +export interface McpConfig { + type?: "local" | "remote" + command?: string[] | string + args?: string[] + env?: Record + environment?: Record + url?: string + headers?: Record + enabled?: boolean +} + +export interface CommandConfig { + template: string + description?: string + agent?: string + model?: string +} + +export interface SkillsConfig { + paths?: string[] + urls?: string[] +} + +export interface CompactionConfig { + auto?: boolean + prune?: boolean +} + +export interface WatcherConfig { + ignore?: string[] +} + +export interface ExperimentalConfig { + disable_paste_summary?: boolean + batch_tool?: boolean + codebase_search?: boolean + primary_tools?: string[] + continue_loop_on_deny?: boolean + mcp_timeout?: number +} + +export interface CommitMessageConfig { + prompt?: string +} + +export interface BrowserSettings { + enabled: boolean + useSystemChrome: boolean + headless: boolean +} + +export interface Config { + permission?: PermissionConfig + model?: string | null + small_model?: string | null + default_agent?: string + agent?: Record + provider?: Record + disabled_providers?: string[] + enabled_providers?: string[] + mcp?: Record + command?: Record + instructions?: string[] + skills?: SkillsConfig + snapshot?: boolean + remote_control?: boolean + share?: "manual" | "auto" | "disabled" + username?: string + watcher?: WatcherConfig + formatter?: false | Record + lsp?: false | Record + compaction?: CompactionConfig + commit_message?: CommitMessageConfig + tools?: Record + layout?: "auto" | "stretch" + experimental?: ExperimentalConfig +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/connection.ts b/packages/kilo-vscode/webview-ui/src/types/messages/connection.ts new file mode 100644 index 0000000000..d048a975d3 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/connection.ts @@ -0,0 +1,30 @@ +// Connection states +export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" + +// Session status (simplified from backend) +export type SessionStatus = "idle" | "busy" | "retry" | "offline" + +// Rich status info for retry countdown and future extensions +export type SessionStatusInfo = + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number } + | { type: "offline"; message: string } + +// Server info +export interface ServerInfo { + port: number + version?: string +} + +// Device auth flow status +export type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" + +// Device auth state +export interface DeviceAuthState { + status: DeviceAuthStatus + code?: string + verificationUrl?: string + expiresIn?: number + error?: string +} 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 new file mode 100644 index 0000000000..6c7807956c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -0,0 +1,906 @@ +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client" +import type { PartBatch, PartUpdate } from "../../../../src/shared/stream-messages" +import type { SessionMode } from "../../context/worktree-mode" +import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace" +import type { ConnectionState, ServerInfo, SessionStatus } from "./connection" +import type { FileAttachment, Part } from "./parts" +import type { CloudSessionInfo, Message, MessageLoadMode, SessionInfo } from "./sessions" +import type { PermissionRequest } from "./permissions" +import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" +import type { ModelSelection, Provider, ProviderAuthState } from "./providers" +import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents" +import type { BrowserSettings, Config } from "./config" +import type { KilocodeNotification, ProfileData } from "./profile" +import type { + AgentManagerApplyWorktreeDiffConflict, + AgentManagerApplyWorktreeDiffStatus, + BranchInfo, + ContinueInWorktreeStatus, + ExternalWorktreeInfo, + LocalGitStats, + ManagedSessionState, + PRStatus, + ReviewComment, + RunStatus, + SectionState, + WorktreeErrorCode, + WorktreeFileDiff, + WorktreeGitStats, + WorktreeState, +} from "./agent-manager" +import type { + LegacyMigrationCompleteMessage, + LegacyMigrationDataMessage, + LegacyMigrationProgressMessage, + LegacyMigrationSessionProgressMessage, + MigrationStateMessage, +} from "./migration" + +// ============================================ +// Messages FROM extension TO webview +// ============================================ + +export interface ReadyMessage { + type: "ready" + serverInfo?: ServerInfo + extensionVersion?: string + vscodeLanguage?: string + languageOverride?: string + workspaceDirectory?: string +} + +export interface GitStatusMessage { + type: "gitStatus" + repo: boolean +} + +export interface WorkspaceDirectoryChangedMessage { + type: "workspaceDirectoryChanged" + directory: string +} + +export interface LanguageChangedMessage { + type: "languageChanged" + locale: string +} + +export interface ConnectionStateMessage { + type: "connectionState" + state: ConnectionState + error?: string + userMessage?: string + userDetails?: string +} + +export interface ErrorMessage { + type: "error" + message: string + code?: string + sessionID?: string +} + +export interface SendMessageFailedMessage { + type: "sendMessageFailed" + error: string + text: string + sessionID?: string + draftID?: string + messageID?: string + files?: FileAttachment[] +} + +// Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the +// webview's concrete union. +export type PartUpdatedMessage = PartUpdate +export type PartsUpdatedMessage = PartBatch + +export interface SessionStatusMessage { + type: "sessionStatus" + sessionID: string + status: SessionStatus + // Retry fields (present when status === "retry") + attempt?: number + message?: string + next?: number +} + +export interface SessionErrorMessage { + type: "sessionError" + sessionID?: string + error?: { name: string; data?: Record } +} + +export interface PermissionRequestMessage { + type: "permissionRequest" + permission: PermissionRequest +} + +export interface PermissionResolvedMessage { + type: "permissionResolved" + permissionID: string +} + +export interface PermissionErrorMessage { + type: "permissionError" + permissionID: string +} + +export interface TodoUpdatedMessage { + type: "todoUpdated" + sessionID: string + items: TodoItem[] +} + +export interface SessionCreatedMessage { + type: "sessionCreated" + session: SessionInfo + draftID?: string +} + +export interface SessionForkedMessage { + type: "sessionForked" + sessionID: string +} + +export interface SessionUpdatedMessage { + type: "sessionUpdated" + session: SessionInfo +} + +export interface SessionDeletedMessage { + type: "sessionDeleted" + sessionID: string +} + +export interface MessageRemovedMessage { + type: "messageRemoved" + sessionID: string + messageID: string +} + +export interface MessagesLoadedMessage { + type: "messagesLoaded" + sessionID: string + messages: Message[] + mode?: Exclude + cursor?: string + hasMore?: boolean +} + +export interface MessageCreatedMessage { + type: "messageCreated" + message: Message +} + +export interface SessionsLoadedMessage { + type: "sessionsLoaded" + sessions: SessionInfo[] + preserveSessionIds?: string[] +} + +export interface CloudSessionsLoadedMessage { + type: "cloudSessionsLoaded" + sessions: CloudSessionInfo[] + nextCursor: string | null +} + +export interface GitRemoteUrlLoadedMessage { + type: "gitRemoteUrlLoaded" + gitUrl: string | null +} + +export interface CloudSessionDataLoadedMessage { + type: "cloudSessionDataLoaded" + cloudSessionId: string + title: string + messages: Message[] +} + +export interface CloudSessionImportedMessage { + type: "cloudSessionImported" + cloudSessionId: string + session: SessionInfo +} + +export interface CloudSessionImportFailedMessage { + type: "cloudSessionImportFailed" + cloudSessionId: string + error: string +} + +export interface OpenCloudSessionMessage { + type: "openCloudSession" + sessionId: string +} + +export interface ActionMessage { + type: "action" + action: string +} + +export interface SetChatBoxMessage { + type: "setChatBoxMessage" + text: string +} + +export interface AppendChatBoxMessage { + type: "appendChatBoxMessage" + text: string +} + +export interface AppendReviewCommentsMessage { + type: "appendReviewComments" + comments: ReviewComment[] + autoSend?: boolean +} + +export interface TriggerTaskMessage { + type: "triggerTask" + text: string +} + +export interface ProfileDataMessage { + type: "profileData" + data: ProfileData | null +} + +export interface DeviceAuthStartedMessage { + type: "deviceAuthStarted" + code?: string + verificationUrl: string + expiresIn: number +} + +export interface DeviceAuthCompleteMessage { + type: "deviceAuthComplete" +} + +export interface DeviceAuthFailedMessage { + type: "deviceAuthFailed" + error: string +} + +export interface DeviceAuthCancelledMessage { + type: "deviceAuthCancelled" +} + +export interface NavigateMessage { + type: "navigate" + view: "newTask" | "marketplace" | "history" | "profile" | "settings" | "subAgentViewer" + tab?: string +} + +export interface ProvidersLoadedMessage { + type: "providersLoaded" + providers: Record + connected: string[] + defaults: Record + defaultSelection: ModelSelection + authMethods: Record + authStates: Record +} + +export interface AgentsLoadedMessage { + type: "agentsLoaded" + agents: AgentInfo[] + allAgents: AgentInfo[] + defaultAgent: string +} + +export interface SkillsLoadedMessage { + type: "skillsLoaded" + skills: SkillInfo[] +} + +export interface CommandsLoadedMessage { + type: "commandsLoaded" + commands: SlashCommandInfo[] +} + +export interface AutocompleteSettingsLoadedMessage { + type: "autocompleteSettingsLoaded" + settings: { + enableAutoTrigger: boolean + enableSmartInlineTaskKeybinding: boolean + enableChatAutocomplete: boolean + } +} + +export interface ChatCompletionResultMessage { + type: "chatCompletionResult" + text: string + requestId: string +} + +export interface FileSearchItem { + path: string + type: "file" | "folder" +} + +export interface FileSearchResultMessage { + type: "fileSearchResult" + paths: string[] + items?: FileSearchItem[] + dir: string + requestId: string +} + +export interface TerminalContextResultMessage { + type: "terminalContextResult" + requestId: string + content: string + truncated?: boolean +} + +export interface TerminalContextErrorMessage { + type: "terminalContextError" + requestId: string + error: string +} + +export interface QuestionRequestMessage { + type: "questionRequest" + question: QuestionRequest +} + +export interface QuestionResolvedMessage { + type: "questionResolved" + requestID: string +} + +export interface QuestionErrorMessage { + type: "questionError" + requestID: string +} + +export interface SuggestionRequestMessage { + type: "suggestionRequest" + suggestion: SuggestionRequest +} + +export interface SuggestionResolvedMessage { + type: "suggestionResolved" + requestID: string +} + +export interface SuggestionErrorMessage { + type: "suggestionError" + requestID: string +} + +export interface BrowserSettingsLoadedMessage { + type: "browserSettingsLoaded" + settings: BrowserSettings +} + +export interface ClaudeCompatSettingLoadedMessage { + type: "claudeCompatSettingLoaded" + enabled: boolean +} + +export interface ConfigLoadedMessage { + type: "configLoaded" + config: Config +} + +export interface ConfigUpdatedMessage { + type: "configUpdated" + config: Config +} + +export interface ConfigUpdateFailedMessage { + type: "configUpdateFailed" + message: string + details?: string +} + +export interface GlobalConfigLoadedMessage { + type: "globalConfigLoaded" + config: Config +} + +export interface NotificationSettingsLoadedMessage { + type: "notificationSettingsLoaded" + settings: { + notifyAgent: boolean + notifyPermissions: boolean + notifyErrors: boolean + soundAgent: string + soundPermissions: string + soundErrors: string + } +} + +export interface TimelineSettingLoadedMessage { + type: "timelineSettingLoaded" + visible: boolean +} + +export interface NotificationsLoadedMessage { + type: "notificationsLoaded" + notifications: KilocodeNotification[] + dismissedIds: string[] +} + +// Agent Manager worktree session metadata +export interface AgentManagerSessionMetaMessage { + type: "agentManager.sessionMeta" + sessionId: string + mode: SessionMode + branch?: string + path?: string + parentBranch?: string +} + +// Agent Manager repo info (current branch of the main workspace) +export interface AgentManagerRepoInfoMessage { + type: "agentManager.repoInfo" + branch: string + defaultBranch?: string +} + +// Agent Manager worktree setup progress +export interface AgentManagerWorktreeSetupMessage { + type: "agentManager.worktreeSetup" + status: "creating" | "starting" | "ready" | "error" + message: string + sessionId?: string + branch?: string + worktreeId?: string + errorCode?: WorktreeErrorCode +} + +// Agent Manager session added to an existing worktree (no setup overlay needed) +export interface AgentManagerSessionAddedMessage { + type: "agentManager.sessionAdded" + sessionId: string + worktreeId: string +} + +// Agent Manager session forked from an existing session +export interface AgentManagerSessionForkedMessage { + type: "agentManager.sessionForked" + sessionId: string + forkedFromId: string + worktreeId?: string +} + +// Full state push from extension to webview +export interface AgentManagerStateMessage { + type: "agentManager.state" + worktrees: WorktreeState[] + sessions: ManagedSessionState[] + sections?: SectionState[] + staleWorktreeIds?: string[] + tabOrder?: Record + worktreeOrder?: string[] + sessionsCollapsed?: boolean + reviewDiffStyle?: "unified" | "split" + isGitRepo?: boolean + defaultBaseBranch?: string + runStatuses?: RunStatus[] + runScriptConfigured?: boolean + runScriptPath?: string +} + +// --------------------------------------------------------------------------- +// Agent Manager terminal messages +// --------------------------------------------------------------------------- + +export interface AgentManagerTerminalCreatedMessage { + type: "agentManager.terminal.created" + /** null for LOCAL, worktree id otherwise */ + worktreeId: string | null + terminalId: string + title: string + wsUrl: string +} + +export interface AgentManagerTerminalClosedMessage { + type: "agentManager.terminal.closed" + terminalId: string +} + +export interface AgentManagerTerminalErrorMessage { + type: "agentManager.terminal.error" + terminalId?: string + message: string +} + +export interface AgentManagerRunStatusMessage extends RunStatus { + type: "agentManager.runStatus" +} + +// Resolved keybindings for agent manager actions +export interface AgentManagerKeybindingsMessage { + type: "agentManager.keybindings" + bindings: Record +} + +// Multi-version creation progress (extension → webview) +export interface AgentManagerMultiVersionProgressMessage { + type: "agentManager.multiVersionProgress" + status: "creating" | "done" + total: number + completed: number + groupId?: string +} + +// Stored variant selections loaded from extension globalState (extension → webview) +export interface VariantsLoadedMessage { + type: "variantsLoaded" + variants: Record +} + +export interface RecentsLoadedMessage { + type: "recentsLoaded" + recents: ModelSelection[] +} + +export interface FavoritesLoadedMessage { + type: "favoritesLoaded" + favorites: ModelSelection[] +} + +// Per-mode model selections loaded from model.json (extension → webview) +export interface ModelSelectionsLoadedMessage { + type: "modelSelectionsLoaded" + selections: Record +} + +export interface AgentManagerBranchesMessage { + type: "agentManager.branches" + branches: BranchInfo[] + defaultBranch: string +} + +export interface AgentManagerExternalWorktreesMessage { + type: "agentManager.externalWorktrees" + worktrees: ExternalWorktreeInfo[] +} + +// Agent Manager Import tab: result feedback (extension → webview) +export interface AgentManagerImportResultMessage { + type: "agentManager.importResult" + success: boolean + message: string + errorCode?: WorktreeErrorCode +} + +// Agent Manager: Diff data push (extension → webview) +export interface AgentManagerWorktreeDiffMessage { + type: "agentManager.worktreeDiff" + sessionId: string + diffs: WorktreeFileDiff[] +} + +export interface AgentManagerWorktreeDiffFileMessage { + type: "agentManager.worktreeDiffFile" + sessionId: string + file: string + diff: WorktreeFileDiff | null +} + +// Agent Manager: Diff loading state (extension → webview) +export interface AgentManagerWorktreeDiffLoadingMessage { + type: "agentManager.worktreeDiffLoading" + sessionId: string + loading: boolean +} + +export interface AgentManagerApplyWorktreeDiffResultMessage { + type: "agentManager.applyWorktreeDiffResult" + worktreeId: string + status: AgentManagerApplyWorktreeDiffStatus + message: string + conflicts?: AgentManagerApplyWorktreeDiffConflict[] +} + +// Agent Manager: Revert single file result (extension → webview) +export interface AgentManagerRevertWorktreeFileResultMessage { + type: "agentManager.revertWorktreeFileResult" + sessionId: string + file: string + status: "success" | "error" + message: string +} + +// Agent Manager: Worktree git stats push (extension → webview) +export interface AgentManagerWorktreeStatsMessage { + type: "agentManager.worktreeStats" + stats: WorktreeGitStats[] +} + +// Agent Manager: Local workspace git stats push (extension → webview) +export interface AgentManagerLocalStatsMessage { + type: "agentManager.localStats" + stats: LocalGitStats +} + +// Agent Manager: PR status push (extension → webview) +export interface AgentManagerPRStatusMessage { + type: "agentManager.prStatus" + worktreeId: string + pr: PRStatus | null + error?: "gh_missing" | "gh_auth" | "fetch_failed" +} + +// Sidebar: Live worktree diff stats (extension → webview) +export interface WorktreeStatsLoadedMessage { + type: "worktreeStatsLoaded" + files: number + additions: number + deletions: number +} + +// Set the model for a session (extension → webview, used during multi-version creation) +export interface AgentManagerSetSessionModelMessage { + type: "agentManager.setSessionModel" + sessionId: string + providerID: string + modelID: string +} + +// Request webview to send initial prompt to a newly created session (extension → webview) +export interface AgentManagerSendInitialMessage { + type: "agentManager.sendInitialMessage" + sessionId: string + worktreeId: string + text?: string + providerID?: string + modelID?: string + agent?: string + files?: Array<{ mime: string; url: string }> +} + +// Enhance prompt result (extension → webview) +export interface EnhancePromptResultMessage { + type: "enhancePromptResult" + text: string + requestId: string +} + +// Enhance prompt error (extension → webview) +export interface EnhancePromptErrorMessage { + type: "enhancePromptError" + error: string + requestId: string +} + +// Sub-agent viewer: open a child session in read-only mode (extension → webview) +export interface ViewSubAgentSessionMessage { + type: "viewSubAgentSession" + sessionID: string +} + +export interface DiffViewerDiffsMessage { + type: "diffViewer.diffs" + diffs: WorktreeFileDiff[] +} + +export interface DiffViewerLoadingMessage { + type: "diffViewer.loading" + loading: boolean +} + +export interface DiffViewerRevertFileResultMessage { + type: "diffViewer.revertFileResult" + file: string + status: "success" | "error" + message: string +} + +export interface ClearPendingPromptsMessage { + type: "clearPendingPrompts" +} + +export interface ExtensionDataReadyMessage { + type: "extensionDataReady" +} + +// ============================================ +// Marketplace Messages +// ============================================ + +export interface MarketplaceDataMessage { + type: "marketplaceData" + marketplaceItems: MarketplaceItem[] + marketplaceInstalledMetadata: MarketplaceInstalledMetadata + errors?: string[] +} + +export interface MarketplaceInstallResultMessage { + type: "marketplaceInstallResult" + success: boolean + slug: string + error?: string +} + +export interface MarketplaceRemoveResultMessage { + type: "marketplaceRemoveResult" + success: boolean + slug: string + error?: string +} + +export interface ProviderOAuthReadyMessage { + type: "providerOAuthReady" + requestId: string + providerID: string + authorization: ProviderAuthAuthorization +} + +export interface ProviderConnectedMessage { + type: "providerConnected" + requestId: string + providerID: string +} + +export interface ProviderDisconnectedMessage { + type: "providerDisconnected" + requestId: string + providerID: string +} + +export interface ProviderActionErrorMessage { + type: "providerActionError" + requestId: string + providerID: string + action: "authorize" | "connect" | "disconnect" + message: string +} + +export interface CustomProviderModelsFetchedMessage { + type: "customProviderModelsFetched" + requestId: string + models?: Array<{ id: string; name: string }> + error?: string + /** True when error was HTTP 401/403 — hints the user to check their API key */ + auth?: boolean +} + +export interface McpStatusEntry { + status: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration" + error?: string +} + +export interface McpStatusLoadedMessage { + type: "mcpStatusLoaded" + status: Record +} + +// Continue in Worktree: progress updates (extension → webview) +export interface ContinueInWorktreeProgressMessage { + type: "continueInWorktreeProgress" + status: ContinueInWorktreeStatus + detail?: string + error?: string +} + +export interface RemoteStatusMessage { + type: "remoteStatus" + enabled: boolean + connected: boolean +} + +export type ExtensionMessage = + | ReadyMessage + | GitStatusMessage + | ConnectionStateMessage + | ErrorMessage + | SendMessageFailedMessage + | PartUpdatedMessage + | PartsUpdatedMessage + | SessionStatusMessage + | SessionErrorMessage + | PermissionRequestMessage + | PermissionResolvedMessage + | PermissionErrorMessage + | TodoUpdatedMessage + | SessionCreatedMessage + | SessionForkedMessage + | SessionUpdatedMessage + | SessionDeletedMessage + | MessageRemovedMessage + | MessagesLoadedMessage + | MessageCreatedMessage + | SessionsLoadedMessage + | CloudSessionsLoadedMessage + | GitRemoteUrlLoadedMessage + | ActionMessage + | ProfileDataMessage + | DeviceAuthStartedMessage + | DeviceAuthCompleteMessage + | DeviceAuthFailedMessage + | DeviceAuthCancelledMessage + | NavigateMessage + | ProvidersLoadedMessage + | AgentsLoadedMessage + | SkillsLoadedMessage + | CommandsLoadedMessage + | AutocompleteSettingsLoadedMessage + | ChatCompletionResultMessage + | FileSearchResultMessage + | TerminalContextResultMessage + | TerminalContextErrorMessage + | QuestionRequestMessage + | QuestionResolvedMessage + | QuestionErrorMessage + | SuggestionRequestMessage + | SuggestionResolvedMessage + | SuggestionErrorMessage + | BrowserSettingsLoadedMessage + | ClaudeCompatSettingLoadedMessage + | ConfigLoadedMessage + | ConfigUpdatedMessage + | ConfigUpdateFailedMessage + | GlobalConfigLoadedMessage + | NotificationSettingsLoadedMessage + | TimelineSettingLoadedMessage + | NotificationsLoadedMessage + | AgentManagerSessionMetaMessage + | AgentManagerRepoInfoMessage + | AgentManagerWorktreeSetupMessage + | AgentManagerSessionAddedMessage + | AgentManagerSessionForkedMessage + | AgentManagerStateMessage + | AgentManagerRunStatusMessage + | AgentManagerKeybindingsMessage + | AgentManagerMultiVersionProgressMessage + | AgentManagerSetSessionModelMessage + | AgentManagerSendInitialMessage + | SetChatBoxMessage + | AppendChatBoxMessage + | AppendReviewCommentsMessage + | TriggerTaskMessage + | VariantsLoadedMessage + | CloudSessionDataLoadedMessage + | CloudSessionImportedMessage + | CloudSessionImportFailedMessage + | OpenCloudSessionMessage + | AgentManagerBranchesMessage + | AgentManagerExternalWorktreesMessage + | AgentManagerImportResultMessage + | WorkspaceDirectoryChangedMessage + | AgentManagerWorktreeDiffMessage + | AgentManagerWorktreeDiffFileMessage + | AgentManagerWorktreeDiffLoadingMessage + | AgentManagerApplyWorktreeDiffResultMessage + | AgentManagerRevertWorktreeFileResultMessage + | AgentManagerWorktreeStatsMessage + | AgentManagerLocalStatsMessage + | AgentManagerPRStatusMessage + | AgentManagerTerminalCreatedMessage + | AgentManagerTerminalClosedMessage + | AgentManagerTerminalErrorMessage + // legacy-migration start + | MigrationStateMessage + | LegacyMigrationDataMessage + | LegacyMigrationProgressMessage + | LegacyMigrationSessionProgressMessage + | LegacyMigrationCompleteMessage + // legacy-migration end + | EnhancePromptResultMessage + | EnhancePromptErrorMessage + | ViewSubAgentSessionMessage + | DiffViewerDiffsMessage + | DiffViewerLoadingMessage + | DiffViewerRevertFileResultMessage + | MarketplaceDataMessage + | MarketplaceInstallResultMessage + | MarketplaceRemoveResultMessage + | ProviderOAuthReadyMessage + | ProviderConnectedMessage + | ProviderDisconnectedMessage + | ProviderActionErrorMessage + | CustomProviderModelsFetchedMessage + | RecentsLoadedMessage + | FavoritesLoadedMessage + | ModelSelectionsLoadedMessage + | LanguageChangedMessage + | ContinueInWorktreeProgressMessage + | WorktreeStatsLoadedMessage + | McpStatusLoadedMessage + | ClearPendingPromptsMessage + | ExtensionDataReadyMessage + | RemoteStatusMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/index.ts b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts new file mode 100644 index 0000000000..f74b20db68 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts @@ -0,0 +1,17 @@ +/** + * Types for extension <-> webview message communication + */ + +export * from "./connection" +export * from "./parts" +export * from "./sessions" +export * from "./permissions" +export * from "./questions" +export * from "./providers" +export * from "./agents" +export * from "./config" +export * from "./profile" +export * from "./agent-manager" +export * from "./migration" +export * from "./extension-messages" +export * from "./webview-messages" diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/migration.ts b/packages/kilo-vscode/webview-ui/src/types/messages/migration.ts new file mode 100644 index 0000000000..c9375de144 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/migration.ts @@ -0,0 +1,150 @@ +// legacy-migration start +export interface MigrationProviderInfo { + profileName: string + provider: string + model?: string + hasApiKey: boolean + supported: boolean + newProviderName?: string +} + +export interface MigrationMcpServerInfo { + name: string + type: string +} + +export interface MigrationCustomModeInfo { + name: string + slug: string +} + +export interface LegacyAutocompleteSettings { + enableAutoTrigger?: boolean + enableSmartInlineTaskKeybinding?: boolean + enableChatAutocomplete?: boolean +} + +export interface LegacySettings { + autoApprovalEnabled?: boolean + allowedCommands?: string[] + deniedCommands?: string[] + // Fine-grained auto-approval (legacy globalState keys — no prefix) + alwaysAllowReadOnly?: boolean + alwaysAllowReadOnlyOutsideWorkspace?: boolean + alwaysAllowWrite?: boolean + alwaysAllowExecute?: boolean + alwaysAllowMcp?: boolean + alwaysAllowModeSwitch?: boolean + alwaysAllowSubtasks?: boolean + language?: string + autocomplete?: LegacyAutocompleteSettings +} + +export interface MigrationSessionInfo { + id: string + title: string + directory: string + time: number +} + +export interface MigrationResultItem { + item: string + category: "provider" | "mcpServer" | "customMode" | "session" | "defaultModel" | "settings" + status: "success" | "warning" | "error" + message?: string +} + +export interface MigrationStateMessage { + type: "migrationState" + needed: boolean + data?: { + providers: MigrationProviderInfo[] + mcpServers: MigrationMcpServerInfo[] + customModes: MigrationCustomModeInfo[] + sessions?: MigrationSessionInfo[] + defaultModel?: { provider: string; model: string } + settings?: LegacySettings + } +} + +export interface LegacyMigrationDataMessage { + type: "legacyMigrationData" + data: { + providers: MigrationProviderInfo[] + mcpServers: MigrationMcpServerInfo[] + customModes: MigrationCustomModeInfo[] + sessions?: MigrationSessionInfo[] + defaultModel?: { provider: string; model: string } + settings?: LegacySettings + } +} + +export interface LegacyMigrationProgressMessage { + type: "legacyMigrationProgress" + item: string + status: "migrating" | "success" | "warning" | "error" + message?: string +} + +export type LegacyMigrationSessionPhase = "preparing" | "storing" | "skipped" | "done" | "summary" | "error" + +export interface LegacyMigrationSessionProgressMessage { + type: "legacyMigrationSessionProgress" + session: MigrationSessionInfo + index: number + total: number + phase: LegacyMigrationSessionPhase + error?: string +} + +export interface LegacyMigrationCompleteMessage { + type: "legacyMigrationComplete" + results: MigrationResultItem[] +} + +export interface RequestLegacyMigrationDataMessage { + type: "requestLegacyMigrationData" +} + +export interface MigrationAutoApprovalSelections { + commandRules: boolean + readPermission: boolean + writePermission: boolean + executePermission: boolean + mcpPermission: boolean + taskPermission: boolean +} + +export interface MigrationSessionSelection { + id: string + force?: boolean +} + +export interface StartLegacyMigrationMessage { + type: "startLegacyMigration" + selections: { + providers: string[] + mcpServers: string[] + customModes: string[] + sessions?: MigrationSessionSelection[] + defaultModel: boolean + settings: { + autoApproval: MigrationAutoApprovalSelections + language: boolean + autocomplete: boolean + } + } +} + +export interface SkipLegacyMigrationMessage { + type: "skipLegacyMigration" +} + +export interface ClearLegacyDataMessage { + type: "clearLegacyData" +} + +export interface FinalizeLegacyMigrationMessage { + type: "finalizeLegacyMigration" +} +// legacy-migration end diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts new file mode 100644 index 0000000000..ae5153dd8e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts @@ -0,0 +1,94 @@ +// Tool state for tool parts +export type ToolState = + | { status: "pending"; input: Record } + | { status: "running"; input: Record; title?: string } + | { status: "completed"; input: Record; output: string; title: string } + | { status: "error"; input: Record; error: string } + +// Base part interface - all parts have these fields +export interface BasePart { + id: string + sessionID?: string + messageID?: string +} + +// Part types from the backend +export interface TextPart extends BasePart { + type: "text" + text: string +} + +export interface FilePartSource { + type: "file" + path: string + text: { + value: string + start: number + end: number + } +} + +export interface FilePart extends BasePart { + type: "file" + mime: string + url: string + filename?: string + source?: FilePartSource +} + +export interface ToolPart extends BasePart { + type: "tool" + tool: string + state: ToolState +} + +export interface ReasoningPart extends BasePart { + type: "reasoning" + text: string +} + +// Step parts from the backend +export interface StepStartPart extends BasePart { + type: "step-start" +} + +export interface StepFinishPart extends BasePart { + type: "step-finish" + reason?: string + cost?: number + tokens?: { + input: number + output: number + reasoning?: number + cache?: { read: number; write: number } + } +} + +export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart + +// Part delta for streaming updates +export interface PartDelta { + type: "text-delta" + textDelta?: string +} + +// Token usage for assistant messages +export interface TokenUsage { + input: number + output: number + reasoning?: number + cache?: { read: number; write: number } +} + +// Context usage derived from the last assistant message's tokens +export interface ContextUsage { + tokens: number + percentage: number | null +} + +export interface FileAttachment { + mime: string + url: string + filename?: string + source?: FilePartSource +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts new file mode 100644 index 0000000000..9ca30b2976 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts @@ -0,0 +1,39 @@ +export type PermissionLevel = "allow" | "ask" | "deny" + +/** null in a PermissionRule object is a delete sentinel — removes the key from the config */ +export type PermissionRule = PermissionLevel | Record + +export type PermissionConfig = Partial> + +// A single resolved permission rule from the CLI backend (matches PermissionNext.Rule) +export interface PermissionRuleItem { + permission: string + pattern: string + action: PermissionLevel +} + +// Permission request +export interface PermissionFileDiff { + file: string + patch?: string + before?: string + after?: string + additions: number + deletions: number +} + +export interface PermissionRequest { + id: string + sessionID: string + toolName: string + patterns: string[] + always: string[] + args: Record & { + rules?: string[] + diff?: string + filepath?: string + filediff?: PermissionFileDiff + } + message?: string + tool?: { messageID: string; callID: string } +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts b/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts new file mode 100644 index 0000000000..fc086dd97a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/profile.ts @@ -0,0 +1,29 @@ +// Kilo notification types (mirrored from kilo-gateway) +export interface KilocodeNotificationAction { + actionText: string + actionURL: string +} + +export interface KilocodeNotification { + id: string + title: string + message: string + action?: KilocodeNotificationAction + showIn?: string[] + suggestModelId?: string +} + +// Profile types from kilo-gateway +export interface KilocodeBalance { + balance: number +} + +export interface ProfileData { + profile: { + email: string + name?: string + organizations?: Array<{ id: string; name: string; role: string }> + } + balance: KilocodeBalance | null + currentOrgId: string | null +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts new file mode 100644 index 0000000000..649de14798 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts @@ -0,0 +1,54 @@ +// Provider/model types for model selector + +export interface ProviderModel { + id: string + name: string + inputPrice?: number + outputPrice?: number + contextLength?: number + releaseDate?: string + latest?: boolean + // Actual shape returned by the server (Provider.Model) + limit?: { context: number; input?: number; output: number } + variants?: Record> + capabilities?: { + reasoning: boolean + input?: { text: boolean; image: boolean; audio: boolean; video: boolean; pdf: boolean } + } + options?: { description?: string } + recommendedIndex?: number + isFree?: boolean + cost?: { + input: number + output: number + cache?: { + read: number + write: number + } + } +} + +export interface Provider { + id: string + name: string + models: Record + source?: "env" | "config" | "custom" | "api" + env?: string[] +} + +export interface ModelSelection { + providerID: string + modelID: string +} + +export type ProviderAuthState = "api" | "oauth" | "wellknown" + +export interface ProviderConfig { + name?: string + api_key?: string + base_url?: string + models?: Record + npm?: string + env?: string[] + options?: Record +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts new file mode 100644 index 0000000000..4970f50c3d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts @@ -0,0 +1,57 @@ +// Todo item +export interface TodoItem { + id: string + content: string + status: "pending" | "in_progress" | "completed" +} + +// Question types +export interface QuestionOption { + label: string + description: string + mode?: string + // Optional i18n keys — the backend fills these for strings it wants translated in the webview. + // The canonical English `label` stays on the reply wire, so server-side matching is unaffected. + labelKey?: string + descriptionKey?: string +} + +export interface QuestionInfo { + question: string + header: string + options: QuestionOption[] + multiple?: boolean + custom?: boolean + // Optional i18n keys for question text and header (see QuestionOption for details). + questionKey?: string + headerKey?: string +} + +export interface QuestionRequest { + id: string + sessionID: string + questions: QuestionInfo[] + blocking?: boolean + tool?: { + messageID: string + callID: string + } +} + +export interface SuggestionAction { + label: string + description?: string + prompt: string +} + +export interface SuggestionRequest { + id: string + sessionID: string + text: string + actions: SuggestionAction[] + blocking?: boolean + tool?: { + messageID: string + callID: string + } +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts new file mode 100644 index 0000000000..2afbcfa4a9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -0,0 +1,65 @@ +import type { Part, TokenUsage } from "./parts" + +// Message structure (simplified for webview) +export interface Message { + id: string + sessionID: string + role: "user" | "assistant" + content?: string + parts?: Part[] + createdAt: string + time?: { created: number; completed?: number } + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string + mode?: string + parentID?: string + path?: { cwd: string; root: string } + error?: { name: string; data?: Record } + summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean + cost?: number + tokens?: TokenUsage + finish?: string +} + +// File diff info (matches Snapshot.FileDiff from CLI backend) +export interface SessionFileDiff { + file: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +// Session info (simplified for webview) +export interface SessionInfo { + id: string + parentID?: string | null + title?: string + createdAt: string + updatedAt: string + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + summary?: { + additions: number + deletions: number + files: number + diffs?: SessionFileDiff[] + } | null +} + +// Cloud session info (from Kilo cloud API) +export interface CloudSessionInfo { + session_id: string + title: string | null + created_at: string + updated_at: string +} + +export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" 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 new file mode 100644 index 0000000000..36744f34b5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -0,0 +1,1048 @@ +import type { InstallMarketplaceItemOptions, MarketplaceFilters, MarketplaceItem } from "../marketplace" +import type { FileAttachment } from "./parts" +import type { MessageLoadMode } from "./sessions" +import type { PermissionFileDiff } from "./permissions" +import type { ModelSelection, ProviderConfig } from "./providers" +import type { Config } from "./config" +import type { ModelAllocation } from "./agent-manager" +import type { + ClearLegacyDataMessage, + FinalizeLegacyMigrationMessage, + RequestLegacyMigrationDataMessage, + SkipLegacyMigrationMessage, + StartLegacyMigrationMessage, +} from "./migration" + +// ============================================ +// Messages FROM webview TO extension +// ============================================ + +export interface SendMessageRequest { + type: "sendMessage" + text: string + messageID?: string + sessionID?: string + draftID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: FileAttachment[] +} + +export interface AbortRequest { + type: "abort" + sessionID: string +} + +export interface RevertSessionRequest { + type: "revertSession" + sessionID: string + messageID: string +} + +export interface UnrevertSessionRequest { + type: "unrevertSession" + sessionID: string +} + +export interface PermissionResponseRequest { + type: "permissionResponse" + permissionId: string + sessionID: string + response: "once" | "always" | "reject" + approvedAlways: string[] + deniedAlways: string[] +} + +export interface CreateSessionRequest { + type: "createSession" +} + +export interface ClearSessionRequest { + type: "clearSession" +} + +export interface LoadMessagesRequest { + type: "loadMessages" + sessionID: string + mode?: MessageLoadMode + before?: string + limit?: number +} + +export interface LoadSessionsRequest { + type: "loadSessions" +} + +export interface RequestCloudSessionsMessage { + type: "requestCloudSessions" + cursor?: string + limit?: number + gitUrl?: string +} + +export interface RequestGitRemoteUrlMessage { + type: "requestGitRemoteUrl" +} + +export interface RequestCloudSessionDataMessage { + type: "requestCloudSessionData" + sessionId: string +} + +export interface ImportAndSendMessage { + type: "importAndSend" + cloudSessionId: string + text: string + messageID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: FileAttachment[] + command?: string + commandArgs?: string +} + +export interface LoginRequest { + type: "login" +} + +export interface LogoutRequest { + type: "logout" +} + +export interface RefreshProfileRequest { + type: "refreshProfile" +} + +export interface OpenExternalRequest { + type: "openExternal" + url: string +} + +export interface OpenFileRequest { + type: "openFile" + filePath: string + line?: number + column?: number +} + +export interface CancelLoginRequest { + type: "cancelLogin" +} + +export interface SetOrganizationRequest { + type: "setOrganization" + organizationId: string | null +} + +export interface WebviewReadyRequest { + type: "webviewReady" +} + +export interface RequestProvidersMessage { + type: "requestProviders" +} + +export interface CompactRequest { + type: "compact" + sessionID: string + providerID?: string + modelID?: string +} + +export interface OpenSettingsPanelRequest { + type: "openSettingsPanel" + tab?: string +} + +export interface OpenVSCodeSettingsRequest { + type: "openVSCodeSettings" + query: string +} + +export interface OpenMarketplacePanelRequest { + type: "openMarketplacePanel" +} + +export interface RequestAgentsMessage { + type: "requestAgents" +} + +export interface RequestSkillsMessage { + type: "requestSkills" +} + +export interface RequestCommandsMessage { + type: "requestCommands" +} + +export interface SendCommandRequest { + type: "sendCommand" + command: string + arguments: string + messageID?: string + sessionID?: string + draftID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: FileAttachment[] +} + +export interface RemoveSkillMessage { + type: "removeSkill" + location: string +} + +export interface RemoveModeMessage { + type: "removeMode" + name: string +} + +export interface RemoveMcpMessage { + type: "removeMcp" + name: string +} + +export interface RequestMcpStatusMessage { + type: "requestMcpStatus" +} + +export interface ConnectMcpMessage { + type: "connectMcp" + name: string +} + +export interface DisconnectMcpMessage { + type: "disconnectMcp" + name: string +} + +export interface SetLanguageRequest { + type: "setLanguage" + locale: string +} + +export interface QuestionReplyRequest { + type: "questionReply" + requestID: string + sessionID?: string + answers: string[][] +} + +export interface QuestionRejectRequest { + type: "questionReject" + requestID: string + sessionID?: string +} + +export interface SuggestionAcceptRequest { + type: "suggestionAccept" + requestID: string + sessionID: string + index: number +} + +export interface SuggestionDismissRequest { + type: "suggestionDismiss" + requestID: string + sessionID: string +} + +export interface DeleteSessionRequest { + type: "deleteSession" + sessionID: string +} + +export interface RenameSessionRequest { + type: "renameSession" + sessionID: string + title: string +} + +export interface RequestAutocompleteSettingsMessage { + type: "requestAutocompleteSettings" +} + +export interface UpdateAutocompleteSettingMessage { + type: "updateAutocompleteSetting" + key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete" + value: boolean +} + +export interface RequestChatCompletionMessage { + type: "requestChatCompletion" + text: string + requestId: string +} + +export interface RequestFileSearchMessage { + type: "requestFileSearch" + query: string + requestId: string + sessionID?: string +} + +export interface RequestTerminalContextMessage { + type: "requestTerminalContext" + requestId: string + sessionID?: string +} + +export interface ChatCompletionAcceptedMessage { + type: "chatCompletionAccepted" + suggestionLength?: number +} +export interface UpdateSettingRequest { + type: "updateSetting" + key: string + value: unknown +} + +export interface RequestTimelineSettingMessage { + type: "requestTimelineSetting" +} + +export interface RequestBrowserSettingsMessage { + type: "requestBrowserSettings" +} + +export interface RequestClaudeCompatSettingMessage { + type: "requestClaudeCompatSetting" +} + +export interface RequestConfigMessage { + type: "requestConfig" +} + +export interface RequestGlobalConfigMessage { + type: "requestGlobalConfig" +} + +export interface UpdateConfigMessage { + type: "updateConfig" + config: Partial +} + +export interface RequestNotificationSettingsMessage { + type: "requestNotificationSettings" +} + +export interface ResetAllSettingsRequest { + type: "resetAllSettings" +} + +export interface SettingsTabChangedMessage { + type: "settingsTabChanged" + tab: string +} + +export interface RequestNotificationsMessage { + type: "requestNotifications" +} + +export interface DismissNotificationMessage { + type: "dismissNotification" + notificationId: string +} + +export interface SyncSessionRequest { + type: "syncSession" + sessionID: string + parentSessionID?: string +} + +// Agent Manager worktree messages +export interface CreateWorktreeSessionRequest { + type: "agentManager.createWorktreeSession" + text: string + providerID?: string + modelID?: string + agent?: string + files?: FileAttachment[] +} + +export interface TelemetryRequest { + type: "telemetry" + event: string + properties?: Record +} + +// Create a new worktree (with auto-created first session) +export interface CreateWorktreeRequest { + type: "agentManager.createWorktree" + baseBranch?: string + branchName?: string + variant?: string +} + +// Delete a worktree and dissociate its sessions +export interface DeleteWorktreeRequest { + type: "agentManager.deleteWorktree" + worktreeId: string +} + +// Remove a stale worktree entry from state without touching disk +export interface RemoveStaleWorktreeRequest { + type: "agentManager.removeStaleWorktree" + worktreeId: string +} + +// Promote a session: create a worktree and move the session into it +export interface PromoteSessionRequest { + type: "agentManager.promoteSession" + sessionId: string +} + +// Open an unassigned session locally (clear any worktree directory override) +export interface OpenLocallyRequest { + type: "agentManager.openLocally" + sessionId: string +} + +// Add a new session to an existing worktree +export interface AddSessionToWorktreeRequest { + type: "agentManager.addSessionToWorktree" + worktreeId: string +} + +// Fork an existing session (copies conversation history) +export interface ForkSessionRequest { + type: "agentManager.forkSession" + sessionId: string + worktreeId?: string + messageId?: string +} + +export interface SidebarForkSessionRequest { + type: "forkSession" + sessionId: string + messageId?: string +} + +// Close (remove) a session from its worktree +export interface CloseSessionRequest { + type: "agentManager.closeSession" + sessionId: string +} + +/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */ +export interface PersistSessionRequest { + type: "agentManager.persistSession" + sessionId: string +} + +/** Remove a non-worktree session from agent-manager.json. */ +export interface ForgetSessionRequest { + type: "agentManager.forgetSession" + sessionId: string +} + +// Rename a worktree's display label +export interface RenameWorktreeRequest { + type: "agentManager.renameWorktree" + worktreeId: string + label: string +} + +export interface RequestRepoInfoMessage { + type: "agentManager.requestRepoInfo" +} + +export interface RequestStateMessage { + type: "agentManager.requestState" +} + +// Configure worktree setup script +export interface ConfigureSetupScriptRequest { + type: "agentManager.configureSetupScript" +} + +export interface ConfigureRunScriptRequest { + type: "agentManager.configureRunScript" +} + +export interface RunScriptRequest { + type: "agentManager.runScript" + worktreeId: string +} + +export interface StopRunScriptRequest { + type: "agentManager.stopRunScript" + worktreeId: string +} + +// Show terminal for a session +export interface ShowTerminalRequest { + type: "agentManager.showTerminal" + sessionId: string +} + +// Show terminal for the local workspace (when no session is active) +export interface ShowLocalTerminalRequest { + type: "agentManager.showLocalTerminal" +} + +// Open a worktree directory in VS Code +export interface OpenWorktreeRequest { + type: "agentManager.openWorktree" + worktreeId: string +} + +// Copy text to the system clipboard via the extension host +export interface CopyToClipboardRequest { + type: "agentManager.copyToClipboard" + text: string +} + +// Show existing local terminal when switching to local context (no-op if none exists) +export interface ShowExistingLocalTerminalRequest { + type: "agentManager.showExistingLocalTerminal" +} + +// Create a new xterm terminal tab in the given worktree context (null = local) +export interface AgentManagerTerminalCreateRequest { + type: "agentManager.terminal.create" + worktreeId: string | null +} + +// Close a terminal tab +export interface AgentManagerTerminalCloseRequest { + type: "agentManager.terminal.close" + terminalId: string +} + +// Notify the extension of an xterm resize so it can update the backend PTY dimensions +export interface AgentManagerTerminalResizeRequest { + type: "agentManager.terminal.resize" + terminalId: string + cols: number + rows: number +} + +// Open a file in the selected worktree for a specific session +export interface AgentManagerOpenFileRequest { + type: "agentManager.openFile" + sessionId: string + filePath: string + line?: number + column?: number +} + +// Create multiple worktree sessions for the same prompt (multi-version mode) +export interface CreateMultiVersionRequest { + type: "agentManager.createMultiVersion" + text?: string + name?: string + versions: number + providerID?: string + modelID?: string + agent?: string + files?: FileAttachment[] + baseBranch?: string + branchName?: string + // Per-version model allocations for multi-model comparison mode. + // When set, each entry expands to `count` versions with that model. + // Overrides `versions`, `providerID`, and `modelID`. + variant?: string + modelAllocations?: ModelAllocation[] +} + +// Persist tab order for a context (worktree ID or "local") +export interface SetTabOrderRequest { + type: "agentManager.setTabOrder" + key: string + order: string[] +} + +// Persist sidebar worktree order +export interface SetWorktreeOrderRequest { + type: "agentManager.setWorktreeOrder" + order: string[] +} + +// Persist sessions collapsed state +export interface SetSessionsCollapsedRequest { + type: "agentManager.setSessionsCollapsed" + collapsed: boolean +} + +// Persist review diff style preference +export interface SetReviewDiffStyleRequest { + type: "agentManager.setReviewDiffStyle" + style: "unified" | "split" +} + +export interface RequestBranchesMessage { + type: "agentManager.requestBranches" +} + +export interface RequestExternalWorktreesMessage { + type: "agentManager.requestExternalWorktrees" +} + +export interface ImportFromBranchRequest { + type: "agentManager.importFromBranch" + branch: string +} + +export interface ImportFromPRRequest { + type: "agentManager.importFromPR" + url: string +} + +export interface ImportExternalWorktreeRequest { + type: "agentManager.importExternalWorktree" + path: string + branch: string +} + +export interface ImportAllExternalWorktreesRequest { + type: "agentManager.importAllExternalWorktrees" +} + +// Agent Manager: Request one-shot diff fetch (webview → extension) +export interface RequestWorktreeDiffMessage { + type: "agentManager.requestWorktreeDiff" + sessionId: string +} + +export interface RequestWorktreeDiffFileMessage { + type: "agentManager.requestWorktreeDiffFile" + sessionId: string + file: string +} + +// Agent Manager: Start polling for live diff updates (webview → extension) +export interface StartDiffWatchMessage { + type: "agentManager.startDiffWatch" + sessionId: string +} + +// Agent Manager: Stop polling for diff updates (webview → extension) +export interface StopDiffWatchMessage { + type: "agentManager.stopDiffWatch" +} + +// Agent Manager: PR messages (webview → extension) +export interface RefreshPRMessage { + type: "agentManager.refreshPR" + worktreeId: string +} + +export interface OpenPRMessage { + type: "agentManager.openPR" + worktreeId: string +} + +export interface ApplyWorktreeDiffMessage { + type: "agentManager.applyWorktreeDiff" + worktreeId: string + selectedFiles?: string[] +} + +// Agent Manager: Revert a single file in a worktree (webview → extension) +export interface RevertWorktreeFileMessage { + type: "agentManager.revertWorktreeFile" + sessionId: string + file: string +} + +// Variant persistence (webview → extension) +export interface PersistVariantRequest { + type: "persistVariant" + key: string + value: string +} + +// Request stored variants from extension (webview → extension) +export interface RequestVariantsMessage { + type: "requestVariants" +} + +// Enhance prompt request (webview → extension) +export interface EnhancePromptRequest { + type: "enhancePrompt" + text: string + requestId: string +} + +// Open the standalone changes viewer tab from the sidebar +export interface OpenChangesRequest { + type: "openChanges" +} + +// Open diff virtual (permission diff) in the lightweight diff virtual panel +export interface OpenDiffVirtualRequest { + type: "openDiffVirtual" + diff: PermissionFileDiff +} + +export interface RetryConnectionRequest { + type: "retryConnection" +} + +// Open a sub-agent session in a read-only editor panel +export interface OpenSubAgentViewerRequest { + type: "openSubAgentViewer" + sessionID: string + title?: string +} + +// Preview an image attachment in VS Code's built-in image viewer +export interface PreviewImageRequest { + type: "previewImage" + dataUrl: string + filename: string +} + +// Set default base branch (webview → extension) +export interface SetDefaultBaseBranchRequest { + type: "agentManager.setDefaultBaseBranch" + branch?: string +} + +// Report all open session IDs to extension for heartbeat (webview → extension) +export interface AgentManagerOpenSessionsMessage { + type: "agentManager.openSessions" + sessionIDs: string[] +} + +export interface ToggleRemoteMessage { + type: "toggleRemote" +} + +export interface SetRemoteEnabledMessage { + type: "setRemoteEnabled" + enabled: boolean +} + +export interface RequestRemoteStatusMessage { + type: "requestRemoteStatus" +} + +export interface ConnectProviderMessage { + type: "connectProvider" + requestId: string + providerID: string + apiKey: string +} + +export interface AuthorizeProviderOAuthMessage { + type: "authorizeProviderOAuth" + requestId: string + providerID: string + method: number +} + +export interface CompleteProviderOAuthMessage { + type: "completeProviderOAuth" + requestId: string + providerID: string + method: number + code?: string +} + +export interface DisconnectProviderMessage { + type: "disconnectProvider" + requestId: string + providerID: string +} + +export interface SaveCustomProviderMessage { + type: "saveCustomProvider" + requestId: string + providerID: string + config: ProviderConfig + apiKey?: string + apiKeyChanged?: boolean +} + +export interface FetchCustomProviderModelsMessage { + type: "fetchCustomProviderModels" + requestId: string + baseURL: string + apiKey?: string + headers?: Record +} + +export interface PersistRecentsRequest { + type: "persistRecents" + recents: ModelSelection[] +} + +export interface RequestRecentsMessage { + type: "requestRecents" +} + +export interface ToggleFavoriteRequest { + type: "toggleFavorite" + action: "add" | "remove" + providerID: string + modelID: string +} + +export interface RequestFavoritesMessage { + type: "requestFavorites" +} + +// Per-mode model selection persistence (webview → extension) +export interface PersistModelSelectionRequest { + type: "persistModelSelection" + agent: string + providerID: string + modelID: string +} + +export interface ClearModelSelectionRequest { + type: "clearModelSelection" + agent: string +} + +export interface RequestModelSelectionsMessage { + type: "requestModelSelections" +} + +// Continue in Worktree: transfer sidebar session + git state to an isolated worktree +export interface ContinueInWorktreeRequest { + type: "continueInWorktree" + sessionId: string +} + +// Section CRUD messages (webview → extension) +export interface CreateSectionRequest { + type: "agentManager.createSection" + name: string + color?: string + worktreeIds?: string[] +} + +export interface RenameSectionRequest { + type: "agentManager.renameSection" + sectionId: string + name: string +} + +export interface DeleteSectionRequest { + type: "agentManager.deleteSection" + sectionId: string +} + +export interface SetSectionColorRequest { + type: "agentManager.setSectionColor" + sectionId: string + color: string | null +} + +export interface ToggleSectionCollapsedRequest { + type: "agentManager.toggleSectionCollapsed" + sectionId: string +} + +export interface MoveToSectionRequest { + type: "agentManager.moveToSection" + worktreeIds: string[] + sectionId: string | null +} + +export interface MoveSectionRequest { + type: "agentManager.moveSection" + sectionId: string + dir: -1 | 1 +} + +export interface FetchMarketplaceDataMessage { + type: "fetchMarketplaceData" +} + +export interface FilterMarketplaceItemsMessage { + type: "filterMarketplaceItems" + filters: MarketplaceFilters +} + +export interface InstallMarketplaceItemMessage { + type: "installMarketplaceItem" + mpItem: MarketplaceItem + mpInstallOptions: InstallMarketplaceItemOptions +} + +export interface RemoveInstalledMarketplaceItemMessage { + type: "removeInstalledMarketplaceItem" + mpItem: MarketplaceItem + mpInstallOptions: InstallMarketplaceItemOptions +} + +export type WebviewMessage = + | SendMessageRequest + | AbortRequest + | RevertSessionRequest + | UnrevertSessionRequest + | PermissionResponseRequest + | CreateSessionRequest + | ClearSessionRequest + | LoadMessagesRequest + | LoadSessionsRequest + | RequestCloudSessionsMessage + | RequestGitRemoteUrlMessage + | LoginRequest + | LogoutRequest + | RefreshProfileRequest + | OpenExternalRequest + | OpenSettingsPanelRequest + | OpenVSCodeSettingsRequest + | OpenMarketplacePanelRequest + | OpenFileRequest + | CancelLoginRequest + | SetOrganizationRequest + | WebviewReadyRequest + | RequestProvidersMessage + | CompactRequest + | RequestAgentsMessage + | RequestSkillsMessage + | RequestCommandsMessage + | SendCommandRequest + | RemoveSkillMessage + | RemoveModeMessage + | RemoveMcpMessage + | RequestMcpStatusMessage + | ConnectMcpMessage + | DisconnectMcpMessage + | SetLanguageRequest + | QuestionReplyRequest + | QuestionRejectRequest + | SuggestionAcceptRequest + | SuggestionDismissRequest + | DeleteSessionRequest + | RenameSessionRequest + | RequestAutocompleteSettingsMessage + | UpdateAutocompleteSettingMessage + | RequestChatCompletionMessage + | RequestFileSearchMessage + | RequestTerminalContextMessage + | ChatCompletionAcceptedMessage + | UpdateSettingRequest + | RequestTimelineSettingMessage + | RequestBrowserSettingsMessage + | RequestClaudeCompatSettingMessage + | RequestConfigMessage + | RequestGlobalConfigMessage + | UpdateConfigMessage + | RequestNotificationSettingsMessage + | ResetAllSettingsRequest + | SettingsTabChangedMessage + | SyncSessionRequest + | CreateWorktreeSessionRequest + | RequestNotificationsMessage + | DismissNotificationMessage + | CreateWorktreeRequest + | DeleteWorktreeRequest + | RemoveStaleWorktreeRequest + | PromoteSessionRequest + | OpenLocallyRequest + | AddSessionToWorktreeRequest + | ForkSessionRequest + | SidebarForkSessionRequest + | CloseSessionRequest + | PersistSessionRequest + | ForgetSessionRequest + | RenameWorktreeRequest + | TelemetryRequest + | RequestRepoInfoMessage + | RequestStateMessage + | ConfigureSetupScriptRequest + | ConfigureRunScriptRequest + | RunScriptRequest + | StopRunScriptRequest + | ShowTerminalRequest + | ShowLocalTerminalRequest + | OpenWorktreeRequest + | CopyToClipboardRequest + | ShowExistingLocalTerminalRequest + | AgentManagerOpenFileRequest + | CreateMultiVersionRequest + | SetTabOrderRequest + | SetWorktreeOrderRequest + | SetSessionsCollapsedRequest + | SetReviewDiffStyleRequest + | PersistVariantRequest + | RequestVariantsMessage + | RequestCloudSessionDataMessage + | ImportAndSendMessage + | RequestBranchesMessage + | RequestExternalWorktreesMessage + | ImportFromBranchRequest + | ImportFromPRRequest + | ImportExternalWorktreeRequest + | ImportAllExternalWorktreesRequest + | RequestWorktreeDiffMessage + | RequestWorktreeDiffFileMessage + | StartDiffWatchMessage + | StopDiffWatchMessage + | RefreshPRMessage + | OpenPRMessage + // legacy-migration start + | RequestLegacyMigrationDataMessage + | StartLegacyMigrationMessage + | SkipLegacyMigrationMessage + | ClearLegacyDataMessage + | FinalizeLegacyMigrationMessage + // legacy-migration end + | ApplyWorktreeDiffMessage + | RevertWorktreeFileMessage + | EnhancePromptRequest + | OpenChangesRequest + | OpenDiffVirtualRequest + | RetryConnectionRequest + | OpenSubAgentViewerRequest + | PreviewImageRequest + | SetDefaultBaseBranchRequest + | AgentManagerOpenSessionsMessage + | FetchMarketplaceDataMessage + | FilterMarketplaceItemsMessage + | InstallMarketplaceItemMessage + | RemoveInstalledMarketplaceItemMessage + | ConnectProviderMessage + | AuthorizeProviderOAuthMessage + | CompleteProviderOAuthMessage + | DisconnectProviderMessage + | SaveCustomProviderMessage + | FetchCustomProviderModelsMessage + | PersistRecentsRequest + | RequestRecentsMessage + | ToggleFavoriteRequest + | RequestFavoritesMessage + | PersistModelSelectionRequest + | ClearModelSelectionRequest + | RequestModelSelectionsMessage + | ToggleRemoteMessage + | SetRemoteEnabledMessage + | RequestRemoteStatusMessage + | ContinueInWorktreeRequest + | CreateSectionRequest + | RenameSectionRequest + | DeleteSectionRequest + | SetSectionColorRequest + | ToggleSectionCollapsedRequest + | MoveToSectionRequest + | MoveSectionRequest + | AgentManagerTerminalCreateRequest + | AgentManagerTerminalCloseRequest + | AgentManagerTerminalResizeRequest + +// ============================================ +// VS Code API type +// ============================================ + +export interface VSCodeAPI { + postMessage(message: WebviewMessage): void + getState(): unknown + setState(state: unknown): void +} + +declare global { + function acquireVsCodeApi(): VSCodeAPI +} From 35f13cbc8678f80cf6870c233f74f47b028bf705 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 24 Apr 2026 10:33:16 +0200 Subject: [PATCH 66/70] fix(vscode): stabilize long session restores (#9444) * fix(vscode): stabilize long session restores * fix(vscode): render partial long-session turns * fix(vscode): split partial session turns --- .changeset/fix-long-session-loading.md | 5 ++ .../src/kilo-provider/message-page.ts | 8 +- .../tests/unit/message-page.test.ts | 30 ++++++++ .../tests/unit/session-queue.test.ts | 57 +++++++++++++- .../src/components/chat/MessageList.tsx | 8 +- .../src/components/chat/VscodeSessionTurn.tsx | 56 +++++++------- .../webview-ui/src/context/session-queue.ts | 77 ++++++++++++++++++- 7 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-long-session-loading.md diff --git a/.changeset/fix-long-session-loading.md b/.changeset/fix-long-session-loading.md new file mode 100644 index 0000000000..71ed584dd6 --- /dev/null +++ b/.changeset/fix-long-session-loading.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix restoring and paginating very long VS Code sessions. diff --git a/packages/kilo-vscode/src/kilo-provider/message-page.ts b/packages/kilo-vscode/src/kilo-provider/message-page.ts index aca1ba8b8a..504182a0c3 100644 --- a/packages/kilo-vscode/src/kilo-provider/message-page.ts +++ b/packages/kilo-vscode/src/kilo-provider/message-page.ts @@ -3,6 +3,9 @@ import { retry } from "../services/cli-backend/retry" export const MESSAGE_PAGE_LIMIT = 80 +// Bound assistant-boundary backfill so corrupt histories cannot load an entire session. +const FILL_LIMIT = 2 + /** * Build the same base64url-encoded cursor format the server emits so a * synthesized cursor round-trips through `session.messages({ before })`. @@ -46,12 +49,13 @@ export async function fetchMessagePage( return { items, cursor } } - const fill = async (page: Awaited>): Promise>> => { + const fill = async (page: Awaited>, depth = 0): Promise>> => { if (page.items[0]?.info.role !== "assistant") return page + if (depth >= FILL_LIMIT) return page if (!page.cursor || input.signal?.aborted) return page const next = await read(page.cursor) const items = [...next.items, ...page.items] - return fill({ items, cursor: next.cursor }) + return fill({ items, cursor: next.cursor }, depth + 1) } return fill(await read(input.before)) diff --git a/packages/kilo-vscode/tests/unit/message-page.test.ts b/packages/kilo-vscode/tests/unit/message-page.test.ts index 945ea2d628..2a2019d878 100644 --- a/packages/kilo-vscode/tests/unit/message-page.test.ts +++ b/packages/kilo-vscode/tests/unit/message-page.test.ts @@ -164,4 +164,34 @@ describe("fetchMessagePage / cursor fallback", () => { expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5"]) expect(page.cursor).toBeUndefined() }) + + it("bounds assistant turn filling when older pages never reach a user message", async () => { + const { client, calls } = mockClient([ + { + items: [message("m5", "assistant", 50), message("m6", "assistant", 60)], + cursor: "c1", + }, + { + items: [message("m3", "assistant", 30), message("m4", "assistant", 40)], + cursor: "c2", + }, + { + items: [message("m1", "assistant", 10), message("m2", "assistant", 20)], + cursor: "c3", + }, + { + items: [message("m0", "user", 0)], + }, + ]) + + const page = await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 2, + }) + + expect(calls.map((call) => call.before)).toEqual([undefined, "c1", "c2"]) + expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5", "m6"]) + expect(page.cursor).toBe("c3") + }) }) diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts index 9f298bd1ce..9234a499aa 100644 --- a/packages/kilo-vscode/tests/unit/session-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test" -import { activeUserMessageID, messageTurns, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue" +import { + activeUserMessageID, + messageTurns, + queuedUserMessageIDs, + stableMessageTurns, +} from "../../webview-ui/src/context/session-queue" import type { Message } from "../../webview-ui/src/types/messages" const base = { @@ -84,6 +89,56 @@ describe("messageTurns", () => { { user: "message_4", assistant: [] }, ]) }) + + it("surfaces leading assistant output as partial turns grouped by parent", () => { + const messages = [ + assistant("message_2", "message_1"), + assistant("message_4", "message_3"), + assistant("message_5", "message_3"), + user("message_6"), + ] + const turns = messageTurns(messages) + + expect( + turns.map((turn) => ({ id: turn.id, partial: turn.partial, assistant: turn.assistant.map((msg) => msg.id) })), + ).toEqual([ + { id: "message_1", partial: true, assistant: ["message_2"] }, + { id: "message_3", partial: true, assistant: ["message_4", "message_5"] }, + { id: "message_6", partial: undefined, assistant: [] }, + ]) + }) +}) + +describe("stableMessageTurns", () => { + it("keeps existing turn identities stable when older turns are prepended", () => { + const u1 = user("message_1") + const a2 = assistant("message_2", "message_1") + const u3 = user("message_3") + const prev = messageTurns([u1, a2, u3]) + const next = stableMessageTurns(messageTurns([user("message_0"), u1, a2, u3]), prev) + + expect(next[1]).toBe(prev[0]) + expect(next[2]).toBe(prev[1]) + }) + + it("replaces a turn identity when its assistant messages change", () => { + const u1 = user("message_1") + const a2 = assistant("message_2", "message_1") + const prev = messageTurns([u1, a2]) + const next = stableMessageTurns(messageTurns([u1, a2, assistant("message_3", "message_1")]), prev) + + expect(next[0]).not.toBe(prev[0]) + expect(next[0]?.assistant.map((msg) => msg.id)).toEqual(["message_2", "message_3"]) + }) + + it("keeps partial turn identities stable while their assistant messages are unchanged", () => { + const a2 = assistant("message_2", "message_1") + const a3 = assistant("message_3", "message_1") + const prev = messageTurns([a2, a3]) + const next = stableMessageTurns(messageTurns([a2, a3, user("message_4")]), prev) + + expect(next[0]).toBe(prev[0]) + }) }) describe("activeUserMessageID", () => { 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 7f546bb611..fc92a33ccc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -29,6 +29,8 @@ import { activeUserMessageID as getActiveUserMessageID, messageTurns, queuedUserMessageIDs, + stableMessageTurns, + type MessageTurn, } from "../../context/session-queue" import type { QuestionRequest, SuggestionRequest } from "../../types/messages" @@ -84,7 +86,9 @@ export const MessageList: Component = (props) => { const positions = new Map() const boundary = () => session.revert()?.messageID - const turns = createMemo(() => messageTurns(session.messages(), boundary())) + const turns = createMemo((prev: MessageTurn[] | undefined) => + stableMessageTurns(messageTurns(session.messages(), boundary()), prev), + ) const isEmpty = () => turns().length === 0 && !session.loading() && !boundary() const recent = createMemo(() => @@ -151,11 +155,11 @@ export const MessageList: Component = (props) => { if (pos?.userScrolled) { el.scrollTop = pos.top autoScroll.pause() + maybeLoadOlder() } else { autoScroll.forceScrollToBottom() } setPendingRestore(undefined) - maybeLoadOlder() }) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index 01e84785c7..925c0199ef 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -50,6 +50,7 @@ export interface VscodeTurn { id: string user: WebMessage assistant: WebMessage[] + partial?: boolean } interface VscodeSessionTurnProps { @@ -71,7 +72,8 @@ export const VscodeSessionTurn: Component = (props) => { createEffect(() => { const turn = props.turn - session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)]) + const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)] + session.hydrateParts(ids) }) const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" }) @@ -138,33 +140,35 @@ export const VscodeSessionTurn: Component = (props) => { {(msg) => (
{/* User message */} -
0 && !session.revert() && session.status() !== "idle" ? "" : undefined - } - title={ - assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" - ? language.t("revert.disabled.agentBusy") - : undefined - } - > - [0]["message"]} - parts={parts() as unknown as Parameters[0]["parts"]} - interrupted={interrupted()} - queued={props.queued} - onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined} - onRevert={ - assistantMessages().length > 0 && !session.revert() - ? () => { - if (session.status() !== "idle") return - session.revertSession(msg().id) - } + +
0 && !session.revert() && session.status() !== "idle" ? "" : undefined + } + title={ + assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" + ? language.t("revert.disabled.agentBusy") : undefined } - /> -
+ > + [0]["message"]} + parts={parts() as unknown as Parameters[0]["parts"]} + interrupted={interrupted()} + queued={props.queued} + onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined} + onRevert={ + assistantMessages().length > 0 && !session.revert() + ? () => { + if (session.status() !== "idle") return + session.revertSession(msg().id) + } + : undefined + } + /> +
+ {/* Assistant parts — flat list, no context grouping */} 0}> diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts index 759c444232..6d8fff78f4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts @@ -4,10 +4,47 @@ export interface MessageTurn { id: string user: Message assistant: Message[] + partial?: boolean } -export function messageTurns(messages: Message[], boundary?: string) { +function key(msg: Message) { + return msg.parentID ?? msg.id +} + +function partial(messages: Message[]): MessageTurn { + const first = messages[0]! + const id = first.parentID ?? `${first.id}:partial` + return { + id, + user: { + id, + sessionID: first.sessionID, + role: "user", + createdAt: first.createdAt, + time: first.time, + }, + assistant: messages, + partial: true, + } +} + +function partials(messages: Message[]): MessageTurn[] { + return messages + .reduce((groups, msg) => { + const prev = groups[groups.length - 1] + if (!prev || key(prev[0]!) !== key(msg)) { + groups.push([msg]) + return groups + } + prev.push(msg) + return groups + }, []) + .map(partial) +} + +export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] { const result: MessageTurn[] = [] + const lead: Message[] = [] const by = new Map() for (const msg of messages) { @@ -20,11 +57,43 @@ export function messageTurns(messages: Message[], boundary?: string) { } if (msg.role !== "assistant") continue - const turn = (msg.parentID ? by.get(msg.parentID) : undefined) ?? result[result.length - 1] - if (turn) turn.assistant.push(msg) + const turn = msg.parentID ? by.get(msg.parentID) : undefined + if (turn) { + turn.assistant.push(msg) + continue + } + const last = result[result.length - 1] + if (last) { + last.assistant.push(msg) + continue + } + lead.push(msg) } - return result + if (lead.length === 0) return result + return [...partials(lead), ...result] +} + +function sameMessages(a: Message[], b: Message[]) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +// Keep virtua's item keys stable across prepends; Solid's adapter keys by data object identity. +export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []): MessageTurn[] { + if (prev.length === 0) return next + const by = new Map(prev.map((turn) => [turn.user.id, turn])) + return next.map((turn) => { + const old = by.get(turn.user.id) + if (!old) return turn + if (old.partial !== turn.partial) return turn + if (!turn.partial && old.user !== turn.user) return turn + if (!sameMessages(old.assistant, turn.assistant)) return turn + return old + }) } function active(messages: Message[]) { From fb854a28154f83b3e7d1ccad91f964cb4b7bb67d Mon Sep 17 00:00:00 2001 From: "hdcode.dev" Date: Fri, 24 Apr 2026 10:39:16 +0200 Subject: [PATCH 67/70] fix(vscode): harden message contract union extraction (#9457) --- .../tests/unit/message-contract.test.ts | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/message-contract.test.ts b/packages/kilo-vscode/tests/unit/message-contract.test.ts index 2cb426ed14..5b37d2042b 100644 --- a/packages/kilo-vscode/tests/unit/message-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/message-contract.test.ts @@ -15,6 +15,8 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") const MESSAGES_DIR = path.join(ROOT, "webview-ui/src/types/messages") +const EXTENSION_MESSAGES_FILE = path.join(MESSAGES_DIR, "extension-messages.ts") +const WEBVIEW_MESSAGES_FILE = path.join(MESSAGES_DIR, "webview-messages.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts") // Some wire types (partUpdated, partsUpdated) live in a file shared by the @@ -37,48 +39,56 @@ function readMessageTypeSources(): string { return readMessagesDir() + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE) } -describe("ExtensionMessage type members", () => { - it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => { - const content = readMessagesDir() - - // Extract ExtensionMessage union members - const unionMatch = content.match( - /export type ExtensionMessage\s*=\s*([\s\S]*?)(?=\nexport type|\nexport interface|\nexport function|\n\/\/|$)/, - ) - if (!unionMatch) { - expect(false, "Could not find ExtensionMessage union in messages.ts").toBe(true) - return +/** + * Extract the named union's member names from a single source file. + * + * Reads each ` | MemberName` line after `export type Name =`, skipping + * blank lines and `//` comments, and stops at the first other line. + */ +function extractUnionMembers(src: string, name: string): string[] { + const lines = src.split("\n") + const start = lines.findIndex((l) => new RegExp(`^export type ${name}\\s*=\\s*$`).test(l)) + if (start === -1) throw new Error(`Could not find union "${name}"`) + const members: string[] = [] + for (const line of lines.slice(start + 1)) { + const m = line.match(/^\s*\|\s*([A-Z]\w+)\b/) + if (m) { + members.push(m[1]!) + continue } + if (line.trim() === "" || /^\s*\/\//.test(line)) continue + break + } + return members +} - const unionBody = unionMatch[1]! - const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) +describe("ExtensionMessage type members", () => { + it("all members of ExtensionMessage union are defined in message type sources", () => { + const memberNames = extractUnionMembers(readFile(EXTENSION_MESSAGES_FILE), "ExtensionMessage") const defined = readMessageTypeSources() const missing = memberNames.filter((name) => { return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined) }) - expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + expect( + missing, + `ExtensionMessage members without definitions in message type sources: ${missing.join(", ")}`, + ).toEqual([]) }) - it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => { - const content = readMessagesDir() - - const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/) - if (!unionMatch) { - expect(false, "Could not find WebviewMessage union in messages.ts").toBe(true) - return - } - - const unionBody = unionMatch[1]! - const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + it("all members of WebviewMessage union are defined in message type sources", () => { + const memberNames = extractUnionMembers(readFile(WEBVIEW_MESSAGES_FILE), "WebviewMessage") const defined = readMessageTypeSources() const missing = memberNames.filter((name) => { return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined) }) - expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + expect( + missing, + `WebviewMessage members without definitions in message type sources: ${missing.join(", ")}`, + ).toEqual([]) }) }) @@ -117,6 +127,9 @@ describe("mapSSEEventToWebviewMessage output types", () => { const missing = typeMatches.filter((t) => !typeSet.has(t)) - expect(missing, `Types in mapSSEEventToWebviewMessage not in messages.ts: ${missing.join(", ")}`).toEqual([]) + expect( + missing, + `Types in mapSSEEventToWebviewMessage not found in message type sources: ${missing.join(", ")}`, + ).toEqual([]) }) }) From 07024a0341217ba43391b1136d64c0774b2bd2c1 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Fri, 24 Apr 2026 11:22:30 +0200 Subject: [PATCH 68/70] fix(tui): preserve SolidJS prop reactivity in Slot wrapper Replace object spread (`{...props}`) with `mergeProps` when forwarding props through the plugin Slot wrapper. Spreading props in SolidJS evaluates each prop once at mount time, freezing reactive values like `ref`, `visible`, `disabled`, and `on_submit`. This caused handlers to bind against stale closures after overlay transitions, breaking Enter submission on the session_prompt slot. Add regression tests verifying the wrapper uses `mergeProps` (not spread) and that reactive prop tracking is preserved at runtime. --- .../opencode/src/cli/cmd/tui/plugin/slots.tsx | 10 +- .../kilocode/slot-prop-reactivity.test.ts | 154 ++++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/kilocode/slot-prop-reactivity.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx index 4d18f6257a..c90756b6d6 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx @@ -1,6 +1,6 @@ import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@kilocode/plugin/tui" import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid" -import { children } from "solid-js" // kilocode_change +import { children, mergeProps } from "solid-js" // kilocode_change import { isRecord } from "@/util/record" type RuntimeSlotMap = TuiSlotMap> @@ -24,14 +24,16 @@ let view: Slot = empty // kilocode_change start - stabilize fallback children so replace-mode slots // don't recreate stateful defaults like the session prompt on prop changes. +// mergeProps (instead of spread) preserves SolidJS prop reactivity so things +// like ref, visible, disabled, on_submit keep flowing through to the slot. export const Slot = (props: TuiSlotProps) => { const value = children(() => props.children) - return view({ - ...props, + const merged = mergeProps(props, { get children() { return value() }, - } as TuiSlotProps) + }) + return view(merged as TuiSlotProps) } // kilocode_change end diff --git a/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts b/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts new file mode 100644 index 0000000000..43c26e7172 --- /dev/null +++ b/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts @@ -0,0 +1,154 @@ +/** + * Regression test for the Slot wrapper in plugin/slots.tsx. + * + * PR #9425 introduced a wrapper around the opentui Slot that memoizes the + * fallback children so stateful defaults (like the session prompt) aren't + * recreated when props change. The first implementation used an object spread + * `{...props}` to forward props, which *breaks SolidJS prop reactivity*: every + * prop except the getter-defined `children` freezes at mount time. + * + * Downstream consequence: `ref`, `visible`, `disabled`, `on_submit` stop + * updating on the session_prompt slot once the outer no longer gates + * mounting. That's what made Enter stop submitting after a blocking overlay + * closed — the prompt ref callback and submit handler were captured against + * stale closures. + * + * This test locks in two things: + * 1. A static invariant: the wrapper does NOT spread raw props (`...props`), + * which would silently reintroduce the regression on refactors. + * 2. A runtime check: forwarding props through the same pattern used in + * slots.tsx preserves reactivity for arbitrary props (not just children). + */ + +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { children, createEffect, createRoot, createSignal, mergeProps } from "solid-js" + +const SLOTS_FILE = path.resolve(import.meta.dir, "../../src/cli/cmd/tui/plugin/slots.tsx") + +describe("Slot wrapper preserves prop reactivity", () => { + test("slots.tsx does not use `{...props}` spread to forward props", () => { + // Spread on a plain object in Solid evaluates every prop once and freezes + // it. mergeProps (or a getter per prop) is required to keep reactivity. + const content = fs.readFileSync(SLOTS_FILE, "utf-8") + const wrapper = content.match(/export const Slot[\s\S]*?^}/m)?.[0] ?? "" + expect(wrapper).not.toBe("") + expect(wrapper).not.toMatch(/\.\.\.props/) + }) + + test("slots.tsx forwards props through mergeProps (or per-prop getters)", () => { + const content = fs.readFileSync(SLOTS_FILE, "utf-8") + const wrapper = content.match(/export const Slot[\s\S]*?^}/m)?.[0] ?? "" + const usesMergeProps = /mergeProps\s*\(/.test(wrapper) + expect(usesMergeProps).toBe(true) + }) + + test("mergeProps preserves reactivity of non-children props", () => { + // Simulates the exact pattern used in slots.tsx: resolve children via the + // `children()` helper and forward the rest via mergeProps. Non-children + // reactive props (like `visible`, `disabled`, `ref`) must keep tracking + // their source signals — otherwise the slot-internal consumer (opentui + // registry → plugin) sees a frozen initial value. + const [visible, setVisible] = createSignal(true) + const [disabled, setDisabled] = createSignal(false) + const refCalls: Array = [] + const refA = () => refCalls.push("a") + const refB = () => refCalls.push("b") + const [ref, setRef] = createSignal<() => void>(refA) + + const seen: Array<{ visible: boolean; disabled: boolean }> = [] + const refSeen: Array<() => void> = [] + + const dispose = createRoot((dispose) => { + // Pretend JSX: reactive props passed into the Slot wrapper. + const sourceProps = { + get visible() { + return visible() + }, + get disabled() { + return disabled() + }, + get ref() { + return ref() + }, + children: "unused", + } + + // This mirrors plugin/slots.tsx exactly. + const value = children(() => sourceProps.children) + const merged = mergeProps(sourceProps, { + get children() { + return value() + }, + }) as typeof sourceProps + + createEffect(() => { + seen.push({ visible: merged.visible, disabled: merged.disabled }) + }) + createEffect(() => { + refSeen.push(merged.ref) + }) + + return dispose + }) + + // Initial render tracked. + expect(seen).toEqual([{ visible: true, disabled: false }]) + expect(refSeen.length).toBe(1) + expect(refSeen[0]).toBe(refA) + + // Flip the source signals — the merged view must update. + setVisible(false) + expect(seen).toEqual([ + { visible: true, disabled: false }, + { visible: false, disabled: false }, + ]) + + setDisabled(true) + expect(seen[seen.length - 1]).toEqual({ visible: false, disabled: true }) + + // Ref callback must also track through the wrapper — this is what makes + // the session prompt ref={bind} actually attach/re-attach correctly. + setRef(() => refB) + expect(refSeen.length).toBe(2) + expect(refSeen[1]).toBe(refB) + + dispose() + }) + + test("plain `{...props}` spread does NOT preserve reactivity (proves the regression)", () => { + // Negative control: the exact bug we're guarding against. A spread into a + // plain object decouples the reactive source, so an effect on the copy + // only fires once. + const [visible, setVisible] = createSignal(true) + let fires = 0 + + const dispose = createRoot((dispose) => { + const sourceProps = { + get visible() { + return visible() + }, + } + + // BUG pattern — copies the value at evaluation time. + const frozen = { ...sourceProps } as { visible: boolean } + + createEffect(() => { + // Touch frozen.visible to subscribe (but it's a static property now). + void frozen.visible + fires++ + }) + + return dispose + }) + + expect(fires).toBe(1) + setVisible(false) + // A correctly reactive wrapper would have fired again; the frozen copy + // does not. Keeping this assertion documents why mergeProps is required. + expect(fires).toBe(1) + + dispose() + }) +}) From a2ae026f50622b897f12eaf420a05349e48be615 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Fri, 24 Apr 2026 11:24:02 +0200 Subject: [PATCH 69/70] test(tui): trim verbose backstory from slot reactivity test header Remove implementation history paragraph from the test file's doc comment, keeping only the concise description of what the test validates. --- .../test/kilocode/slot-prop-reactivity.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts b/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts index 43c26e7172..c2689fd06b 100644 --- a/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts +++ b/packages/opencode/test/kilocode/slot-prop-reactivity.test.ts @@ -1,18 +1,6 @@ /** * Regression test for the Slot wrapper in plugin/slots.tsx. * - * PR #9425 introduced a wrapper around the opentui Slot that memoizes the - * fallback children so stateful defaults (like the session prompt) aren't - * recreated when props change. The first implementation used an object spread - * `{...props}` to forward props, which *breaks SolidJS prop reactivity*: every - * prop except the getter-defined `children` freezes at mount time. - * - * Downstream consequence: `ref`, `visible`, `disabled`, `on_submit` stop - * updating on the session_prompt slot once the outer no longer gates - * mounting. That's what made Enter stop submitting after a blocking overlay - * closed — the prompt ref callback and submit handler were captured against - * stale closures. - * * This test locks in two things: * 1. A static invariant: the wrapper does NOT spread raw props (`...props`), * which would silently reintroduce the regression on refactors. From ad1cdb550c8f42afc59f9ae758bcfe3588f97c6c Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Fri, 24 Apr 2026 10:20:41 +0000 Subject: [PATCH 70/70] release: v7.2.22 --- .changeset/cli-sync-ready-debug-leftover.md | 5 --- .changeset/fix-long-session-loading.md | 5 --- ...preserve-input-across-blocking-overlays.md | 5 --- bun.lock | 32 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++---- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 6 ++++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 8 +++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 26 files changed, 55 insertions(+), 56 deletions(-) delete mode 100644 .changeset/cli-sync-ready-debug-leftover.md delete mode 100644 .changeset/fix-long-session-loading.md delete mode 100644 .changeset/preserve-input-across-blocking-overlays.md diff --git a/.changeset/cli-sync-ready-debug-leftover.md b/.changeset/cli-sync-ready-debug-leftover.md deleted file mode 100644 index e20d82336d..0000000000 --- a/.changeset/cli-sync-ready-debug-leftover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix a 1-2 second startup delay before home content (agents, news, tips) appears in the TUI. diff --git a/.changeset/fix-long-session-loading.md b/.changeset/fix-long-session-loading.md deleted file mode 100644 index 71ed584dd6..0000000000 --- a/.changeset/fix-long-session-loading.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix restoring and paginating very long VS Code sessions. diff --git a/.changeset/preserve-input-across-blocking-overlays.md b/.changeset/preserve-input-across-blocking-overlays.md deleted file mode 100644 index fe4a59786a..0000000000 --- a/.changeset/preserve-input-across-blocking-overlays.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Preserve typed text in the main prompt when a blocking question, suggestion, permission, or network overlay is shown and then dismissed. diff --git a/bun.lock b/bun.lock index 22b3be0bbe..15ec313443 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -88,7 +88,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -121,7 +121,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -172,7 +172,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -201,7 +201,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -237,7 +237,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.21", + "version": "7.2.22", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -250,7 +250,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -270,7 +270,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -305,7 +305,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -365,7 +365,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.21", + "version": "7.2.22", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -520,7 +520,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -545,7 +545,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "semver": "^7.6.3", }, @@ -556,7 +556,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "cross-spawn": "catalog:", }, @@ -571,7 +571,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.21", + "version": "7.2.22", "bin": { "opencode": "./bin/opencode", }, @@ -595,7 +595,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.21", + "version": "7.2.22", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -618,7 +618,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.21", + "version": "7.2.22", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 65e4b09e91..5a0c25ec04 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.21", + "version": "7.2.22", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index e31590e58f..df757adb16 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.21", + "version": "7.2.22", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index fca8dfed75..5cf83d5617 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 373d476c35..a4b0a7fcfd 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ff10d64a20..b0a17d2519 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.21" +version = "7.2.22" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.22/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.22/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.22/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.22/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 1c9da59a88..f5eb0e202a 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.21", + "version": "7.2.22", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index b88e9b8c86..6996219315 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 2050bb2137..ced6424b00 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 4d3198aa57..9638b2276f 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 1f9e278dca..f442ecf793 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 844a56ead9..de6d5ad474 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # kilo-code +## 7.2.22 + +### Patch Changes + +- [#9444](https://github.com/Kilo-Org/kilocode/pull/9444) [`35f13cb`](https://github.com/Kilo-Org/kilocode/commit/35f13cbc8678f80cf6870c233f74f47b028bf705) - Fix restoring and paginating very long VS Code sessions. + ## 7.2.21 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 76d1149366..c949dff805 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.21", + "version": "7.2.22", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index a38583587e..e4718ef909 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,13 @@ # @kilocode/cli +## 7.2.22 + +### Patch Changes + +- [#9455](https://github.com/Kilo-Org/kilocode/pull/9455) [`567ca0d`](https://github.com/Kilo-Org/kilocode/commit/567ca0d34178a6a896aa58c10cc946565c116d4e) - Fix a 1-2 second startup delay before home content (agents, news, tips) appears in the TUI. + +- [#9425](https://github.com/Kilo-Org/kilocode/pull/9425) [`6ee160f`](https://github.com/Kilo-Org/kilocode/commit/6ee160f89c10293d635990798779988d34b092b4) - Preserve typed text in the main prompt when a blocking question, suggestion, permission, or network overlay is shown and then dismissed. + ## 7.2.21 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f235dce02a..e89bb7b1ce 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.21", + "version": "7.2.22", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 530ce31acf..4c6d946611 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index d293a75474..c04255f9f6 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.21", + "version": "7.2.22", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 45b54d2fa8..5c604f2195 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 74376ccadb..3a2fcc8dd9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.21", + "version": "7.2.22", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 4d6e40e177..3219d5f29e 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.21", + "version": "7.2.22", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4ba49c8f7e..62fdd7f3e8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.21", + "version": "7.2.22", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index d874e5cb68..0900bbac88 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.21", + "version": "7.2.22", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 804acb46c6..fe4d789910 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.21", + "version": "7.2.22", "publisher": "sst-dev", "repository": { "type": "git",