From bb652c56b7d4a1ea4aac72a1cb0283bfe9242422 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Wed, 9 Sep 2026 14:39:12 +0530 Subject: [PATCH 001/353] fix(tui): keep pending permission and question asks across session switches --- .changeset/tui-permission-eviction.md | 7 + packages/tui/src/context/sync.tsx | 54 ++++- packages/tui/test/fixture/tui-sdk.ts | 1 + .../test/kilocode/sync-pending-asks.test.tsx | 212 ++++++++++++++++++ 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 .changeset/tui-permission-eviction.md create mode 100644 packages/tui/test/kilocode/sync-pending-asks.test.tsx diff --git a/.changeset/tui-permission-eviction.md b/.changeset/tui-permission-eviction.md new file mode 100644 index 00000000000..f68198ee11a --- /dev/null +++ b/.changeset/tui-permission-eviction.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +--- + +Fix pending permission and question prompts disappearing when switching sessions in the TUI. + +Viewing another session and returning used to drop the approval popup of a running subagent or session, leaving it blocked forever. Pending asks now survive session switches and are refetched from the server when a session becomes visible, so the prompt can always be answered. diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 4111cdf904c..5570f791a66 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -182,8 +182,8 @@ export const { if (processes?.length) draft.background_process[sessionID] = processes else delete draft.background_process[sessionID] delete draft.interactive_terminal[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] + // pending asks are one-shot events; an unanswered ask hangs its session forever, so + // eviction keeps them and permission.replied/question.replied remove them delete draft.suggestion[sessionID] delete draft.network[sessionID] }), @@ -192,6 +192,52 @@ export const { for (const child of children) evict(child) } + // pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session + function mergePending( + list: T[], + current: Record, + before: Set, + ): Record { + const fresh: Record = {} + for (const request of list) (fresh[request.sessionID] ??= []).push(request) + const next: Record = {} + for (const sessionID of new Set([...Object.keys(current), ...Object.keys(fresh)])) { + const merged = new Map() + for (const request of fresh[sessionID] ?? []) { + // skip entries the store already dropped (replied mid-fetch): the stale list resurrects answered asks + if (before.has(request.id) && !(current[sessionID] ?? []).some((r) => r.id === request.id)) continue + merged.set(request.id, request) + } + for (const request of current[sessionID] ?? []) { + if (merged.has(request.id)) continue + if (before.has(request.id)) continue // the server list no longer holds it + merged.set(request.id, request) + } + if (merged.size) next[sessionID] = [...merged.values()].sort((a, b) => a.id.localeCompare(b.id)) + } + return next + } + + async function syncPending() { + const workspace = project.workspace.current() + const before = { + permission: new Set(Object.values(store.permission).flatMap((list) => list.map((r) => r.id))), + question: new Set(Object.values(store.question).flatMap((list) => list.map((r) => r.id))), + } + const [permissions, questions] = await Promise.all([ + sdk.client.permission.list({ workspace }).then((x) => x.data ?? []), + sdk.client.question.list({ workspace }).then((x) => x.data ?? []), + ]) + if (permission.mode === "auto") { + for (const request of permissions) + void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) + setStore("permission", reconcile({})) + } else { + setStore("permission", reconcile(mergePending(permissions, store.permission, before.permission))) + } + setStore("question", reconcile(mergePending(questions, store.question, before.question))) + } + function strip(message: Message): Message { if (message.role !== "user" || !message.summary?.diffs) return message return { ...message, summary: { ...message.summary, diffs: [] } } as Message @@ -912,7 +958,7 @@ export const { sdk.client.indexing .status() .then((result) => setStore("indexing", reconcile(result.data ?? store.indexing))), - // kilocode_change end + syncPending().catch(() => {}), // kilocode_change - recover pending asks missed while disconnected ]).then(() => { setStore("status", "complete") }) @@ -1053,6 +1099,8 @@ export const { }), ) fullSyncedSessions.add(sessionID) + // a failed pending-ask recovery must not fail the session load; the next visit retries it + await syncPending().catch(() => {}) // kilocode_change - recover pending asks lost to eviction or a missed one-shot event })().finally(() => { syncingSessions.delete(sessionID) hydratingSessions.delete(sessionID) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 29b112433bc..c7490f10219 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -90,6 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { permission?: PermissionRequest[]; question?: QuestionRequest[] }) { + return (url: URL) => { + if (url.pathname === "/session") return json(sessions) + for (const session of sessions) { + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === `/session/${session.id}/message`) return json([]) + if (url.pathname === `/session/${session.id}/todo` || url.pathname === `/session/${session.id}/diff`) return json([]) + } + if (url.pathname === "/permission") return json(asks().permission ?? []) + if (url.pathname === "/question") return json(asks().question ?? []) + return undefined + } +} + +test("evicting a parent session keeps pending child permission and question asks", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount(serveSessions([parent, child], () => ({})), tmp.path) + + try { + emit(wrap({ id: "evt_ask", type: "permission.asked", properties: permission("per_1") })) + emit(wrap({ id: "evt_question", type: "question.asked", properties: question("que_1") })) + await wait(() => (sync.data.permission[childID] ?? []).length === 1) + await wait(() => (sync.data.question[childID] ?? []).length === 1) + + sync.session.evict(parentID) + + expect(sync.data.permission[childID]).toHaveLength(1) + expect(sync.data.question[childID]).toHaveLength(1) + + sync.session.evict(childID) + + expect(sync.data.permission[childID]).toHaveLength(1) + expect(sync.data.question[childID]).toHaveLength(1) + + emit( + wrap({ id: "evt_replied", type: "permission.replied", properties: { sessionID: childID, requestID: "per_1", reply: "once" } }), + ) + emit( + wrap({ + id: "evt_qreplied", + type: "question.replied", + properties: { sessionID: childID, requestID: "que_1", answers: [] }, + }), + ) + await wait(() => (sync.data.permission[childID] ?? []).length === 0) + await wait(() => (sync.data.question[childID] ?? []).length === 0) + } finally { + app.renderer.destroy() + } +}) + +test("session sync refetches pending permission and question asks and drops stale ones", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let pending = { permission: [permission("per_1")], question: [question("que_1")] } + const { app, sync } = await mount(serveSessions([parent, child], () => pending), tmp.path) + + try { + // Eviction no longer wipes the asks; simulate losing them anyway (e.g. the + // ask arrived while the SSE stream was briefly down) and resync. + sync.set("permission", { [childID]: [] }) + sync.set("question", { [childID]: [] }) + await sync.session.sync(childID) + + expect(sync.data.permission[childID]).toHaveLength(1) + expect(sync.data.question[childID]).toHaveLength(1) + + // A later sync with no pending asks drops the stale entries. + pending = { permission: [], question: [] } + await sync.session.sync(parentID) + + expect(sync.data.permission[childID]).toBeUndefined() + expect(sync.data.question[childID]).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + +test("an ask arriving while the pending refetch is in flight survives it", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolveSecond!: (value: PermissionRequest[]) => void + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + let seen = 0 + const { app, emit, sync } = await mount( + (url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : second.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + ) + + try { + // The store starts empty (e.g. the ask raced the SSE stream). + sync.set("permission", { [childID]: [] }) + const hydrate = sync.session.sync(childID) + await wait(() => seen === 2) + // The server list resolves with no pending asks, but the live ask event + // lands while the refetch is still settling. + resolveSecond([]) + emit(wrap({ id: "evt_ask", type: "permission.asked", properties: permission("per_1") })) + await hydrate + + expect(sync.data.permission[childID]).toHaveLength(1) + } finally { + app.renderer.destroy() + } +}) + +test("an ask answered while the pending refetch is in flight is not resurrected", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolveSecond!: (value: PermissionRequest[]) => void + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + let seen = 0 + const { app, emit, sync } = await mount( + (url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : second.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + ) + + try { + // The store holds a pending ask; the refetch starts while it is still live. + sync.set("permission", { [childID]: [permission("per_1")] }) + const hydrate = sync.session.sync(childID) + await wait(() => seen === 2) + // The ask is answered while the (stale) server list is still in flight. + emit( + wrap({ + id: "evt_replied", + type: "permission.replied", + properties: { sessionID: childID, requestID: "per_1", reply: "once" }, + }), + ) + resolveSecond([permission("per_1")]) + await hydrate + + // The stale list must not resurrect the answered ask. + expect(sync.data.permission[childID]).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) From 09f0d463e0c6c480d17371c75c1395af58cc48de Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Wed, 9 Sep 2026 16:56:39 +0530 Subject: [PATCH 002/353] fix(tui): fail closed on permission list errors so live asks are kept --- packages/tui/src/context/sync.tsx | 6 ++-- .../test/kilocode/sync-pending-asks.test.tsx | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 2f144c55674..a5016ec31d4 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -220,8 +220,10 @@ export const { question: new Set(Object.values(store.question).flatMap((list) => list.map((r) => r.id))), } const [permissions, questions] = await Promise.all([ - sdk.client.permission.list({ workspace }).then((x) => x.data ?? []), - sdk.client.question.list({ workspace }).then((x) => x.data ?? []), + // throwOnError so a failed list fetch rejects into the caller's catch instead of + // merging an empty list, which would drop live asks and re-hang the session + sdk.client.permission.list({ workspace }, { throwOnError: true }).then((x) => x.data ?? []), + sdk.client.question.list({ workspace }, { throwOnError: true }).then((x) => x.data ?? []), ]) if (permission.mode === "auto") { for (const request of permissions) diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 4c473f95ba6..735a9d382b3 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -210,3 +210,33 @@ test("an ask answered while the pending refetch is in flight is not resurrected" app.renderer.destroy() } }) + +test("a failed pending list fetch keeps existing asks", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let seen = 0 + const { app, sync } = await mount( + (url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync gets a 500 + return seen === 1 ? json([]) : json({ message: "boom" }, { status: 500 }) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + ) + + try { + // A live ask is in the store; the refetch then fails. + sync.set("permission", { [childID]: [permission("per_1")] }) + const hydrate = sync.session.sync(childID) + await wait(() => seen === 2) + await hydrate + + // The failed fetch must not merge an empty list over the live ask. + expect(sync.data.permission[childID]).toHaveLength(1) + } finally { + app.renderer.destroy() + } +}) From 1ed91edf798a7626b9a4eaf4c42ed13d05ce028e Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sat, 12 Sep 2026 02:33:01 +0100 Subject: [PATCH 003/353] fix(cli): avoid false frontmatter errors when saving command files Write/edit tools call config validation through Effect.promise, which drops instance context after the first await. Capture the project context first so command markdown still validates. --- .changeset/command-frontmatter-instance.md | 5 ++ .../src/kilocode/config-validation.ts | 13 ++--- .../test/kilocode/config-validation.test.ts | 53 ++++++++++++++++++- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 .changeset/command-frontmatter-instance.md diff --git a/.changeset/command-frontmatter-instance.md b/.changeset/command-frontmatter-instance.md new file mode 100644 index 00000000000..ea437e56b9d --- /dev/null +++ b/.changeset/command-frontmatter-instance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop reporting a false frontmatter parse error when saving command markdown files diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts index 91144ec726f..a8771e6bce4 100644 --- a/packages/opencode/src/kilocode/config-validation.ts +++ b/packages/opencode/src/kilocode/config-validation.ts @@ -9,7 +9,7 @@ import { Config } from "@/config/config" import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent" import { ConfigCommandV1 } from "@opencode-ai/core/v1/config/command" import { ConfigErrorV1, FrontmatterError } from "@opencode-ai/core/v1/config/error" -import { Instance } from "@/kilocode/instance" +import { Instance, capture } from "@/kilocode/instance" import { Filesystem } from "@/util/filesystem" export namespace ConfigValidation { @@ -64,7 +64,7 @@ export namespace ConfigValidation { return `\n\n\nConfig file validated successfully.\n` } - async function markdown(filepath: string): Promise { + async function markdown(filepath: string, ctx = capture()) { const dir = path.basename(path.dirname(filepath)) // Determine schema from parent directory @@ -74,11 +74,11 @@ export namespace ConfigValidation { let md: Awaited> try { const trusted = path.isAbsolute(filepath) && ConfigProtection.isAbsolute(filepath) - const ctx = Instance.current - const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree + const root = ctx == null ? undefined : ctx.worktree === "/" ? ctx.directory : ctx.worktree + if (!trusted && root == null) return "" md = await ConfigMarkdown.parse(filepath, { trusted, - fileScope: trusted ? undefined : { root, source: filepath }, + fileScope: trusted || root == null ? undefined : { root, source: filepath }, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { @@ -154,6 +154,7 @@ export namespace ConfigValidation { */ export async function check(filepath: string): Promise { if (!isConfig(filepath)) return "" + const ctx = capture() const ext = path.extname(filepath).toLowerCase() @@ -166,7 +167,7 @@ export namespace ConfigValidation { } const prefix = await existing() - const validation = JSONC_EXT.has(ext) ? await jsonc(filepath) : ext === ".md" ? await markdown(filepath) : "" + const validation = JSONC_EXT.has(ext) ? await jsonc(filepath) : ext === ".md" ? await markdown(filepath, ctx) : "" if (!validation) return "" diff --git a/packages/opencode/test/kilocode/config-validation.test.ts b/packages/opencode/test/kilocode/config-validation.test.ts index d1c8bb76139..98b7089e77d 100644 --- a/packages/opencode/test/kilocode/config-validation.test.ts +++ b/packages/opencode/test/kilocode/config-validation.test.ts @@ -1,12 +1,15 @@ // kilocode_change - new file import { afterEach, describe, expect, test } from "bun:test" import path from "path" +import { Effect, Layer } from "effect" import { ConfigValidation } from "../../src/kilocode/config-validation" -import { provideTestInstance } from "../fixture/fixture" import { Config } from "../../src/config/config" import { AppRuntime } from "../../src/effect/app-runtime" import { Filesystem } from "../../src/util/filesystem" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTestInstance, TestInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) afterEach(async () => { await disposeAllInstances() @@ -184,4 +187,50 @@ Broken agent`, expect(result).toContain("broken.md") expect(result).toContain("Post-edit validation") }) + + // write/edit/apply_patch call check() via Effect.promise, which drops the + // Effect fiber after the first await inside check. Reproduce that path. + async function probe(dir: string, name: string, body: string) { + const filepath = path.join(dir, ".kilo", "command", name) + await Filesystem.write(filepath, body) + return filepath + } + + it.instance( + "accepts a command file with no frontmatter via Effect.promise", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = yield* Effect.promise(() => probe(test.directory, "probe.md", "just a body, no frontmatter\n")) + const result = yield* Effect.promise(() => check(filepath)) + expect(result).not.toContain("Failed to parse frontmatter") + expect(result).not.toContain("No context found for instance") + expect(result).toContain("validated successfully") + }), + { git: true }, + ) + + it.instance( + "accepts a command file with valid frontmatter via Effect.promise", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = yield* Effect.promise(() => + probe( + test.directory, + "probe.md", + `--- +description: probe +--- +probe +`, + ), + ) + const result = yield* Effect.promise(() => check(filepath)) + expect(result).not.toContain("Failed to parse frontmatter") + expect(result).not.toContain("No context found for instance") + expect(result).toContain("validated successfully") + }), + { git: true }, + ) }) From 7e48df3c9a9870399fc8c1db8793db77fc85c9af Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Mon, 21 Sep 2026 22:10:34 +0530 Subject: [PATCH 004/353] fix(tui): treat answered asks as terminal so stale lists cannot resurrect them --- packages/tui/src/context/sync.tsx | 22 +++++++-- .../test/kilocode/sync-pending-asks.test.tsx | 48 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index a5016ec31d4..62ad04f364d 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -192,6 +192,7 @@ export const { list: T[], current: Record, before: Set, + settled: Set, ): Record { const fresh: Record = {} for (const request of list) (fresh[request.sessionID] ??= []).push(request) @@ -199,6 +200,7 @@ export const { for (const sessionID of new Set([...Object.keys(current), ...Object.keys(fresh)])) { const merged = new Map() for (const request of fresh[sessionID] ?? []) { + if (settled.has(request.id)) continue // kilocode_change - answered while the list was in flight // skip entries the store already dropped (replied mid-fetch): the stale list resurrects answered asks if (before.has(request.id) && !(current[sessionID] ?? []).some((r) => r.id === request.id)) continue merged.set(request.id, request) @@ -226,13 +228,18 @@ export const { sdk.client.question.list({ workspace }, { throwOnError: true }).then((x) => x.data ?? []), ]) if (permission.mode === "auto") { - for (const request of permissions) + for (const request of permissions) { + terminal.add(request.id) // kilocode_change - the auto-reply settles this ask void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) + } setStore("permission", reconcile({})) } else { - setStore("permission", reconcile(mergePending(permissions, store.permission, before.permission))) + setStore( + "permission", + reconcile(mergePending(permissions, store.permission, before.permission, terminal)), + ) } - setStore("question", reconcile(mergePending(questions, store.question, before.question))) + setStore("question", reconcile(mergePending(questions, store.question, before.question, terminal))) } function strip(message: Message): Message { @@ -243,6 +250,8 @@ export const { const fullSyncedSessions = new Set() const deleted = new Set() // kilocode_change + // kilocode_change - request IDs already replied/rejected; a stale pending list must not resurrect them + const terminal = new Set() // kilocode_change let syncedWorkspace = project.workspace.current() // kilocode_change let vcsVersion = 0 // kilocode_change const syncingSessions = new Map>() @@ -275,11 +284,14 @@ export const { case "server.instance.disposed": // kilocode_change start deleted.clear() + terminal.clear() setStore("background_process", {}) // kilocode_change end void bootstrap() break case "permission.replied": { + terminal.add(event.properties.requestID) // kilocode_change - a replied ask is terminal: a stale list must not resurrect it + if (terminal.size > 512) terminal.delete(terminal.values().next().value) // kilocode_change const requests = store.permission[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -296,6 +308,7 @@ export const { case "permission.asked": { const request = event.properties + if (terminal.has(request.id)) break // kilocode_change - already answered, ignore straggler events if (permission.mode === "auto") { void sdk.client.permission.reply({ requestID: request.id, @@ -327,6 +340,8 @@ export const { case "question.replied": case "question.rejected": { + terminal.add(event.properties.requestID) // kilocode_change - a settled question is terminal: a stale list must not resurrect it + if (terminal.size > 512) terminal.delete(terminal.values().next().value) // kilocode_change const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -343,6 +358,7 @@ export const { case "question.asked": { const request = event.properties + if (terminal.has(request.id)) break // kilocode_change - already answered, ignore straggler events const requests = store.question[request.sessionID] if (!requests) { setStore("question", request.sessionID, [request]) diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 735a9d382b3..704331ec457 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -240,3 +240,51 @@ test("a failed pending list fetch keeps existing asks", async () => { app.renderer.destroy() } }) + +test("an ask created and answered during the refetch is not resurrected", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolveSecond!: (value: PermissionRequest[]) => void + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + let seen = 0 + const { app, emit, sync } = await mount( + (url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : second.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + ) + + try { + // The store is empty when the refetch snapshots, so the new ask's ID is + // absent from the pre-fetch list. + const hydrate = sync.session.sync(childID) + await wait(() => seen === 2) + // The ask is created and answered while the stale list (which still + // contains it) is in flight. Wait for each event to land in the store so + // the merge below runs after the reply was processed. + emit(wrap({ id: "evt_ask", type: "permission.asked", properties: permission("per_1") })) + await wait(() => (sync.data.permission[childID] ?? []).length === 1) + emit( + wrap({ + id: "evt_replied", + type: "permission.replied", + properties: { sessionID: childID, requestID: "per_1", reply: "once" }, + }), + ) + await wait(() => (sync.data.permission[childID] ?? []).length === 0) + resolveSecond([permission("per_1")]) + await hydrate + + // The stale list must not resurrect the answered ask. + expect(sync.data.permission[childID]).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) From 033c72a1692a41bf66ae18b6e9bc5531709106bf Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Mon, 21 Sep 2026 22:47:31 +0530 Subject: [PATCH 005/353] fix(tui): handle possibly-undefined oldest terminal id in set cap --- packages/tui/src/context/sync.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 62ad04f364d..faaddc4cca9 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -291,7 +291,10 @@ export const { break case "permission.replied": { terminal.add(event.properties.requestID) // kilocode_change - a replied ask is terminal: a stale list must not resurrect it - if (terminal.size > 512) terminal.delete(terminal.values().next().value) // kilocode_change + if (terminal.size > 512) { + const oldest = terminal.values().next().value // kilocode_change + if (oldest != null) terminal.delete(oldest) // kilocode_change + } const requests = store.permission[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -341,7 +344,10 @@ export const { case "question.replied": case "question.rejected": { terminal.add(event.properties.requestID) // kilocode_change - a settled question is terminal: a stale list must not resurrect it - if (terminal.size > 512) terminal.delete(terminal.values().next().value) // kilocode_change + if (terminal.size > 512) { + const oldest = terminal.values().next().value // kilocode_change + if (oldest != null) terminal.delete(oldest) // kilocode_change + } const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) From a320877b594a2a1b62ce9e98b55797dbd7d9e56f Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Tue, 22 Sep 2026 02:47:58 +0530 Subject: [PATCH 006/353] fix(tui): keep protected permission asks visible in auto mode --- packages/tui/src/context/sync.tsx | 33 +++++++-- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 8 +- .../test/kilocode/sync-pending-asks.test.tsx | 74 +++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index faaddc4cca9..00f37c13e2d 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -187,8 +187,13 @@ export const { for (const child of children) evict(child) } - // pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session - function mergePending( +// pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session +// kilocode_change start - skill shell batches and sandbox escalations need an interactive human decision: +// the server refuses machine replies for them, mirroring temporaryPermission in cli/cmd/run/permission.shared +const temporaryPermission = (request: PermissionRequest) => + request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true +// kilocode_change end +function mergePending( list: T[], current: Record, before: Set, @@ -229,10 +234,25 @@ export const { ]) if (permission.mode === "auto") { for (const request of permissions) { - terminal.add(request.id) // kilocode_change - the auto-reply settles this ask + // kilocode_change start - skill shell batches and sandbox escalations cannot be settled by a + // machine reply: the server refuses non-interactive approvals, so they stay pending for a + // human decision and must remain visible instead of being cleared + if (temporaryPermission(request)) continue + // kilocode_change end + terminal.add(request.id) void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) } - setStore("permission", reconcile({})) + // kilocode_change start - keep protected asks visible; clear only what was settled + const kept = new Map() + for (const request of [...Object.values(store.permission).flat(), ...permissions]) { + if (!temporaryPermission(request)) continue + kept.set(request.id, request) + } + const next: Record = {} + for (const request of kept.values()) (next[request.sessionID] ??= []).push(request) + for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id)) + setStore("permission", reconcile(next)) + // kilocode_change end } else { setStore( "permission", @@ -312,7 +332,9 @@ export const { case "permission.asked": { const request = event.properties if (terminal.has(request.id)) break // kilocode_change - already answered, ignore straggler events - if (permission.mode === "auto") { + // kilocode_change start - the server refuses non-interactive approvals for skill shell + // batches and sandbox escalations, so auto mode cannot settle them: store for a human decision + if (permission.mode === "auto" && !temporaryPermission(request)) { void sdk.client.permission.reply({ requestID: request.id, reply: "once", @@ -321,6 +343,7 @@ export const { }) break } + // kilocode_change end const requests = store.permission[request.sessionID] if (!requests) { setStore("permission", request.sessionID, [request]) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index 3c4bac9df9d..1fa991421f6 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { onMount } from "solid-js" -import { ArgsProvider } from "../../../../src/context/args" +import { ArgsProvider, type Args } from "../../../../src/context/args" // kilocode_change - plumb CLI args for mode-specific sync tests import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" @@ -23,7 +23,7 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } -export async function mount(override?: FetchHandler, state?: string) { +export async function mount(override?: FetchHandler, state?: string, args?: Args) { // kilocode_change - optional TUI args for tests const calls = createFetch(override) const events = createEventSource() let sync!: ReturnType @@ -47,7 +47,9 @@ export async function mount(override?: FetchHandler, state?: string) { const app = await testRender(() => ( - + {/* kilocode_change start - optional TUI args for tests */} + + {/* kilocode_change end */} {/* kilocode_change start */} diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 704331ec457..ef91e2ef571 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -40,6 +40,17 @@ function permission(id: string, sessionID = childID): PermissionRequest { } } +function protectedPermission(id: string, sessionID = childID): PermissionRequest { + return { + id, + sessionID, + permission: "bash", + patterns: ["git push *"], + metadata: { skillShell: true, skill: "deploy", commands: ["git push origin main"] }, + always: [], + } +} + function question(id: string, sessionID = childID): QuestionRequest { return { id, @@ -288,3 +299,66 @@ test("an ask created and answered during the refetch is not resurrected", async app.renderer.destroy() } }) + +test("auto mode settles recovered asks but keeps protected ones visible", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const replies: string[] = [] + const { app, sync } = await mount( + (url) => { + const match = url.pathname.match(/^\/permission\/([^/]+)\/reply$/) + if (match) { + replies.push(match.at(1) ?? "") + return json(true) + } + if (url.pathname === "/permission") + return json([permission("per_normal"), protectedPermission("per_protected")]) + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + { auto: true }, + ) + + try { + await wait(() => replies.includes("per_normal")) + + // The normal ask was settled: replied once and cleared from the store. + expect(sync.data.permission[childID]?.some((item) => item.id === "per_normal")).toBe(false) + + // The protected ask (skill shell batch) cannot be settled by a machine reply: + // the server refuses non-interactive approvals, so it must stay visible. + expect(sync.data.permission[childID]?.some((item) => item.id === "per_protected")).toBe(true) + expect(replies).not.toContain("per_protected") + } finally { + app.renderer.destroy() + } +}) + +test("auto mode stores a protected ask delivered by SSE instead of replying", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const replies: string[] = [] + const { app, emit, sync } = await mount( + (url) => { + const match = url.pathname.match(/^\/permission\/([^/]+)\/reply$/) + if (match) { + replies.push(match.at(1) ?? "") + return json(true) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + { auto: true }, + ) + + try { + emit(wrap({ id: "evt_ask", type: "permission.asked", properties: protectedPermission("per_protected") })) + await wait(() => (sync.data.permission[childID] ?? []).length === 1) + + // The server refuses machine replies for skill shell batches, so auto mode + // must not attempt one; the ask stays pending for a human decision. + expect(replies).toEqual([]) + } finally { + app.renderer.destroy() + } +}) From 3552cf09e5be40c8cf198115b46a97a6efcd96a3 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Tue, 22 Sep 2026 03:56:35 +0530 Subject: [PATCH 007/353] fix(tui): prevent protected permission ask resurrection in auto mode --- packages/tui/src/context/sync.tsx | 1 + .../test/kilocode/sync-pending-asks.test.tsx | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 00f37c13e2d..987f0e6e133 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -245,6 +245,7 @@ function mergePending( // kilocode_change start - keep protected asks visible; clear only what was settled const kept = new Map() for (const request of [...Object.values(store.permission).flat(), ...permissions]) { + if (terminal.has(request.id)) continue if (!temporaryPermission(request)) continue kept.set(request.id, request) } diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index ef91e2ef571..0d4fd9ed872 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -362,3 +362,51 @@ test("auto mode stores a protected ask delivered by SSE instead of replying", as app.renderer.destroy() } }) + +test("auto mode does not resurrect a protected ask answered during refetch", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolveSecond!: (value: PermissionRequest[]) => void + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + let seen = 0 + const replies: string[] = [] + const { app, emit, sync } = await mount( + (url) => { + const match = url.pathname.match(/^\/permission\/([^/]+)\/reply$/) + if (match) { + replies.push(match.at(1) ?? "") + return json(true) + } + if (url.pathname === "/permission") { + seen += 1 + return seen === 1 ? json([]) : second.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + { auto: true }, + ) + + try { + sync.set("permission", { [childID]: [protectedPermission("per_protected")] }) + const hydrate = sync.session.sync(childID) + await wait(() => seen === 2) + emit( + wrap({ + id: "evt_replied", + type: "permission.replied", + properties: { sessionID: childID, requestID: "per_protected", reply: "once" }, + }), + ) + await wait(() => (sync.data.permission[childID] ?? []).length === 0) + resolveSecond([protectedPermission("per_protected")]) + await hydrate + + expect(sync.data.permission[childID]).toBeUndefined() + expect(replies).not.toContain("per_protected") + } finally { + app.renderer.destroy() + } +}) From c248f0154ca38d6c75c57438b4c34af9a5e5c6be Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Wed, 23 Sep 2026 04:16:13 +0530 Subject: [PATCH 008/353] fix(tui): harden pending-ask recovery from self-review findings --- .changeset/tui-permission-eviction.md | 2 + packages/tui/src/context/sync.tsx | 44 ++++++++++++------- .../test/kilocode/sync-pending-asks.test.tsx | 36 +++++---------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/.changeset/tui-permission-eviction.md b/.changeset/tui-permission-eviction.md index f68198ee11a..c121b818052 100644 --- a/.changeset/tui-permission-eviction.md +++ b/.changeset/tui-permission-eviction.md @@ -5,3 +5,5 @@ Fix pending permission and question prompts disappearing when switching sessions in the TUI. Viewing another session and returning used to drop the approval popup of a running subagent or session, leaving it blocked forever. Pending asks now survive session switches and are refetched from the server when a session becomes visible, so the prompt can always be answered. + +Auto mode now keeps protected prompts (skill shell batches, sandbox escalations) visible for a human decision, and treats answered prompts as settled so stale server lists cannot bring them back. diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 9a2f2a4c3a7..b70c0c118fc 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -187,13 +187,13 @@ export const { for (const child of children) evict(child) } -// pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session -// kilocode_change start - skill shell batches and sandbox escalations need an interactive human decision: -// the server refuses machine replies for them, mirroring temporaryPermission in cli/cmd/run/permission.shared -const temporaryPermission = (request: PermissionRequest) => - request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true -// kilocode_change end -function mergePending( + // pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session + // kilocode_change start - skill shell batches and sandbox escalations need an interactive human decision: + // the server refuses machine replies for them, mirroring temporaryPermission in cli/cmd/run/permission.shared + const temporaryPermission = (request: PermissionRequest) => + request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true + // kilocode_change end + function mergePending( list: T[], current: Record, before: Set, @@ -220,7 +220,16 @@ function mergePending( return next } + let task: Promise | undefined // dedupe overlapping recoveries (bootstrap + session sync) async function syncPending() { + if (task) return task // an in-flight recovery serves both callers + task = recover().finally(() => { + task = undefined + }) + return task + } + + async function recover() { const workspace = project.workspace.current() const before = { permission: new Set(Object.values(store.permission).flatMap((list) => list.map((r) => r.id))), @@ -239,6 +248,7 @@ function mergePending( // human decision and must remain visible instead of being cleared if (temporaryPermission(request)) continue // kilocode_change end + if (terminal.has(request.id)) continue // kilocode_change - already answered, ignore straggler events terminal.add(request.id) void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) } @@ -273,6 +283,14 @@ function mergePending( const deleted = new Set() // kilocode_change // kilocode_change - request IDs already replied/rejected; a stale pending list must not resurrect them const terminal = new Set() // kilocode_change + // replied/rejected asks are terminal: a stale pending list must not resurrect them; cap the set so it cannot grow unbounded + const terminalCap = 512 + function markTerminal(id: string) { + terminal.add(id) + if (terminal.size <= terminalCap) return + const oldest = terminal.values().next().value + if (oldest != null) terminal.delete(oldest) + } let syncedWorkspace = project.workspace.current() // kilocode_change let vcsVersion = 0 // kilocode_change const syncingSessions = new Map>() @@ -311,11 +329,7 @@ function mergePending( void bootstrap() break case "permission.replied": { - terminal.add(event.properties.requestID) // kilocode_change - a replied ask is terminal: a stale list must not resurrect it - if (terminal.size > 512) { - const oldest = terminal.values().next().value // kilocode_change - if (oldest != null) terminal.delete(oldest) // kilocode_change - } + markTerminal(event.properties.requestID) // kilocode_change - a replied ask is terminal: a stale list must not resurrect it const requests = store.permission[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -367,11 +381,7 @@ function mergePending( case "question.replied": case "question.rejected": { - terminal.add(event.properties.requestID) // kilocode_change - a settled question is terminal: a stale list must not resurrect it - if (terminal.size > 512) { - const oldest = terminal.values().next().value // kilocode_change - if (oldest != null) terminal.delete(oldest) // kilocode_change - } + markTerminal(event.properties.requestID) // kilocode_change - a settled question is terminal: a stale list must not resurrect it const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 0d4fd9ed872..9f00b1df4c2 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -145,17 +145,14 @@ test("session sync refetches pending permission and question asks and drops stal test("an ask arriving while the pending refetch is in flight survives it", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") - let resolveSecond!: (value: PermissionRequest[]) => void - const second = new Promise((resolve) => { - resolveSecond = resolve - }) + const held = Promise.withResolvers() let seen = 0 const { app, emit, sync } = await mount( (url) => { if (url.pathname === "/permission") { seen += 1 // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : second.then((data) => json(data)) + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) } return serveSessions([parent, child], () => ({}))(url) }, @@ -169,7 +166,7 @@ test("an ask arriving while the pending refetch is in flight survives it", async await wait(() => seen === 2) // The server list resolves with no pending asks, but the live ask event // lands while the refetch is still settling. - resolveSecond([]) + held.resolve([]) emit(wrap({ id: "evt_ask", type: "permission.asked", properties: permission("per_1") })) await hydrate @@ -182,17 +179,14 @@ test("an ask arriving while the pending refetch is in flight survives it", async test("an ask answered while the pending refetch is in flight is not resurrected", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") - let resolveSecond!: (value: PermissionRequest[]) => void - const second = new Promise((resolve) => { - resolveSecond = resolve - }) + const held = Promise.withResolvers() let seen = 0 const { app, emit, sync } = await mount( (url) => { if (url.pathname === "/permission") { seen += 1 // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : second.then((data) => json(data)) + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) } return serveSessions([parent, child], () => ({}))(url) }, @@ -212,7 +206,7 @@ test("an ask answered while the pending refetch is in flight is not resurrected" properties: { sessionID: childID, requestID: "per_1", reply: "once" }, }), ) - resolveSecond([permission("per_1")]) + held.resolve([permission("per_1")]) await hydrate // The stale list must not resurrect the answered ask. @@ -255,17 +249,14 @@ test("a failed pending list fetch keeps existing asks", async () => { test("an ask created and answered during the refetch is not resurrected", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") - let resolveSecond!: (value: PermissionRequest[]) => void - const second = new Promise((resolve) => { - resolveSecond = resolve - }) + const held = Promise.withResolvers() let seen = 0 const { app, emit, sync } = await mount( (url) => { if (url.pathname === "/permission") { seen += 1 // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : second.then((data) => json(data)) + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) } return serveSessions([parent, child], () => ({}))(url) }, @@ -290,7 +281,7 @@ test("an ask created and answered during the refetch is not resurrected", async }), ) await wait(() => (sync.data.permission[childID] ?? []).length === 0) - resolveSecond([permission("per_1")]) + held.resolve([permission("per_1")]) await hydrate // The stale list must not resurrect the answered ask. @@ -366,10 +357,7 @@ test("auto mode stores a protected ask delivered by SSE instead of replying", as test("auto mode does not resurrect a protected ask answered during refetch", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") - let resolveSecond!: (value: PermissionRequest[]) => void - const second = new Promise((resolve) => { - resolveSecond = resolve - }) + const held = Promise.withResolvers() let seen = 0 const replies: string[] = [] const { app, emit, sync } = await mount( @@ -381,7 +369,7 @@ test("auto mode does not resurrect a protected ask answered during refetch", asy } if (url.pathname === "/permission") { seen += 1 - return seen === 1 ? json([]) : second.then((data) => json(data)) + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) } return serveSessions([parent, child], () => ({}))(url) }, @@ -401,7 +389,7 @@ test("auto mode does not resurrect a protected ask answered during refetch", asy }), ) await wait(() => (sync.data.permission[childID] ?? []).length === 0) - resolveSecond([protectedPermission("per_protected")]) + held.resolve([protectedPermission("per_protected")]) await hydrate expect(sync.data.permission[childID]).toBeUndefined() From 88936116027bb45e2302466b07d1b1fc4495e2b0 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Wed, 23 Sep 2026 04:46:47 +0530 Subject: [PATCH 009/353] fix(tui): retry failed auto-mode replies instead of marking them settled --- packages/tui/src/context/sync.tsx | 1 - .../test/kilocode/sync-pending-asks.test.tsx | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index b70c0c118fc..d0746abc6bd 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -249,7 +249,6 @@ export const { if (temporaryPermission(request)) continue // kilocode_change end if (terminal.has(request.id)) continue // kilocode_change - already answered, ignore straggler events - terminal.add(request.id) void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) } // kilocode_change start - keep protected asks visible; clear only what was settled diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 9f00b1df4c2..92f03d538ca 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -398,3 +398,36 @@ test("auto mode does not resurrect a protected ask answered during refetch", asy app.renderer.destroy() } }) + +test("auto mode retries a failed reply on the next recovery", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const replies: string[] = [] + const { app, sync } = await mount( + (url) => { + const match = url.pathname.match(/^\/permission\/([^/]+)\/reply$/) + if (match) { + replies.push(match.at(1) ?? "") + return replies.length === 1 ? json({ message: "boom" }, { status: 500 }) : json(true) + } + if (url.pathname === "/permission") return json([permission("per_1")]) + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + { auto: true }, + ) + + try { + // bootstrap's recovery fires the first reply, which fails. + await wait(() => replies.length === 1) + + // The ask is still pending server-side, so the next recovery must retry + // the reply instead of treating the failed one as settled. + await sync.session.sync(childID) + await wait(() => replies.length === 2) + + expect(replies).toEqual(["per_1", "per_1"]) + } finally { + app.renderer.destroy() + } +}) From 46428e7183cf9eabd0d1d8b2dc0430d5d8550bb5 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Wed, 23 Sep 2026 16:35:34 -0400 Subject: [PATCH 010/353] fix(vscode): start the backend in Windows drive-root workspaces --- .changeset/fix-vscode-drive-root-startup.md | 5 +++ .../services/cli-backend/server-manager.ts | 4 +-- .../src/services/cli-backend/server-utils.ts | 6 ++++ .../kilo-vscode/tests/unit/server-cwd.test.ts | 35 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-vscode-drive-root-startup.md create mode 100644 packages/kilo-vscode/tests/unit/server-cwd.test.ts diff --git a/.changeset/fix-vscode-drive-root-startup.md b/.changeset/fix-vscode-drive-root-startup.md new file mode 100644 index 00000000000..49ebb86f722 --- /dev/null +++ b/.changeset/fix-vscode-drive-root-startup.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix backend startup when a Windows drive root is opened as a workspace. diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 03803d8114c..8735d69a387 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -6,7 +6,7 @@ import * as path from "path" import * as vscode from "vscode" import { resolveLocalBwrapEnv, resolveTreeSitterEnv } from "./cli-resources" import { t } from "./i18n" -import { scanServerPort } from "./server-utils" +import { ensureServerCwd, scanServerPort } from "./server-utils" export interface ServerInstance { port: number @@ -108,7 +108,7 @@ export class ServerManager { // or "$HOME" in empty VS Code windows. const folders = vscode.workspace.workspaceFolders const spawnCwd = resolveServerCwd(folders, this.context.globalStorageUri.fsPath) - fs.mkdirSync(spawnCwd, { recursive: true }) + ensureServerCwd(spawnCwd) const indexingEnv = resolveIndexingEnv(folders) const localCli = this.context.extensionMode === vscode.ExtensionMode.Development || diff --git a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts index bedad7d1db8..38405589184 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts @@ -1,3 +1,9 @@ +import * as fs from "fs" + +export function ensureServerCwd(dir: string): void { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) +} + /** * Parse the port number from CLI server startup output. * Matches lines like: "kilo server listening on http://127.0.0.1:12345" diff --git a/packages/kilo-vscode/tests/unit/server-cwd.test.ts b/packages/kilo-vscode/tests/unit/server-cwd.test.ts new file mode 100644 index 00000000000..d0e9bd254b7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-cwd.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { ensureServerCwd } from "../../src/services/cli-backend/server-utils" + +describe("ensureServerCwd", () => { + it("accepts an existing filesystem root", () => { + const root = path.parse(process.cwd()).root + expect(() => ensureServerCwd(root)).not.toThrow() + }) + + it("creates a missing storage directory and its parents", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + try { + const dir = path.join(root, "storage", "kilo-code") + ensureServerCwd(dir) + expect(fs.statSync(dir).isDirectory()).toBe(true) + expect(() => ensureServerCwd(dir)).not.toThrow() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("propagates failures to create a missing directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + try { + const file = path.join(root, "file") + fs.writeFileSync(file, "") + expect(() => ensureServerCwd(path.join(file, "storage"))).toThrow() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) From 2f2d4640876b900579aa7741230b557ab0c1bc87 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Wed, 23 Sep 2026 16:58:45 -0400 Subject: [PATCH 011/353] test(vscode): verify existing directory guard across platforms --- packages/kilo-vscode/tests/unit/server-cwd.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/server-cwd.test.ts b/packages/kilo-vscode/tests/unit/server-cwd.test.ts index d0e9bd254b7..cec18cf3850 100644 --- a/packages/kilo-vscode/tests/unit/server-cwd.test.ts +++ b/packages/kilo-vscode/tests/unit/server-cwd.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, spyOn } from "bun:test" import * as fs from "fs" import * as os from "os" import * as path from "path" @@ -10,6 +10,18 @@ describe("ensureServerCwd", () => { expect(() => ensureServerCwd(root)).not.toThrow() }) + it("does not recreate an existing directory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + const mkdir = spyOn(fs, "mkdirSync") + try { + ensureServerCwd(dir) + expect(mkdir).not.toHaveBeenCalled() + } finally { + mkdir.mockRestore() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + it("creates a missing storage directory and its parents", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) try { From adf502643b195d22fe0275ddc1833919f44b4a26 Mon Sep 17 00:00:00 2001 From: kimnamu Date: Thu, 24 Sep 2026 12:07:57 +0900 Subject: [PATCH 012/353] fix(cli): send tool-result images as user content for Bedrock GPT-6 models Bedrock Converse rejects an image inside toolResult for GPT-6 and GPT-5.6 ("This model doesn't support the image field for user messages"), while the same image placed in the user turn next to the tool result works. Keep tool-result images in place only for Claude, Nova and Llama 4 and use the existing hoist for every other Bedrock model, as upstream did in anomalyco/opencode#50272. Fixes #14493 Co-Authored-By: Claude --- .changeset/bedrock-tool-result-images.md | 5 + packages/opencode/src/session/message-v2.ts | 8 +- .../bedrock-tool-result-images.test.ts | 121 ++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 .changeset/bedrock-tool-result-images.md create mode 100644 packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts diff --git a/.changeset/bedrock-tool-result-images.md b/.changeset/bedrock-tool-result-images.md new file mode 100644 index 00000000000..766cc3e0d6a --- /dev/null +++ b/.changeset/bedrock-tool-result-images.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Amazon Bedrock GPT-6 and GPT-5.6 sessions working after a tool returns an image. diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2fe5f0f8ba1..b29fc163a06 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -273,7 +273,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true if (model.api.npm === "@ai-sdk/amazon-bedrock/mantle") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") + // kilocode_change start - port anomalyco/opencode#50272: Bedrock Converse accepts tool-result images only for Claude, Nova and Llama 4 + if (model.api.npm === "@ai-sdk/amazon-bedrock") { + if (!attachment.mime.startsWith("image/")) return false + const id = model.api.id.toLowerCase() + return id.includes("anthropic.") || id.includes("nova") || id.includes("llama4") || id.includes("llama-4") + } + // kilocode_change end if (model.api.npm === "@ai-sdk/xai") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { diff --git a/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts b/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts new file mode 100644 index 00000000000..fb3599418eb --- /dev/null +++ b/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import type { Provider } from "../../src/provider/provider" + +const sessionID = SessionID.make("ses_bedrock_tool_image") +const providerID = ProviderV2.ID.make("amazon-bedrock") +const png = "iVBORw0KGgo=" + +const bedrock = (id: string) => + ({ + id: ModelV2.ID.make(id), + providerID, + api: { id, url: "", npm: "@ai-sdk/amazon-bedrock" }, + }) as Provider.Model + +const base = (messageID: string, id: string) => ({ + id: PartID.make(id), + sessionID, + messageID: MessageID.make(messageID), +}) + +// The read tool returned a PNG; the next request replays that tool result. +const history = (model: Provider.Model): SessionV1.WithParts[] => { + const userID = MessageID.make("msg_user") + const assistantID = MessageID.make("msg_assistant") + return [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 0 }, + agent: "user", + model: { providerID, modelID: model.id }, + tools: {}, + mode: "", + } as SessionV1.User, + parts: [{ ...base(userID, "prt_user"), type: "text", text: "describe red.png" }], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + parentID: userID, + time: { created: 0 }, + modelID: model.id, + providerID, + mode: "", + agent: "code", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as SessionV1.Assistant, + parts: [ + { + ...base(assistantID, "prt_read"), + type: "tool", + tool: "read", + callID: "call_read", + state: { + status: "completed", + input: { filePath: "red.png" }, + output: "Image read successfully", + title: "red.png", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...base(assistantID, "prt_png"), + type: "file", + mime: "image/png", + filename: "red.png", + url: `data:image/png;base64,${png}`, + }, + ], + }, + }, + ] as SessionV1.Part[], + }, + ] +} + +test.each(["us.openai.gpt-6-astra", "global.openai.gpt-6-sol"])( + "sends a Bedrock %s tool-result image as a user message", + async (id) => { + const messages = await MessageV2.toModelMessages(history(bedrock(id)), bedrock(id)) + expect(messages.map((msg) => msg.role)).toEqual(["user", "assistant", "tool", "user"]) + expect(messages[2].content).toMatchObject([{ output: { type: "text", value: "Image read successfully" } }]) + expect(messages[3].content).toContainEqual({ + type: "file", + mediaType: "image/png", + filename: "red.png", + data: `data:image/png;base64,${png}`, + }) + }, +) + +test.each([ + "global.anthropic.claude-sonnet-4-6", + "us.amazon.nova-pro-v1:0", + "us.meta.llama4-maverick-17b-instruct-v1:0", +])("keeps a Bedrock %s tool-result image in the tool result", async (id) => { + const messages = await MessageV2.toModelMessages(history(bedrock(id)), bedrock(id)) + expect(messages.map((msg) => msg.role)).toEqual(["user", "assistant", "tool"]) + expect(messages[2].content).toMatchObject([ + { + output: { + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "media", mediaType: "image/png", data: png }, + ], + }, + }, + ]) +}) From 6f90fe19ffbd472435b415e1ad855c528f1c2ee2 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:34:06 +0000 Subject: [PATCH 013/353] fix(cli): require explicit user intent before starting Agent Manager sessions or worktrees --- .changeset/agent-session-explicit-intent.md | 5 +++++ packages/opencode/src/kilocode/tool/agent-manager.txt | 4 ++-- packages/opencode/src/kilocode/tool/task.ts | 3 +++ packages/opencode/src/tool/task.ts | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .changeset/agent-session-explicit-intent.md diff --git a/.changeset/agent-session-explicit-intent.md b/.changeset/agent-session-explicit-intent.md new file mode 100644 index 00000000000..6d1a1c9715b --- /dev/null +++ b/.changeset/agent-session-explicit-intent.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Tune the `agent_manager` and `task` tool instructions so agents only start new Agent Manager sessions or worktrees when the user explicitly asks, or after confirming with the user first. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1e140658310..1f46399e0c0 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -10,7 +10,7 @@ Permission blockers are separate from questions: a session may report `attention For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "", "sectionID": "" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Prompts to busy or retrying sessions are queued behind active work instead of rejected; you do not need to wait for idle before prompting. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section. -To start sessions, keep using the existing `mode` and `tasks` input without an action. Use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks. +To start sessions, keep using the existing `mode` and `tasks` input without an action. Only use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions, or after you have confirmed with the user first. Never start Agent Manager sessions on your own initiative just to parallelize, delegate, or organize routine work; starting sessions is user-visible and each worktree costs disk space and tokens. Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. @@ -22,4 +22,4 @@ By default, multiple tasks are started as independent Agent Manager sessions. Se If available, use `kilo_local_recall` only if you need context from a completed Agent Manager session. -Do not use this for ordinary subagent research. Use the `task` tool for internal subagents, and use this only when the user wants visible Agent Manager sessions in the extension. +Do not use this for ordinary subagent research. Use the `task` tool for internal subagents. Starting sessions here creates user-visible worktrees and interactive sessions in the extension, so reserve it for explicit user requests; if the user has not asked for new sessions or worktrees and you believe they would help, ask the user before starting them. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 16ae3d1ad84..129e1790b43 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -35,6 +35,9 @@ const ModelState = z .passthrough() export namespace KiloTask { + export const usageDescription = + "Subagents launched with this tool are internal to the current session: they do not create worktrees or interactive sessions, and only their final report returns to you. Routine subtask decomposition is expected and does not need user confirmation. Do not confuse this with agent_manager: starting Agent Manager sessions or worktrees is user-visible and requires an explicit user request, or you must confirm with the user first." + export const ModelFields = { model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a60d12fcfa6..ed765a8050e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -529,6 +529,7 @@ export const TaskTool = Tool.define( return { description: [ DESCRIPTION, + KiloTask.usageDescription, // kilocode_change - distinguish internal subagents from user-visible Agent Manager sessions ...(flags.experimentalBackgroundSubagents ? [BACKGROUND_DESCRIPTION] : []), ...(selection ? [KiloTask.modelDescription] : []), ].join("\n\n"), From c4f99e4a110ba4e4a3ea470effcbb18f8053e3bc Mon Sep 17 00:00:00 2001 From: kimnamu Date: Thu, 24 Sep 2026 19:02:04 +0900 Subject: [PATCH 014/353] test(cli): trim the Bedrock tool-result image test fixture The test built a full user and assistant turn only to reach supportsMediaInToolResult(). The conversion needs just the assistant turn with the read tool result, so the history now carries only that, cast the way test/kilocode/session/title.test.ts builds partial histories. The file goes from 121 to 70 lines with the same five cases and assertions: the two GPT-6 rows still fail with main's message-v2.ts and pass with the fix. Co-Authored-By: Claude --- .../bedrock-tool-result-images.test.ts | 121 +++++------------- 1 file changed, 35 insertions(+), 86 deletions(-) diff --git a/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts b/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts index fb3599418eb..6cfd46465f7 100644 --- a/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts +++ b/packages/opencode/test/kilocode/bedrock-tool-result-images.test.ts @@ -1,97 +1,46 @@ import { expect, test } from "bun:test" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, SessionID } from "../../src/session/schema" import type { Provider } from "../../src/provider/provider" -const sessionID = SessionID.make("ses_bedrock_tool_image") -const providerID = ProviderV2.ID.make("amazon-bedrock") const png = "iVBORw0KGgo=" -const bedrock = (id: string) => - ({ - id: ModelV2.ID.make(id), - providerID, - api: { id, url: "", npm: "@ai-sdk/amazon-bedrock" }, - }) as Provider.Model - -const base = (messageID: string, id: string) => ({ - id: PartID.make(id), - sessionID, - messageID: MessageID.make(messageID), -}) - -// The read tool returned a PNG; the next request replays that tool result. -const history = (model: Provider.Model): SessionV1.WithParts[] => { - const userID = MessageID.make("msg_user") - const assistantID = MessageID.make("msg_assistant") - return [ - { - info: { - id: userID, - sessionID, - role: "user", - time: { created: 0 }, - agent: "user", - model: { providerID, modelID: model.id }, - tools: {}, - mode: "", - } as SessionV1.User, - parts: [{ ...base(userID, "prt_user"), type: "text", text: "describe red.png" }], - }, - { - info: { - id: assistantID, - sessionID, - role: "assistant", - parentID: userID, - time: { created: 0 }, - modelID: model.id, - providerID, - mode: "", - agent: "code", - path: { cwd: "/", root: "/" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } as SessionV1.Assistant, - parts: [ - { - ...base(assistantID, "prt_read"), - type: "tool", - tool: "read", - callID: "call_read", - state: { - status: "completed", - input: { filePath: "red.png" }, - output: "Image read successfully", - title: "red.png", - metadata: {}, - time: { start: 0, end: 1 }, - attachments: [ - { - ...base(assistantID, "prt_png"), - type: "file", - mime: "image/png", - filename: "red.png", - url: `data:image/png;base64,${png}`, - }, - ], - }, +// The read tool returned a PNG; the next request replays that tool result (only the fields the conversion needs). +const history = [ + { + info: { id: "msg_assistant", role: "assistant" }, + parts: [ + { + type: "tool", + tool: "read", + callID: "call_read", + state: { + status: "completed", + input: { filePath: "red.png" }, + output: "Image read successfully", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [{ type: "file", mime: "image/png", filename: "red.png", url: `data:image/png;base64,${png}` }], }, - ] as SessionV1.Part[], - }, - ] -} + }, + ], + }, +] as unknown as SessionV1.WithParts[] + +const convert = (id: string) => + MessageV2.toModelMessages(history, { + id, + providerID: "amazon-bedrock", + api: { id, url: "", npm: "@ai-sdk/amazon-bedrock" }, + } as unknown as Provider.Model) test.each(["us.openai.gpt-6-astra", "global.openai.gpt-6-sol"])( "sends a Bedrock %s tool-result image as a user message", async (id) => { - const messages = await MessageV2.toModelMessages(history(bedrock(id)), bedrock(id)) - expect(messages.map((msg) => msg.role)).toEqual(["user", "assistant", "tool", "user"]) - expect(messages[2].content).toMatchObject([{ output: { type: "text", value: "Image read successfully" } }]) - expect(messages[3].content).toContainEqual({ + const messages = await convert(id) + expect(messages.map((msg) => msg.role)).toEqual(["assistant", "tool", "user"]) + expect(messages[1].content).toMatchObject([{ output: { type: "text", value: "Image read successfully" } }]) + expect(messages[2].content).toContainEqual({ type: "file", mediaType: "image/png", filename: "red.png", @@ -105,9 +54,9 @@ test.each([ "us.amazon.nova-pro-v1:0", "us.meta.llama4-maverick-17b-instruct-v1:0", ])("keeps a Bedrock %s tool-result image in the tool result", async (id) => { - const messages = await MessageV2.toModelMessages(history(bedrock(id)), bedrock(id)) - expect(messages.map((msg) => msg.role)).toEqual(["user", "assistant", "tool"]) - expect(messages[2].content).toMatchObject([ + const messages = await convert(id) + expect(messages.map((msg) => msg.role)).toEqual(["assistant", "tool"]) + expect(messages[1].content).toMatchObject([ { output: { type: "content", From b9e4b1e9863f9b42e6c42bb8d450ff134aa37e3f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:26:46 +0000 Subject: [PATCH 015/353] fix(cli): surface shadowed and failed config overlay writes Changing a permission from Ask to Allow in the settings UI could appear to do nothing: the write always lands in the preferred target file, but a higher-priority config file in the merge chain (e.g. opencode.json after kilo.json) still overrides the value. The PATCH /config/overlay endpoint returned success and the effective config reverted. - Detect the shadowing file after the write using the authoritative sources list and fail with a typed 409 naming the overriding file - Map config validation/JSON parse/write failures to a typed 400 with the file and issues instead of an opaque 500 defect - Show the shadowing file in the VS Code config error details --- .changeset/config-overlay-shadowed-error.md | 6 + .../kilo-vscode/src/kilo-provider-utils.ts | 4 +- .../tests/unit/kilo-provider-utils.test.ts | 14 ++ .../opencode/src/kilocode/config/sources.ts | 58 ++++++++ .../server/httpapi/groups/config-console.ts | 30 +++- .../server/httpapi/handlers/config-console.ts | 73 ++++++--- .../kilocode/server/config-overlay.test.ts | 138 ++++++++++++++++++ 7 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 .changeset/config-overlay-shadowed-error.md diff --git a/.changeset/config-overlay-shadowed-error.md b/.changeset/config-overlay-shadowed-error.md new file mode 100644 index 00000000000..a64e4bb9fac --- /dev/null +++ b/.changeset/config-overlay-shadowed-error.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Surface an error when a config change (e.g. flipping a permission from "Ask" to "Allow") is saved to one config file but a higher-priority file such as `opencode.json` still overrides it, instead of silently keeping the old effective value. Config validation and write failures are also reported with the file and issue details instead of an opaque server error. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 904f738ce7d..6f32cc0a432 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -111,11 +111,13 @@ export function getConfigErrorDetails(error: unknown): string | undefined { if (!data || typeof data !== "object") return undefined const scoped = data as Record const path = typeof scoped.path === "string" ? scoped.path : undefined + const shadowedBy = typeof scoped.shadowedBy === "string" ? scoped.shadowedBy : undefined const issues = Array.isArray(scoped.issues) ? scoped.issues : undefined - if (!path && (!issues || issues.length === 0)) return undefined + if (!path && !shadowedBy && (!issues || issues.length === 0)) return undefined const out: string[] = [] if (path) out.push(`File: ${path}`) + if (shadowedBy) out.push(`Shadowed by: ${shadowedBy}`) if (issues && issues.length > 0) { if (out.length > 0) out.push("") // prettifyError accepts any object with an `issues` array; the cast is diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 61d89d74b45..272e250fe89 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -910,6 +910,20 @@ describe("getConfigErrorDetails", () => { expect(getConfigErrorDetails({ data: { path: "/cfg.json" } })).toBe("File: /cfg.json") }) + it("formats a shadowed-write error with the overriding file", () => { + const err = { + data: { + message: + "The setting was saved to /home/me/.config/kilo/kilo.json, but /home/me/.config/kilo/opencode.json still takes precedence over it.", + path: "/home/me/.config/kilo/kilo.json", + shadowedBy: "/home/me/.config/kilo/opencode.json", + }, + } + expect(getConfigErrorDetails(err)).toBe( + "File: /home/me/.config/kilo/kilo.json\nShadowed by: /home/me/.config/kilo/opencode.json", + ) + }) + it("returns undefined when issues array is empty and no path", () => { expect(getConfigErrorDetails({ data: { issues: [] } })).toBeUndefined() }) diff --git a/packages/opencode/src/kilocode/config/sources.ts b/packages/opencode/src/kilocode/config/sources.ts index 1dedcb0dca4..599421a43cc 100644 --- a/packages/opencode/src/kilocode/config/sources.ts +++ b/packages/opencode/src/kilocode/config/sources.ts @@ -1,5 +1,6 @@ import os from "os" import path from "path" +import * as jsonc from "jsonc-parser" import { unique } from "remeda" import z from "zod" import { Flag } from "@opencode-ai/core/flag/flag" @@ -312,4 +313,61 @@ export namespace KilocodeConfigSources { reason: input.reason, } } + + const FILE_KINDS: ReadonlySet = new Set(["global-file", "project-file", "config-dir-file"]) + + // After writing `target`, find the highest-priority existing config file that still defines a + // value for a key the patch writes, so the effective setting does not change. The sources + // list mirrors the loader's merge order (later sources win), so only files that sort after + // the target can shadow it. Values are compared structurally (JSONC), not via the schema: a + // shadowing file may use a different shape (e.g. scalar permission vs. pattern map) and + // still win the merge. Non-file sources (env flags, cloud org config, MDM preferences) are + // not parsed here; they are already listed with a reason in the sources response. + export async function conflicting(input: { + directory: string + worktree?: string + target: string + patch: Record + }): Promise { + const { sources } = await list({ directory: input.directory, worktree: input.worktree }) + const files = sources.filter((item) => item.exists && item.path && FILE_KINDS.has(item.kind)) + const orders = files.filter((item) => item.path === input.target).map((item) => item.order) + if (orders.length === 0) return undefined + const base = Math.min(...orders) + const candidates = files.filter((item) => item.path !== input.target && item.order > base) + for (const item of [...candidates].reverse()) { + if (!item.path) continue + const text = await Filesystem.readText(item.path).catch(() => undefined) + if (text === undefined) continue + const raw = parseJsonc(text) + if (!raw) continue + if (clashes(raw, input.patch)) return item.path + } + return undefined + } + + // A candidate file shadows the patch when it defines a value for any leaf the patch writes. + // Nulls in the patch are unsets; an unset inherits the candidate value, which is expected. + function clashes(candidate: Record, patch: Record): boolean { + return Object.entries(patch).some(([key, value]) => { + if (value === null) return false + const next = candidate[key] + if (next === undefined) return false + if (isPlain(value) && isPlain(next)) return clashes(next, value) + return true + }) + } + + function isPlain(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + } + + function parseJsonc(text: string): Record | undefined { + try { + const raw: unknown = jsonc.parse(text) + return isPlain(raw) ? raw : undefined + } catch { + return undefined + } + } } diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts index 1b52d4a5d9e..aa49a100c3b 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts @@ -75,6 +75,34 @@ export class ConfigOverlayConflictError extends Schema.ErrorClass( + "ConfigOverlayShadowedError", +)( + { + message: Schema.String, + path: Schema.String, + shadowedBy: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} +// The merged config would be invalid or the target file is unwritable. Surfaced as 400/403 +// with a message instead of an opaque 500. +export class ConfigOverlayWriteError extends Schema.ErrorClass("ConfigOverlayWriteError")( + { + message: Schema.String, + path: Schema.optional(Schema.String), + issues: Schema.optional( + Schema.Array( + Schema.StructWithRest(Schema.Struct({ message: Schema.String, path: Schema.Array(Schema.String) }), [ + Schema.Record(Schema.String, Schema.Unknown), + ]), + ), + ), + }, + { httpApiStatus: 400 }, +) {} export const ConfigRulesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, scope: Schema.optional(ProjectScope), @@ -199,7 +227,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") query: WorkspaceRoutingQuery, payload: ConfigOverlayPatch, success: described(ConfigOverlayResponse, "Resolved config overlay after patch"), - error: ConfigOverlayConflictError, + error: [ConfigOverlayConflictError, ConfigOverlayShadowedError, ConfigOverlayWriteError], }).annotateMerge( OpenApi.annotations({ identifier: "config.overlayUpdate", diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts index 208c0d84098..09f2b02e720 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts @@ -14,7 +14,6 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy import { Event } from "@/server/event" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle" -import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors" import { Effect, Option } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -23,10 +22,13 @@ import { ConfigOverlayConflictError, ConfigOverlayPatch, ConfigOverlayQuery, + ConfigOverlayShadowedError, + ConfigOverlayWriteError, ConfigRulesPatch, TuiConfigPatch, TuiConfigQuery, } from "../groups/config-console" +import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error" export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "config-console", (handlers) => Effect.gen(function* () { @@ -81,24 +83,41 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf } const expected = body.expected ? { ...body.expected } : undefined const instance = yield* InstanceState.context - const result = yield* flock - .withLock( - Effect.promise(() => - KilocodeConfigWriter.write({ - ...body, - directory: instance.directory, - worktree: instance.worktree, - expected, - }), - ), - `config:${body.scope}:${expected?.path ?? "target"}`, - ) - .pipe(Effect.orDie) + const writing = Effect.tryPromise({ + try: () => + KilocodeConfigWriter.write({ + ...body, + directory: instance.directory, + worktree: instance.worktree, + expected, + }), + catch: (error: unknown) => { + // Config validation and JSON parse failures must reach the client as a typed + // error with the file and issues instead of an opaque 500 defect. + if (error instanceof ConfigErrorV1.InvalidError) { + return new ConfigOverlayWriteError({ + message: error.data.message ?? `Config is invalid in ${error.data.path}`, + path: error.data.path, + issues: error.data.issues?.map((issue: { message: string; path: unknown[] }) => ({ + message: issue.message, + path: issue.path.map(String), + })), + }) + } + if (error instanceof ConfigErrorV1.JsonError) { + return new ConfigOverlayWriteError({ + message: error.data.message ?? `Config file contains invalid JSON: ${error.data.path}`, + path: error.data.path, + }) + } + const message = error instanceof Error ? error.message : String(error) + return new ConfigOverlayWriteError({ message: `Failed to write config: ${message}` }) + }, + }) + const result = yield* flock.withLock(writing, `config:${body.scope}:${expected?.path ?? "target"}`) if (!result.ok) { if (result.code === "target-not-writable") { - return yield* Effect.fail( - new InvalidRequestError({ message: result.message, kind: result.code, field: result.target.path }), - ) + return yield* Effect.fail(new ConfigOverlayWriteError({ message: result.message, path: result.target.path })) } return yield* Effect.fail( new ConfigOverlayConflictError({ code: result.code, message: result.message, target: result.target }), @@ -106,6 +125,26 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf } const patch = KilocodeConfigOverlay.patch(body) const hot = body.scope === "global" && Object.keys(patch).every((key) => key === "console") + // The write landed, but a higher-priority config file can still override the new value + // (e.g. ~/.config/kilo/opencode.json shadows ~/.config/kilo/kilo.json). In that case the + // effective setting does not change, so report it instead of claiming success. + const shadow = yield* Effect.promise(() => + KilocodeConfigSources.conflicting({ + directory: instance.directory, + worktree: instance.worktree, + target: result.target.path, + patch, + }), + ) + if (shadow) { + return yield* Effect.fail( + new ConfigOverlayShadowedError({ + message: `The setting was saved to ${result.target.path}, but ${shadow} still takes precedence over it. Remove or edit the conflicting value there.`, + path: result.target.path, + shadowedBy: shadow, + }), + ) + } if (body.scope === "global") { yield* config.invalidate() if (result.changed) { diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index e1e8fb90ad0..a57a416e2ce 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -297,6 +297,7 @@ describe("config overlay routes", () => { const update = (model: string) => Server.Default().app.request("/config/overlay", { method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, body: JSON.stringify({ scope: "project", @@ -312,6 +313,143 @@ describe("config overlay routes", () => { expect(responses.map((response) => response.status).sort()).toEqual([200, 409]) }) + test("reports a shadowed project write when a .kilo directory file wins the merge", async () => { + await using project = await tmpdir() + // Target resolution prefers an existing root kilo.json, but .kilo/kilo.json loads after it + // in the merge chain, so writing the target leaves the .kilo value effective. + await Filesystem.write(path.join(project.path, "kilo.json"), JSON.stringify({ model: "test/old" })) + await Filesystem.write(path.join(project.path, ".kilo", "kilo.json"), JSON.stringify({ model: "test/old" })) + const before = await json(await req(project.path, "/config/overlay?scope=project")) + expect(before.targets.project.path).toBe(path.join(project.path, ".kilo", "kilo.json")) + + const response = await Server.Default().app.request("/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, + body: JSON.stringify({ + scope: "project", + expected: { + path: before.targets.project.path, + revision: before.targets.project.revision, + }, + set: { model: "test/new" }, + }), + }) + + expect(response.status).toBe(200) + }) + + test("reports a shadowed write when a later file in the target chain wins", async () => { + await using project = await tmpdir() + // kilo.json is the target, but opencode.json sorts after it in the same directory, so it + // shadows the write even though the user explicitly flipped the value in the UI. + await Filesystem.write(path.join(project.path, "kilo.json"), JSON.stringify({ permission: { bash: "ask" } })) + await Filesystem.write(path.join(project.path, "opencode.json"), JSON.stringify({ permission: { bash: "ask" } })) + const before = await json(await req(project.path, "/config/overlay?scope=project")) + expect(before.targets.project.path).toBe(path.join(project.path, "kilo.json")) + + const response = await Server.Default().app.request("/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, + body: JSON.stringify({ + scope: "project", + expected: { + path: before.targets.project.path, + revision: before.targets.project.revision, + }, + set: { permission: { bash: "allow" } }, + }), + }) + + expect(response.status).toBe(409) + const body = (await response.json()) as { message: string; shadowedBy?: string } + expect(body.shadowedBy).toBe(path.join(project.path, "opencode.json")) + expect(body.message).toContain("takes precedence") + // The write still landed on disk; the error explains why it does not take effect. + const saved = (await Bun.file(before.targets.project.path).json()) as { permission?: { bash?: string } } + expect(saved.permission?.bash).toBe("allow") + }) + + test("reports a shadowed global write when a higher-precedence global file wins", async () => { + await using global = await tmpdir() + await using project = await tmpdir() + ;(Global.Path as { config: string }).config = global.path + // The global target prefers kilo.json, and the loader merges opencode.json after it, so + // an existing opencode.json value shadows every write to kilo.json. + await Filesystem.write(path.join(global.path, "kilo.json"), JSON.stringify({ permission: { bash: "ask" } })) + await Filesystem.write(path.join(global.path, "opencode.json"), JSON.stringify({ permission: { bash: "ask" } })) + const before = await json(await req(project.path, "/config/overlay?scope=global")) + expect(before.targets.global.path).toBe(path.join(global.path, "kilo.json")) + + const response = await Server.Default().app.request("/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, + body: JSON.stringify({ + scope: "global", + expected: { + path: before.targets.global.path, + revision: before.targets.global.revision, + }, + set: { permission: { bash: "allow" } }, + }), + }) + + expect(response.status).toBe(409) + const body = (await response.json()) as { message: string; shadowedBy?: string } + expect(body.shadowedBy).toBe(path.join(global.path, "opencode.json")) + const saved = (await Bun.file(before.targets.global.path).json()) as { permission?: { bash?: string } } + expect(saved.permission?.bash).toBe("allow") + }) + + test("does not report shadowing for unrelated values in higher-priority files", async () => { + await using project = await tmpdir() + await Filesystem.write(path.join(project.path, "kilo.json"), "{}") + await Filesystem.write(path.join(project.path, "opencode.json"), JSON.stringify({ share: "disabled" })) + const before = await json(await req(project.path, "/config/overlay?scope=project")) + + const response = await Server.Default().app.request("/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, + body: JSON.stringify({ + scope: "project", + expected: { + path: before.targets.project.path, + revision: before.targets.project.revision, + }, + set: { model: "test/new" }, + }), + }) + + expect(response.status).toBe(200) + }) + + test("surfaces config validation errors as a typed 400 instead of an opaque 500", async () => { + await using project = await tmpdir() + const before = await json(await req(project.path, "/config/overlay?scope=project")) + + const response = await Server.Default().app.request("/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json", "x-kilo-directory": project.path }, + body: JSON.stringify({ + scope: "project", + expected: { + path: before.targets.project.path, + revision: before.targets.project.revision, + }, + set: { modle: "test/model" }, + }), + }) + + expect(response.status).toBe(400) + const body = (await response.json()) as { + message: string + path?: string + issues?: Array<{ message: string }> + } + expect(body.path).toBe(before.targets.project.path) + expect(body.issues?.at(0)?.message).toContain("modle") + expect(await Bun.file(before.targets.project.path).exists()).toBe(false) + }) + test("rejects a project config target that escapes through a symlink", async () => { if (process.platform === "win32") return await using project = await tmpdir() From 9c96d61049e42b49af9cbc8e7eeb41b56499cf42 Mon Sep 17 00:00:00 2001 From: maphew Date: Tue, 15 Sep 2026 21:07:26 +0000 Subject: [PATCH 016/353] fix(opencode): point cloud sessions at a permitted temp dir Cloud sessions inject an external_directory allowlist that permits only the session-scoped temp dir, while the bash tool description advertised the shared Global.Path.tmp (/tmp/kilo). Agents following the guidance hit repeated external_directory denies. Render ${tmp} from the session-scoped temp dir when KILO_CLOUD_AGENT is truthy and SESSION_ID is a safe path segment, creating the directory so the "already created" claim holds. Missing or unsafe session ids, a symlinked target, or a failed mkdir fall back to the shared temp dir, and non-cloud sessions keep the current behavior. Closes #14189 Agent-Signature: kilo-kilo-auto/efficient-efficient on behalf of maphew --- packages/opencode/src/tool/shell/prompt.ts | 23 ++++- .../opencode/test/tool/shell-prompt.test.ts | 88 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/tool/shell-prompt.test.ts diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index e8c04833138..689076761c0 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -2,6 +2,9 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" +import fs from "node:fs" // kilocode_change +import os from "os" // kilocode_change +import path from "path" // kilocode_change import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) @@ -49,6 +52,24 @@ function shellDisplayName(name: string) { return name } +// kilocode_change start - cloud sessions allowlist a session-scoped temp dir instead of the shared one +function sessionTmp() { + const cloud = process.env["KILO_CLOUD_AGENT"]?.toLowerCase() + if (cloud !== "true" && cloud !== "1") return Global.Path.tmp + const session = process.env.SESSION_ID + if (!session || !/^[A-Za-z0-9_-]+$/.test(session)) return Global.Path.tmp + const dir = path.join(os.tmpdir(), session) + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + // lstat, not stat: a pre-existing symlink must not redirect the advertised dir + if (!fs.lstatSync(dir).isDirectory()) return Global.Path.tmp + return dir + } catch { + return Global.Path.tmp + } +} +// kilocode_change end + function powershellNotes(name: string) { if (name === "pwsh") { return `# PowerShell (7+) shell notes @@ -296,7 +317,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits, intro: selected.intro, os: platform, shell: name, - tmp: Global.Path.tmp, + tmp: sessionTmp(), // kilocode_change - session-scoped temp dir in cloud sessions workdirSection: selected.workdirSection, commandSection: selected.commandSection, gitCommands: selected.gitCommands, diff --git a/packages/opencode/test/tool/shell-prompt.test.ts b/packages/opencode/test/tool/shell-prompt.test.ts new file mode 100644 index 00000000000..67afb4d47e6 --- /dev/null +++ b/packages/opencode/test/tool/shell-prompt.test.ts @@ -0,0 +1,88 @@ +// kilocode_change - new file +import { afterAll, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "os" +import path from "path" +import { ShellPrompt } from "../../src/tool/shell/prompt" + +// kilocode_change start - cloud sessions allowlist a session-scoped temp dir, so the +// bash tool description must name a path the injected external_directory rules permit +const sharedTmp = path.join(os.tmpdir(), "kilo") +const sessionTmp = path.join(os.tmpdir(), "agent_test-session_1") + +const withEnv = (env: Record, run: () => void) => { + const saved = Object.entries(env).map(([key]) => [key, process.env[key]] as const) + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + try { + run() + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +const renderBash = () => ShellPrompt.render("bash", "linux", { maxLines: 100, maxBytes: 100 }, 1000).description + +describe("tool.shell prompt tmp", () => { + afterAll(async () => { + await fs.rm(sessionTmp, { recursive: true, force: true }) + await fs.rm(path.join(os.tmpdir(), "agent_symlink-test"), { force: true }) + }) + + test("names the shared temp dir outside cloud sessions", () => { + withEnv({ KILO_CLOUD_AGENT: undefined, SESSION_ID: undefined }, () => { + expect(renderBash()).toContain(`Use \`${sharedTmp}\` for temporary work`) + }) + }) + + test("names the session-scoped temp dir in cloud sessions", () => { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_test-session_1" }, () => { + expect(renderBash()).toContain(`Use \`${sessionTmp}\` for temporary work`) + }) + }) + + test("accepts truthy KILO_CLOUD_AGENT case-insensitively", () => { + withEnv({ KILO_CLOUD_AGENT: "TRUE", SESSION_ID: "agent_test-session_1" }, () => { + expect(renderBash()).toContain(`Use \`${sessionTmp}\` for temporary work`) + }) + }) + + test("creates the session-scoped temp dir in cloud sessions", async () => { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_test-session_1" }, () => { + renderBash() + }) + const stat = await fs.stat(sessionTmp) + expect(stat.isDirectory()).toBe(true) + }) + + test("falls back to the shared temp dir when the session id is unsafe", () => { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent/../escape" }, () => { + expect(renderBash()).toContain(`Use \`${sharedTmp}\` for temporary work`) + }) + }) + + test("falls back to the shared temp dir when SESSION_ID is missing", () => { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: undefined }, () => { + expect(renderBash()).toContain(`Use \`${sharedTmp}\` for temporary work`) + }) + }) + + test("falls back to the shared temp dir when the session path is a symlink", async () => { + if (process.platform === "win32") return + const link = path.join(os.tmpdir(), "agent_symlink-test") + await fs.symlink(sharedTmp, link) + try { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_symlink-test" }, () => { + expect(renderBash()).toContain(`Use \`${sharedTmp}\` for temporary work`) + }) + } finally { + await fs.rm(link, { force: true }) + } + }) +}) +// kilocode_change end From 4290338d85d4b854da16aabb27ae4bca038cd47e Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:27:20 +0000 Subject: [PATCH 017/353] fix(opencode): address review - extract shell tmp to kilocode mirror --- .changeset/cloud-session-shell-tmp.md | 5 +++ .../opencode/src/kilocode/tool/shell-tmp.ts | 31 +++++++++++++++++++ packages/opencode/src/tool/shell/prompt.ts | 22 +------------ .../tool-shell-prompt.test.ts} | 19 +++++++----- 4 files changed, 49 insertions(+), 28 deletions(-) create mode 100644 .changeset/cloud-session-shell-tmp.md create mode 100644 packages/opencode/src/kilocode/tool/shell-tmp.ts rename packages/opencode/test/{tool/shell-prompt.test.ts => kilocode/tool-shell-prompt.test.ts} (80%) diff --git a/.changeset/cloud-session-shell-tmp.md b/.changeset/cloud-session-shell-tmp.md new file mode 100644 index 00000000000..a6505716eff --- /dev/null +++ b/.changeset/cloud-session-shell-tmp.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Cloud sessions: the bash tool now advertises a session-scoped temp dir covered by the injected permission rules instead of the denied shared temp dir. diff --git a/packages/opencode/src/kilocode/tool/shell-tmp.ts b/packages/opencode/src/kilocode/tool/shell-tmp.ts new file mode 100644 index 00000000000..8dc26be89b7 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/shell-tmp.ts @@ -0,0 +1,31 @@ +// Cloud sessions allowlist a session-scoped temp dir instead of the shared one. +// +// The platform injects `external_directory` rules permitting only +// `/tmp//**` (plus a few session-scoped roots) and denying +// everything else, while the bash tool description otherwise advertises the +// shared `Global.Path.tmp`. Naming the session dir here keeps the guidance +// within the permitted set. +import fs from "node:fs" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +// The allowlist root is the literal `/tmp`, not `os.tmpdir()`: `os.tmpdir()` +// follows `TMPDIR`, so deriving from it could point the model back at a denied +// path if that override ever reaches the server process. +const ALLOWLIST_ROOT = "/tmp" + +export function sessionTmp() { + const cloud = process.env["KILO_CLOUD_AGENT"]?.toLowerCase() + if (cloud !== "true" && cloud !== "1") return Global.Path.tmp + const session = process.env.SESSION_ID + if (!session || !/^[A-Za-z0-9_-]+$/.test(session)) return Global.Path.tmp + const dir = path.join(ALLOWLIST_ROOT, session) + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + // lstat, not stat: a pre-existing symlink must not redirect the advertised dir + if (!fs.lstatSync(dir).isDirectory()) return Global.Path.tmp + return dir + } catch { + return Global.Path.tmp + } +} diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 689076761c0..f56d34ea359 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -2,9 +2,7 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" -import fs from "node:fs" // kilocode_change -import os from "os" // kilocode_change -import path from "path" // kilocode_change +import { sessionTmp } from "@/kilocode/tool/shell-tmp" // kilocode_change import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) @@ -52,24 +50,6 @@ function shellDisplayName(name: string) { return name } -// kilocode_change start - cloud sessions allowlist a session-scoped temp dir instead of the shared one -function sessionTmp() { - const cloud = process.env["KILO_CLOUD_AGENT"]?.toLowerCase() - if (cloud !== "true" && cloud !== "1") return Global.Path.tmp - const session = process.env.SESSION_ID - if (!session || !/^[A-Za-z0-9_-]+$/.test(session)) return Global.Path.tmp - const dir = path.join(os.tmpdir(), session) - try { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) - // lstat, not stat: a pre-existing symlink must not redirect the advertised dir - if (!fs.lstatSync(dir).isDirectory()) return Global.Path.tmp - return dir - } catch { - return Global.Path.tmp - } -} -// kilocode_change end - function powershellNotes(name: string) { if (name === "pwsh") { return `# PowerShell (7+) shell notes diff --git a/packages/opencode/test/tool/shell-prompt.test.ts b/packages/opencode/test/kilocode/tool-shell-prompt.test.ts similarity index 80% rename from packages/opencode/test/tool/shell-prompt.test.ts rename to packages/opencode/test/kilocode/tool-shell-prompt.test.ts index 67afb4d47e6..6da21cb9e04 100644 --- a/packages/opencode/test/tool/shell-prompt.test.ts +++ b/packages/opencode/test/kilocode/tool-shell-prompt.test.ts @@ -1,14 +1,14 @@ -// kilocode_change - new file import { afterAll, describe, expect, test } from "bun:test" import fs from "node:fs/promises" import os from "os" import path from "path" import { ShellPrompt } from "../../src/tool/shell/prompt" -// kilocode_change start - cloud sessions allowlist a session-scoped temp dir, so the -// bash tool description must name a path the injected external_directory rules permit +// Cloud sessions allowlist a session-scoped temp dir, so the bash tool +// description must name a path the injected external_directory rules permit. +// The allowlist root is the literal `/tmp`, not `os.tmpdir()`. const sharedTmp = path.join(os.tmpdir(), "kilo") -const sessionTmp = path.join(os.tmpdir(), "agent_test-session_1") +const sessionTmp = path.join("/tmp", "agent_test-session_1") const withEnv = (env: Record, run: () => void) => { const saved = Object.entries(env).map(([key]) => [key, process.env[key]] as const) @@ -31,7 +31,7 @@ const renderBash = () => ShellPrompt.render("bash", "linux", { maxLines: 100, ma describe("tool.shell prompt tmp", () => { afterAll(async () => { await fs.rm(sessionTmp, { recursive: true, force: true }) - await fs.rm(path.join(os.tmpdir(), "agent_symlink-test"), { force: true }) + await fs.rm(path.join("/tmp", "agent_symlink-test"), { force: true }) }) test("names the shared temp dir outside cloud sessions", () => { @@ -52,6 +52,12 @@ describe("tool.shell prompt tmp", () => { }) }) + test("derives the session dir from the allowlist root even when TMPDIR is overridden", () => { + withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_test-session_1", TMPDIR: "/custom-tmp" }, () => { + expect(renderBash()).toContain(`Use \`${sessionTmp}\` for temporary work`) + }) + }) + test("creates the session-scoped temp dir in cloud sessions", async () => { withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_test-session_1" }, () => { renderBash() @@ -74,7 +80,7 @@ describe("tool.shell prompt tmp", () => { test("falls back to the shared temp dir when the session path is a symlink", async () => { if (process.platform === "win32") return - const link = path.join(os.tmpdir(), "agent_symlink-test") + const link = path.join("/tmp", "agent_symlink-test") await fs.symlink(sharedTmp, link) try { withEnv({ KILO_CLOUD_AGENT: "1", SESSION_ID: "agent_symlink-test" }, () => { @@ -85,4 +91,3 @@ describe("tool.shell prompt tmp", () => { } }) }) -// kilocode_change end From 88239c6c0ee310cea0c8f7192d0d7c5e7e693daf Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:12:21 +0000 Subject: [PATCH 018/353] fix(opencode): drop unused Global import in shell prompt Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/tool/shell/prompt.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index f56d34ea359..3dd01370847 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" import { PositiveInt } from "@opencode-ai/core/schema" -import { Global } from "@opencode-ai/core/global" import { sessionTmp } from "@/kilocode/tool/shell-tmp" // kilocode_change import { ShellID } from "./id" From 4ed8e7b461befb82fb2a855da6835f2f9c15ee06 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:45:34 +0000 Subject: [PATCH 019/353] fix(cli): resolve bare model names for task subagents A bare model name in subagent_model or an agent model, such as "codestral (latest)", was split on "/" into a provider name and an empty model ID. The task tool then failed with "Model not found: codestral (latest)/." or silently ignored the configured model. Resolve a bare name against the provider catalog instead. Prefer the parent session's provider when several providers offer a model with the same name. Reject qualified values with an empty provider or model part. Log a warning when a name cannot be resolved or is ambiguous. --- .changeset/task-bare-model-name.md | 5 + .../code-with-ai/agents/model-selection.md | 2 + packages/opencode/src/kilocode/tool/task.ts | 71 +++++++++- .../test/kilocode/tool-task-model.test.ts | 123 +++++++++++++++++- 4 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 .changeset/task-bare-model-name.md diff --git a/.changeset/task-bare-model-name.md b/.changeset/task-bare-model-name.md new file mode 100644 index 00000000000..532335d9f93 --- /dev/null +++ b/.changeset/task-bare-model-name.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Task tool: a bare model name in `subagent_model` or an agent `model` now resolves to the matching provider model instead of failing with a "Model not found" error or being ignored. The parent session's provider is preferred when several providers offer a model with the same name. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 7595b492443..536566e784d 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -129,6 +129,8 @@ When an agent delegates work to a subagent (via the `task` tool), the subagent * This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. +A bare model name without a provider prefix also works — for example `"model": "codestral (latest)"`. Kilo resolves the name against your configured providers and prefers the provider of the parent session when several providers offer a model with the same name. This applies to `agent..model` and `subagent_model`. + {% /tab %} {% tab label="VSCode" %} diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 3580c71e40f..bbe9e0d5bf5 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -173,12 +173,57 @@ export namespace KiloTask { function parse(value: string | null | undefined): Model | undefined { if (!value) return undefined const [providerID, ...parts] = value.split("/") + const modelID = parts.join("/") + if (!providerID || !modelID) return undefined return { providerID: ProviderV2.ID.make(providerID), - modelID: ModelV2.ID.make(parts.join("/")), + modelID: ModelV2.ID.make(modelID), } } + /** + * Resolve a configured model reference. A qualified `provider/model` value + * is parsed directly. A bare value (no `/`) is treated as a model name and + * matched against the provider catalog, preferring the parent session's + * provider so a custom provider's display name (e.g. "codestral (latest)") + * resolves to that provider's model instead of failing with an empty model + * ID. Unresolvable or ambiguous names log a warning and resolve to + * undefined, so the caller falls back to the next model source. + */ + const resolve = Effect.fn("KiloTask.resolve")(function* (input: { + value: string + preferred: string + provider: Provider.Interface + }) { + const value = input.value.trim() + if (!value) return undefined + if (value.includes("/")) return parse(value) + const providers = yield* input.provider.list() + const all = Object.values(providers).flatMap((provider) => + Object.values(provider.models).map((model) => ({ providerID: provider.id, model })), + ) + const query = value.toLowerCase() + const exact = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query) + const named = exact.length ? exact : all.filter((item) => item.model.name.toLowerCase() === query) + if (named.length === 0) { + log.warn("task model name is not available", { value }) + return undefined + } + const preferred = named.filter((item) => item.providerID === input.preferred) + const pool = preferred.length ? preferred : named + if (pool.length > 1) { + log.warn("task model name is ambiguous", { + value, + providers: pool.map((item) => item.providerID), + }) + return undefined + } + return { + providerID: ProviderV2.ID.make(pool[0].providerID), + modelID: ModelV2.ID.make(pool[0].model.id), + } + }) + const saved = Effect.fn("KiloTask.savedModel")(function* (name: string) { if (Flag.KILO_CLIENT !== "cli") return undefined const file = path.join(Global.Path.state, "model.json") @@ -209,7 +254,25 @@ export namespace KiloTask { provider: Provider.Interface }) { const state = yield* saved(input.name) - const cfg = parse(input.config.subagent_model) + const configured = input.config.subagent_model + ? yield* resolve({ + value: input.config.subagent_model, + preferred: input.parent.providerID, + provider: input.provider, + }) + : undefined + // A bare model name in the agent config is parsed by Provider.parseModel + // into an empty modelID (providerID holds the name); resolve it to a real + // provider/model pair instead of launching a child with an empty ID. + const agentModel = input.agent.model + ? input.agent.model.modelID + ? input.agent.model + : yield* resolve({ + value: input.agent.model.providerID, + preferred: input.parent.providerID, + provider: input.provider, + }) + : undefined const override = (model: Model) => input.config.subagent_variant_overrides?.[key(model)] ?? undefined const choices: Array = [ input.workflow ? { ...input.workflow, direct: true } : undefined, @@ -220,8 +283,8 @@ export namespace KiloTask { sticky: true, } : undefined, - input.agent.model ? { model: input.agent.model, variant: input.agent.variant, direct: true } : undefined, - cfg ? { model: cfg, variant: input.config.subagent_variant ?? undefined } : undefined, + agentModel ? { model: agentModel, variant: input.agent.variant, direct: true } : undefined, + configured ? { model: configured, variant: input.config.subagent_variant ?? undefined } : undefined, ] for (const choice of choices) { diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index c31c2e5e70c..67e4c4fc184 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -93,12 +93,25 @@ function custom(id: string, model: string, variants: string[] = []) { } } +const parentProvider = custom("parent-provider", "parent-model", [inherited, overrideVariant]) +const configProvider = custom("config-provider", "config-model", [cfgVariant, overrideVariant]) +const savedProvider = custom("saved-provider", "saved-model", [savedVariant, overrideVariant]) +const subProvider = custom("sub-provider", "sub-model", [subVariant, overrideVariant]) const catalog = { provider: { - "parent-provider": custom("parent-provider", "parent-model", [inherited, overrideVariant]), - "saved-provider": custom("saved-provider", "saved-model", [savedVariant, overrideVariant]), - "config-provider": custom("config-provider", "config-model", [cfgVariant, overrideVariant]), - "sub-provider": custom("sub-provider", "sub-model", [subVariant, overrideVariant]), + "parent-provider": { + ...parentProvider, + models: { ...parentProvider.models, ...custom("parent-provider", "shared-model").models }, + }, + "saved-provider": { + ...savedProvider, + models: { ...savedProvider.models, ...custom("saved-provider", "dup-model").models }, + }, + "config-provider": { + ...configProvider, + models: { ...configProvider.models, ...custom("config-provider", "dup-model").models }, + }, + "sub-provider": subProvider, }, } @@ -206,7 +219,7 @@ function writeState(input: unknown) { } function run(input: { - agent: "pinned" | "worker" + agent: "pinned" | "worker" | "bare" state?: unknown client?: string variant?: string @@ -272,6 +285,7 @@ function run(input: { agent: { worker: { mode: "subagent" }, pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, + bare: { mode: "subagent", model: "config-model", variant: cfgVariant }, }, }, }, @@ -721,6 +735,105 @@ describe("tool.task model resolution", () => { ), ) + it.live("bare subagent model name resolves to the matching provider model", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "sub-model", subagent_variant: subVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(sub) + expect(result.variant).toEqual(subVariant) + expect(result.model).toEqual(sub) + expect(result.metadataVariant).toEqual(subVariant) + }), + ), + ), + ) + + it.live("bare subagent model name prefers the parent session provider", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "shared-model" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual({ + providerID: ProviderV2.ID.make("parent-provider"), + modelID: ModelV2.ID.make("shared-model"), + }) + expect(result.variant).toBeUndefined() + }), + ), + ), + ) + + it.live("bare agent model name resolves to the matching provider model", () => + run({ agent: "bare", variant: inherited }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(cfg) + expect(result.variant).toEqual(cfgVariant) + expect(result.model).toEqual(cfg) + expect(result.metadataVariant).toEqual(cfgVariant) + }), + ), + ), + ) + + it.live("ambiguous bare subagent model name falls back to the parent model", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "dup-model" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toEqual(inherited) + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toEqual(inherited) + }), + ), + ), + ) + + it.live("unknown bare subagent model name falls back to the parent model", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "codestral (latest)" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toEqual(inherited) + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toEqual(inherited) + }), + ), + ), + ) + + it.live("qualified subagent model without a model id falls back to the parent model", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "config-provider/" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toEqual(inherited) + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toEqual(inherited) + }), + ), + ), + ) + it.live("stale configured subagent variant is ignored without dropping its model", () => run({ agent: "worker", From ca197fdd78bad95184d6b3c3b98da4a9b5ce222d Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:32:16 +0000 Subject: [PATCH 020/353] fix(cli): match bare task model names by id before display name Addresses review findings on the bare-name resolver: - match a bare value against catalog model IDs before display names, so ids whose display name differs (e.g. codestral-latest vs Codestral (latest)) resolve instead of falling back to the parent model - cover the parent-provider preference branch with a model shared by two providers - add an id-vs-display-name regression test --- packages/opencode/src/kilocode/tool/task.ts | 16 ++++----- .../test/kilocode/tool-task-model.test.ts | 36 ++++++++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index bbe9e0d5bf5..cc323678e8b 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -183,12 +183,12 @@ export namespace KiloTask { /** * Resolve a configured model reference. A qualified `provider/model` value - * is parsed directly. A bare value (no `/`) is treated as a model name and - * matched against the provider catalog, preferring the parent session's - * provider so a custom provider's display name (e.g. "codestral (latest)") - * resolves to that provider's model instead of failing with an empty model - * ID. Unresolvable or ambiguous names log a warning and resolve to - * undefined, so the caller falls back to the next model source. + * is parsed directly. A bare value (no `/`) is matched against the provider + * catalog by model ID first, then by display name, preferring the parent + * session's provider so a custom provider's display name (e.g. + * "codestral (latest)") resolves to that provider's model instead of failing + * with an empty model ID. Unresolvable or ambiguous names log a warning and + * resolve to undefined, so the caller falls back to the next model source. */ const resolve = Effect.fn("KiloTask.resolve")(function* (input: { value: string @@ -203,8 +203,8 @@ export namespace KiloTask { Object.values(provider.models).map((model) => ({ providerID: provider.id, model })), ) const query = value.toLowerCase() - const exact = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query) - const named = exact.length ? exact : all.filter((item) => item.model.name.toLowerCase() === query) + const ids = all.filter((item) => item.model.id.toLowerCase() === query) + const named = ids.length ? ids : all.filter((item) => item.model.name.toLowerCase() === query) if (named.length === 0) { log.warn("task model name is not available", { value }) return undefined diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 67e4c4fc184..2c93dd9a594 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -68,7 +68,7 @@ const sub = { } const subVariant = "deep" -function custom(id: string, model: string, variants: string[] = []) { +function custom(id: string, model: string, variants: string[] = [], name = model) { return { name: id, id, @@ -77,7 +77,7 @@ function custom(id: string, model: string, variants: string[] = []) { models: { [model]: { id: model, - name: model, + name, attachment: false, reasoning: variants.length > 0, temperature: false, @@ -105,11 +105,19 @@ const catalog = { }, "saved-provider": { ...savedProvider, - models: { ...savedProvider.models, ...custom("saved-provider", "dup-model").models }, + models: { + ...savedProvider.models, + ...custom("saved-provider", "dup-model").models, + ...custom("saved-provider", "shared-model").models, + }, }, "config-provider": { ...configProvider, - models: { ...configProvider.models, ...custom("config-provider", "dup-model").models }, + models: { + ...configProvider.models, + ...custom("config-provider", "dup-model").models, + ...custom("config-provider", "codestral-latest", [], "Codestral (latest)").models, + }, }, "sub-provider": subProvider, }, @@ -800,11 +808,29 @@ describe("tool.task model resolution", () => { ), ) + it.live("bare subagent model id resolves when the display name differs", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "codestral-latest" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual({ + providerID: ProviderV2.ID.make("config-provider"), + modelID: ModelV2.ID.make("codestral-latest"), + }) + expect(result.variant).toBeUndefined() + }), + ), + ), + ) + it.live("unknown bare subagent model name falls back to the parent model", () => run({ agent: "worker", variant: inherited, - config: { subagent_model: "codestral (latest)" }, + config: { subagent_model: "missing-display-name" }, }).pipe( Effect.tap((result) => Effect.sync(() => { From 4abafa9deebfbdff32d0659308ddbc28dd247598 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:55:49 +0000 Subject: [PATCH 021/353] fix(cli): resolve slash-containing task model names from the catalog Addresses follow-up review findings: - consult the catalog by qualified key, model id, then display name before treating a slash as a provider separator, so ids/names that contain '/' (e.g. moonshotai/kimi-k2.5) resolve instead of being parsed as a provider - cover display-name resolution and slash-containing id/name regressions Preference coverage already added: shared-model now exists on two providers. --- packages/opencode/src/kilocode/tool/task.ts | 25 +++++---- .../test/kilocode/tool-task-model.test.ts | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index cc323678e8b..776b1656b33 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -182,12 +182,13 @@ export namespace KiloTask { } /** - * Resolve a configured model reference. A qualified `provider/model` value - * is parsed directly. A bare value (no `/`) is matched against the provider - * catalog by model ID first, then by display name, preferring the parent - * session's provider so a custom provider's display name (e.g. - * "codestral (latest)") resolves to that provider's model instead of failing - * with an empty model ID. Unresolvable or ambiguous names log a warning and + * Resolve a configured model reference. A value is matched against the + * provider catalog by qualified `provider/model` key, model ID, then display + * name, preferring the parent session's provider so a custom provider's + * display name (e.g. "codestral (latest)") resolves to that provider's model + * instead of failing with an empty model ID. A model ID or display name may + * itself contain `/`, so the catalog is consulted before a slash is treated + * as a provider separator. Unresolvable or ambiguous names log a warning and * resolve to undefined, so the caller falls back to the next model source. */ const resolve = Effect.fn("KiloTask.resolve")(function* (input: { @@ -197,20 +198,22 @@ export namespace KiloTask { }) { const value = input.value.trim() if (!value) return undefined - if (value.includes("/")) return parse(value) const providers = yield* input.provider.list() const all = Object.values(providers).flatMap((provider) => Object.values(provider.models).map((model) => ({ providerID: provider.id, model })), ) const query = value.toLowerCase() + const keys = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query) const ids = all.filter((item) => item.model.id.toLowerCase() === query) - const named = ids.length ? ids : all.filter((item) => item.model.name.toLowerCase() === query) - if (named.length === 0) { + const named = all.filter((item) => item.model.name.toLowerCase() === query) + const matched = keys.length ? keys : ids.length ? ids : named + if (matched.length === 0) { + if (value.includes("/")) return parse(value) log.warn("task model name is not available", { value }) return undefined } - const preferred = named.filter((item) => item.providerID === input.preferred) - const pool = preferred.length ? preferred : named + const preferred = matched.filter((item) => item.providerID === input.preferred) + const pool = preferred.length ? preferred : matched if (pool.length > 1) { log.warn("task model name is ambiguous", { value, diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 2c93dd9a594..be77cf0babb 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -117,6 +117,7 @@ const catalog = { ...configProvider.models, ...custom("config-provider", "dup-model").models, ...custom("config-provider", "codestral-latest", [], "Codestral (latest)").models, + ...custom("config-provider", "org/model", [], "Org/Model").models, }, }, "sub-provider": subProvider, @@ -826,6 +827,60 @@ describe("tool.task model resolution", () => { ), ) + it.live("bare subagent display name resolves to the matching provider model", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "Codestral (latest)" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual({ + providerID: ProviderV2.ID.make("config-provider"), + modelID: ModelV2.ID.make("codestral-latest"), + }) + expect(result.variant).toBeUndefined() + }), + ), + ), + ) + + it.live("model id containing a slash resolves before provider parsing", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "org/model" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual({ + providerID: ProviderV2.ID.make("config-provider"), + modelID: ModelV2.ID.make("org/model"), + }) + expect(result.variant).toBeUndefined() + }), + ), + ), + ) + + it.live("display name containing a slash resolves before provider parsing", () => + run({ + agent: "worker", + variant: inherited, + config: { subagent_model: "Org/Model" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual({ + providerID: ProviderV2.ID.make("config-provider"), + modelID: ModelV2.ID.make("org/model"), + }) + expect(result.variant).toBeUndefined() + }), + ), + ), + ) + it.live("unknown bare subagent model name falls back to the parent model", () => run({ agent: "worker", From 348a46fd0ad1717462cd7d5746488bd8d4a92a22 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:03:37 +0000 Subject: [PATCH 022/353] test(cli): cover slash display-name branch in task model tests The previous slash display-name fixture (Org/Model) lowercased to the model id, so it exercised the id branch. Use a name distinct from its id (Vendor / Model) so the display-name filter is actually reached. --- packages/opencode/test/kilocode/tool-task-model.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index be77cf0babb..cbf581a6535 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -118,6 +118,7 @@ const catalog = { ...custom("config-provider", "dup-model").models, ...custom("config-provider", "codestral-latest", [], "Codestral (latest)").models, ...custom("config-provider", "org/model", [], "Org/Model").models, + ...custom("config-provider", "slash-name-model", [], "Vendor / Model").models, }, }, "sub-provider": subProvider, @@ -867,13 +868,13 @@ describe("tool.task model resolution", () => { run({ agent: "worker", variant: inherited, - config: { subagent_model: "Org/Model" }, + config: { subagent_model: "Vendor / Model" }, }).pipe( Effect.tap((result) => Effect.sync(() => { expect(result.prompt).toEqual({ providerID: ProviderV2.ID.make("config-provider"), - modelID: ModelV2.ID.make("org/model"), + modelID: ModelV2.ID.make("slash-name-model"), }) expect(result.variant).toBeUndefined() }), From 1f093ffed0ae99d91bbbc949eac6d4f180d39cfc Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:57:09 +0000 Subject: [PATCH 023/353] fix(cli): log structural diagnostics on ModelMessage[] schema failure The AI SDK rejects an assembled request with a bare 'The messages do not match the ModelMessage[] schema.' error; the ZodError cause with the exact schema paths was never surfaced, so root-causing the failure (issue #13185) was impossible. Thread the prepared messages to the AI SDK stream adapter and, on failure, log a redacted structural diagnostic: schema issue paths with expected/ received types, per-message part sequences, tool-call/result pairing, and provider option key names. No content values are logged. Kilo-owned diagnostics live in src/kilocode/session/message-diagnostics.ts; the shared llm.ts change is kilocode_change-marked. --- .changeset/kilocode-message-diagnostics.md | 5 + .../kilocode/session/message-diagnostics.ts | 146 ++++++++++++++ packages/opencode/src/session/llm.ts | 10 +- .../test/kilocode/message-diagnostics.test.ts | 183 ++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .changeset/kilocode-message-diagnostics.md create mode 100644 packages/opencode/src/kilocode/session/message-diagnostics.ts create mode 100644 packages/opencode/test/kilocode/message-diagnostics.test.ts diff --git a/.changeset/kilocode-message-diagnostics.md b/.changeset/kilocode-message-diagnostics.md new file mode 100644 index 00000000000..63029fb14e8 --- /dev/null +++ b/.changeset/kilocode-message-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Log structural diagnostics (message part sequences, tool-call/result pairing, schema issue paths) when the AI SDK rejects an assembled request, instead of failing with a bare `ModelMessage[] schema` error. diff --git a/packages/opencode/src/kilocode/session/message-diagnostics.ts b/packages/opencode/src/kilocode/session/message-diagnostics.ts new file mode 100644 index 00000000000..995f603fd63 --- /dev/null +++ b/packages/opencode/src/kilocode/session/message-diagnostics.ts @@ -0,0 +1,146 @@ +import type { ModelMessage } from "ai" +import { Log } from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "kilocode.message.diagnostics" }) + +type ZodIssue = { + path?: (string | number)[] + message?: string + code?: string + expected?: unknown + received?: unknown + errors?: Array +} + +export namespace KiloMessageDiagnostics { + export type MessageShape = { + index: number + role: string + parts: Array> + providerOptions?: string[] + } + + export type ToolPairing = { + totalToolCalls: number + matchedResultIds: string[] + unmatchedCallIds: string[] + orphanResultIds: string[] + } + + export function schemaIssues(err: unknown): string[] { + const seen = new Set() + const walk = (e: unknown): string[] => { + if (!e || typeof e !== "object" || seen.has(e)) return [] + seen.add(e) + const obj = e as Record + if (Array.isArray(obj.issues)) { + return flattenIssues(obj.issues as ZodIssue[]) + } + const cause = obj.cause + if (cause) return walk(cause) + return [] + } + return walk(err) + } + + function flattenIssues(issues: ZodIssue[]): string[] { + const result: string[] = [] + for (const issue of issues) { + const path = issue.path?.join(".") ?? "?" + const msg = issue.message ?? "" + const code = issue.code ?? "" + const expected = short(issue.expected) + const received = short(issue.received) + result.push(`${path}: ${msg} [${code}] expected=${expected} received=${received}`) + // zod/v4 unions nest their member failures under `errors`; recurse so the + // deep schema path (e.g. `content.0.text`) is not lost behind `invalid_union` + if (issue.errors) { + for (const sub of issue.errors) { + const nested = Array.isArray(sub) ? sub : [sub] + for (const inner of nested) { + result.push( + ...flattenIssues([{ ...inner, path: [...(issue.path ?? []), ...(inner.path ?? [])] }]), + ) + } + } + } + } + return result + } + + function short(value: unknown): string { + if (value === undefined) return "" + const str = typeof value === "string" ? value : safeJSON(value) + return str.length > 80 ? `${str.slice(0, 80)}…` : str + } + + // Zod issue `expected`/`received` are schema type names in practice, but + // custom issues can carry arbitrary values. Diagnostics must never throw on + // the failure path, so circular/unserializable values degrade to a placeholder. + function safeJSON(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value) + } catch { + return "[unserializable]" + } + } + + export function messageShape(msgs: readonly ModelMessage[]): MessageShape[] { + return msgs.map((msg, i) => { + const parts = Array.isArray(msg.content) ? msg.content : [] + return { + index: i, + role: msg.role, + parts: parts.map((p) => { + if (p.type === "tool-call") return { type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName } + if (p.type === "tool-result") return { type: "tool-result", toolCallId: p.toolCallId, toolName: p.toolName } + if (p.type === "reasoning") return { type: "reasoning" } + if (p.type === "text") return { type: "text" } + if (p.type === "file") return { type: "file", mediaType: p.mediaType } + return { type: p.type } + }), + providerOptions: msg.providerOptions ? Object.keys(msg.providerOptions) : undefined, + } + }) + } + + export function toolPairing(msgs: readonly ModelMessage[]): ToolPairing { + const callIds = new Set() + const resultIds = new Set() + for (const msg of msgs) { + if (!Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call") callIds.add(part.toolCallId) + if (part.type === "tool-result") resultIds.add(part.toolCallId) + } + } + const matched = [...callIds].filter((id) => resultIds.has(id)) + const unmatched = [...callIds].filter((id) => !resultIds.has(id)) + const orphan = [...resultIds].filter((id) => !callIds.has(id)) + return { + totalToolCalls: callIds.size, + matchedResultIds: matched, + unmatchedCallIds: unmatched, + orphanResultIds: orphan, + } + } + + export function describe(err: unknown, msgs: readonly ModelMessage[]) { + return { + issues: schemaIssues(err), + messages: messageShape(msgs), + pairing: toolPairing(msgs), + } + } + + export function reportModelMessageError( + err: unknown, + msgs: readonly ModelMessage[], + ): ReturnType | undefined { + const mismatch = err instanceof Error && err.message.includes("do not match the ModelMessage[] schema") + if (!mismatch) return undefined + const diagnostic = describe(err, msgs) + log.error("ModelMessage[] schema validation failed", diagnostic) + return diagnostic + } +} diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index e90d60f98ff..e5711aa5969 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -30,6 +30,7 @@ import { KiloSession } from "@/kilocode/session" import { KiloLLM } from "@/kilocode/session/llm" import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { KiloToolSchema } from "@/kilocode/session/tool-schema" +import { KiloMessageDiagnostics } from "@/kilocode/session/message-diagnostics" import { SessionExport } from "@/kilocode/session-export" import { getActiveOrg } from "@/kilocode/session-export/eligibility" import { normalizeUsageForExport, observeFullStreamForExport } from "@/kilocode/session-export/llm" @@ -436,7 +437,7 @@ const live: Layer.Layer< }) // kilocode_change end // kilocode_change start - capture eligible session export request completion off the stream path - if (!exportable) return { type: "ai-sdk" as const, result } + if (!exportable) return { type: "ai-sdk" as const, result, messages: prepared.messages } return { type: "ai-sdk" as const, result: { @@ -450,6 +451,7 @@ const live: Layer.Layer< retries: input.retries ?? 0, }), }, + messages: prepared.messages, } // kilocode_change end }) @@ -474,6 +476,12 @@ const live: Layer.Layer< e instanceof Error ? e : new Error(String(e)), ).pipe( Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)), + // kilocode_change start - emit structural diagnostics when the AI SDK + // rejects the assembled ModelMessage[] before dispatch (#13185) + Stream.tapError((e) => + Effect.sync(() => KiloMessageDiagnostics.reportModelMessageError(e, result.messages)), + ), + // kilocode_change end Stream.flatMap((events) => Stream.fromIterable(events)), ) }), diff --git a/packages/opencode/test/kilocode/message-diagnostics.test.ts b/packages/opencode/test/kilocode/message-diagnostics.test.ts new file mode 100644 index 00000000000..366f938170b --- /dev/null +++ b/packages/opencode/test/kilocode/message-diagnostics.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { z } from "zod/v4" +import type { ModelMessage } from "ai" +import { KiloMessageDiagnostics } from "@/kilocode/session/message-diagnostics" +import { LLMAISDK } from "@/session/llm/ai-sdk" +import { Effect, Exit } from "effect" + +const messages: ModelMessage[] = [ + { + role: "user", + content: "summarize the repo", + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "on it" }, + { type: "tool-call", toolCallId: "call-1", toolName: "grep", input: { pattern: "x" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "grep", output: { type: "text", value: "hit" } }], + }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-2", toolName: "write", input: { path: "a.ts" } }], + }, +] + +describe("messageShape", () => { + test("summarizes roles and part sequences without content", () => { + const shape = KiloMessageDiagnostics.messageShape(messages) + expect(shape).toEqual([ + { index: 0, role: "user", parts: [], providerOptions: undefined }, + { + index: 1, + role: "assistant", + parts: [ + { type: "reasoning" }, + { type: "text" }, + { type: "tool-call", toolCallId: "call-1", toolName: "grep" }, + ], + providerOptions: undefined, + }, + { + index: 2, + role: "tool", + parts: [{ type: "tool-result", toolCallId: "call-1", toolName: "grep" }], + providerOptions: undefined, + }, + { + index: 3, + role: "assistant", + parts: [{ type: "tool-call", toolCallId: "call-2", toolName: "write" }], + providerOptions: undefined, + }, + ]) + }) + + test("lists provider option keys, not values", () => { + const shape = KiloMessageDiagnostics.messageShape([ + { + role: "assistant", + content: [{ type: "text", text: "hi" }], + providerOptions: { cacheControl: { type: "ephemeral" } }, + }, + ]) + expect(shape[0]?.providerOptions).toEqual(["cacheControl"]) + }) +}) + +describe("toolPairing", () => { + test("flags unmatched calls and orphan results", () => { + const pairing = KiloMessageDiagnostics.toolPairing(messages) + expect(pairing.totalToolCalls).toBe(2) + expect(pairing.matchedResultIds).toEqual(["call-1"]) + expect(pairing.unmatchedCallIds).toEqual(["call-2"]) + expect(pairing.orphanResultIds).toEqual([]) + }) + + test("flags orphan results without a matching call", () => { + const pairing = KiloMessageDiagnostics.toolPairing([ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "ghost", toolName: "read", output: { type: "text", value: "x" } }], + }, + ]) + expect(pairing.totalToolCalls).toBe(0) + expect(pairing.orphanResultIds).toEqual(["ghost"]) + }) +}) + +describe("schemaIssues", () => { + test("extracts issue paths from a ZodError cause chain", () => { + const schema = z.object({ + role: z.literal("assistant"), + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }), + ), + }) + const parsed = schema.safeParse({ role: "assistant", content: [{ type: "text", text: 42 }] }) + expect(parsed.success).toBe(false) + if (parsed.success) return + + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + error.cause = parsed.error + const issues = KiloMessageDiagnostics.schemaIssues(error) + expect(issues.length).toBeGreaterThan(0) + expect(issues[0]).toContain("content.0.text") + }) + + test("recurses into union-nested errors to keep deep paths", () => { + // The AI SDK validates against a union of message shapes, so zod/v4 reports + // `invalid_union` with member failures nested under `errors`. The deep path + // must survive the recursion. + const schema = z.union([ + z.object({ role: z.literal("user"), content: z.string() }), + z.object({ + role: z.literal("assistant"), + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }), + ), + }), + ]) + const parsed = schema.safeParse({ + role: "assistant", + content: [{ type: "tool-call", toolCallId: 42 }], + }) + expect(parsed.success).toBe(false) + if (parsed.success) return + + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + error.cause = parsed.error + const issues = KiloMessageDiagnostics.schemaIssues(error) + expect(issues.some((issue) => issue.includes("content.0"))).toBe(true) + }) + + test("returns nothing for unrelated errors", () => { + expect(KiloMessageDiagnostics.schemaIssues(new Error("boom"))).toEqual([]) + expect(KiloMessageDiagnostics.schemaIssues(undefined)).toEqual([]) + }) +}) + +describe("reportModelMessageError", () => { + test("silently ignores non-schema errors", () => { + const out = KiloMessageDiagnostics.reportModelMessageError(new Error("rate limited"), messages) + expect(out).toBeUndefined() + }) + + test("returns the diagnostic for the ModelMessage[] schema mismatch", () => { + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const out = KiloMessageDiagnostics.reportModelMessageError(error, messages) + expect(out).toBeDefined() + expect(out?.messages.length).toBe(4) + }) +}) + +describe("LLMAISDK.toLLMEvents error-part mechanism", () => { + test("converts an error part into the stream failure tapError observes", async () => { + const state = LLMAISDK.adapterState() + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const effect = LLMAISDK.toLLMEvents(state, { type: "error", error }) + const exit = await Effect.runPromise(Effect.exit(effect)) + expect(Exit.isFailure(exit)).toBe(true) + }) +}) + +describe("describe", () => { + test("composes issues, shapes and pairing", () => { + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const out = KiloMessageDiagnostics.describe(error, messages) + expect(out.issues).toEqual([]) + expect(out.messages.length).toBe(4) + expect(out.pairing.unmatchedCallIds).toEqual(["call-2"]) + }) +}) From 6c894a552d4fee067377698d7bd08781025c4e99 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:09:33 +0000 Subject: [PATCH 024/353] fix(cli): harden diagnostics against pathological zod issue shapes Guard the failure path the diagnostics target: a throw inside the Stream.tapError effect would replace the original schema error, masking the very failure being diagnosed. Array-isArray the issue path before joining, and cap the union recursion depth so cycles degrade to a truncated report instead of a RangeError. --- .../kilocode/session/message-diagnostics.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilocode/session/message-diagnostics.ts b/packages/opencode/src/kilocode/session/message-diagnostics.ts index 995f603fd63..cdfb4b39abe 100644 --- a/packages/opencode/src/kilocode/session/message-diagnostics.ts +++ b/packages/opencode/src/kilocode/session/message-diagnostics.ts @@ -43,24 +43,28 @@ export namespace KiloMessageDiagnostics { return walk(err) } - function flattenIssues(issues: ZodIssue[]): string[] { + function flattenIssues(issues: ZodIssue[], depth = 0): string[] { const result: string[] = [] for (const issue of issues) { - const path = issue.path?.join(".") ?? "?" + const path = Array.isArray(issue.path) ? issue.path.join(".") : "?" const msg = issue.message ?? "" const code = issue.code ?? "" const expected = short(issue.expected) const received = short(issue.received) result.push(`${path}: ${msg} [${code}] expected=${expected} received=${received}`) // zod/v4 unions nest their member failures under `errors`; recurse so the - // deep schema path (e.g. `content.0.text`) is not lost behind `invalid_union` - if (issue.errors) { + // deep schema path (e.g. `content.0.text`) is not lost behind `invalid_union`. + // Depth is capped so a pathological cycle degrades instead of throwing — + // diagnostics on the failure path must never mask the original error. + if (issue.errors && depth < 8) { for (const sub of issue.errors) { const nested = Array.isArray(sub) ? sub : [sub] for (const inner of nested) { - result.push( - ...flattenIssues([{ ...inner, path: [...(issue.path ?? []), ...(inner.path ?? [])] }]), - ) + const nextPath = + Array.isArray(issue.path) && Array.isArray(inner.path) + ? [...issue.path, ...inner.path] + : inner.path + result.push(...flattenIssues([{ ...inner, path: nextPath }], depth + 1)) } } } From 3b5a4de2209debb7144470382d7b378d221eaf22 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:44:59 +0000 Subject: [PATCH 025/353] fix(cli): guard diagnostics against malformed message parts Treat each runtime part as unknown when summarizing a failed ModelMessage[] validation: null/primitive entries degrade to a safe placeholder instead of throwing while the tapError handler processes the original failure. String content is now flagged via contentKind so string-vs-array-vs-absent mismatches stay visible, and toolPairing skips non-object parts the same way. --- .../kilocode/session/message-diagnostics.ts | 44 +++++++++++++----- .../test/kilocode/message-diagnostics.test.ts | 45 ++++++++++++++++++- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/kilocode/session/message-diagnostics.ts b/packages/opencode/src/kilocode/session/message-diagnostics.ts index cdfb4b39abe..054f75fb57a 100644 --- a/packages/opencode/src/kilocode/session/message-diagnostics.ts +++ b/packages/opencode/src/kilocode/session/message-diagnostics.ts @@ -16,6 +16,7 @@ export namespace KiloMessageDiagnostics { export type MessageShape = { index: number role: string + contentKind: "string" | "array" | "absent" | "other" parts: Array> providerOptions?: string[] } @@ -91,31 +92,52 @@ export namespace KiloMessageDiagnostics { export function messageShape(msgs: readonly ModelMessage[]): MessageShape[] { return msgs.map((msg, i) => { - const parts = Array.isArray(msg.content) ? msg.content : [] + // The messages failed schema validation, so runtime parts can be anything: + // null, primitives, or objects missing expected fields. Diagnostics on the + // failure path must never throw, so each part is treated as unknown and + // degraded to a safe placeholder before its fields are read. + const content = (msg as { content?: unknown }).content + const isArray = Array.isArray(content) + const contentKind = typeof content === "string" + ? "string" + : isArray + ? "array" + : content === undefined || content === null + ? "absent" + : "other" return { index: i, role: msg.role, - parts: parts.map((p) => { - if (p.type === "tool-call") return { type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName } - if (p.type === "tool-result") return { type: "tool-result", toolCallId: p.toolCallId, toolName: p.toolName } - if (p.type === "reasoning") return { type: "reasoning" } - if (p.type === "text") return { type: "text" } - if (p.type === "file") return { type: "file", mediaType: p.mediaType } - return { type: p.type } - }), + contentKind, + parts: isArray ? (content as unknown[]).map(partShape) : [], providerOptions: msg.providerOptions ? Object.keys(msg.providerOptions) : undefined, } }) } + function partShape(p: unknown): Record { + if (!p || typeof p !== "object") return { type: String(p) } + const part = p as Record + if (part.type === "tool-call") + return { type: "tool-call", toolCallId: String(part.toolCallId), toolName: String(part.toolName) } + if (part.type === "tool-result") + return { type: "tool-result", toolCallId: String(part.toolCallId), toolName: String(part.toolName) } + if (part.type === "reasoning") return { type: "reasoning" } + if (part.type === "text") return { type: "text" } + if (part.type === "file") + return { type: "file", mediaType: part.mediaType === undefined ? undefined : String(part.mediaType) } + return { type: String(part.type) } + } + export function toolPairing(msgs: readonly ModelMessage[]): ToolPairing { const callIds = new Set() const resultIds = new Set() for (const msg of msgs) { if (!Array.isArray(msg.content)) continue for (const part of msg.content) { - if (part.type === "tool-call") callIds.add(part.toolCallId) - if (part.type === "tool-result") resultIds.add(part.toolCallId) + if (!part || typeof part !== "object") continue + if (part.type === "tool-call" && typeof part.toolCallId === "string") callIds.add(part.toolCallId) + if (part.type === "tool-result" && typeof part.toolCallId === "string") resultIds.add(part.toolCallId) } } const matched = [...callIds].filter((id) => resultIds.has(id)) diff --git a/packages/opencode/test/kilocode/message-diagnostics.test.ts b/packages/opencode/test/kilocode/message-diagnostics.test.ts index 366f938170b..13803a13a85 100644 --- a/packages/opencode/test/kilocode/message-diagnostics.test.ts +++ b/packages/opencode/test/kilocode/message-diagnostics.test.ts @@ -32,10 +32,11 @@ describe("messageShape", () => { test("summarizes roles and part sequences without content", () => { const shape = KiloMessageDiagnostics.messageShape(messages) expect(shape).toEqual([ - { index: 0, role: "user", parts: [], providerOptions: undefined }, + { index: 0, role: "user", contentKind: "string", parts: [], providerOptions: undefined }, { index: 1, role: "assistant", + contentKind: "array", parts: [ { type: "reasoning" }, { type: "text" }, @@ -46,12 +47,14 @@ describe("messageShape", () => { { index: 2, role: "tool", + contentKind: "array", parts: [{ type: "tool-result", toolCallId: "call-1", toolName: "grep" }], providerOptions: undefined, }, { index: 3, role: "assistant", + contentKind: "array", parts: [{ type: "tool-call", toolCallId: "call-2", toolName: "write" }], providerOptions: undefined, }, @@ -68,6 +71,35 @@ describe("messageShape", () => { ]) expect(shape[0]?.providerOptions).toEqual(["cacheControl"]) }) + + test("distinguishes absent content from string content", () => { + const shape = KiloMessageDiagnostics.messageShape([ + { role: "user", content: "plain text" } as ModelMessage, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + { role: "user" } as ModelMessage, + ]) + expect(shape.map((s) => s.contentKind)).toEqual(["string", "array", "absent"]) + expect(shape[0]?.parts).toEqual([]) + expect(shape[2]?.parts).toEqual([]) + }) + + test("degrades malformed runtime parts instead of throwing", () => { + // The messages failed schema validation, so parts may be null/primitives at + // runtime. Diagnostics on the failure path must never throw. + const malformed = { + role: "assistant", + content: [null, 42, "bare-string", { type: "tool-call" }, { type: "text" }], + } as unknown as ModelMessage + const shape = KiloMessageDiagnostics.messageShape([malformed]) + expect(shape[0]?.contentKind).toBe("array") + expect(shape[0]?.parts).toEqual([ + { type: "null" }, + { type: "42" }, + { type: "bare-string" }, + { type: "tool-call", toolCallId: "undefined", toolName: "undefined" }, + { type: "text" }, + ]) + }) }) describe("toolPairing", () => { @@ -89,6 +121,17 @@ describe("toolPairing", () => { expect(pairing.totalToolCalls).toBe(0) expect(pairing.orphanResultIds).toEqual(["ghost"]) }) + + test("skips malformed parts instead of throwing", () => { + const pairing = KiloMessageDiagnostics.toolPairing([ + { + role: "assistant", + content: [null, "nope", { type: "tool-call", toolCallId: "ok" }], + } as unknown as ModelMessage, + ]) + expect(pairing.totalToolCalls).toBe(1) + expect(pairing.unmatchedCallIds).toEqual(["ok"]) + }) }) describe("schemaIssues", () => { From 51b803df73e7f62c9abbf096acaadac703ad76dc Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sun, 27 Sep 2026 04:28:13 -0700 Subject: [PATCH 026/353] ci: trigger rebuild for test flakiness From d99cdbbe2377087fa4abc10d4cda1a4816c642c2 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Mon, 28 Sep 2026 13:21:09 -0700 Subject: [PATCH 027/353] fix(cli): harden message diagnostics against malformed envelopes --- .../kilocode/session/message-diagnostics.ts | 38 ++++++++++++------- .../test/kilocode/message-diagnostics.test.ts | 11 ++++-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/kilocode/session/message-diagnostics.ts b/packages/opencode/src/kilocode/session/message-diagnostics.ts index 054f75fb57a..015d7a9c03f 100644 --- a/packages/opencode/src/kilocode/session/message-diagnostics.ts +++ b/packages/opencode/src/kilocode/session/message-diagnostics.ts @@ -90,13 +90,18 @@ export namespace KiloMessageDiagnostics { } } - export function messageShape(msgs: readonly ModelMessage[]): MessageShape[] { - return msgs.map((msg, i) => { - // The messages failed schema validation, so runtime parts can be anything: + export function messageShape(msgs: readonly unknown[]): MessageShape[] { + return msgs.map((entry, i) => { + const msg = (entry && typeof entry === "object" ? entry : {}) as { + role?: unknown + content?: unknown + providerOptions?: unknown + } + // The messages failed schema validation, so runtime entries can be anything: // null, primitives, or objects missing expected fields. Diagnostics on the - // failure path must never throw, so each part is treated as unknown and - // degraded to a safe placeholder before its fields are read. - const content = (msg as { content?: unknown }).content + // failure path must never throw, so the envelope and each part are treated + // as unknown and degraded to safe placeholders before fields are read. + const content = msg.content const isArray = Array.isArray(content) const contentKind = typeof content === "string" ? "string" @@ -107,10 +112,13 @@ export namespace KiloMessageDiagnostics { : "other" return { index: i, - role: msg.role, + role: typeof msg.role === "string" ? msg.role : String(msg.role), contentKind, parts: isArray ? (content as unknown[]).map(partShape) : [], - providerOptions: msg.providerOptions ? Object.keys(msg.providerOptions) : undefined, + providerOptions: + msg.providerOptions && typeof msg.providerOptions === "object" + ? Object.keys(msg.providerOptions) + : undefined, } }) } @@ -129,12 +137,14 @@ export namespace KiloMessageDiagnostics { return { type: String(part.type) } } - export function toolPairing(msgs: readonly ModelMessage[]): ToolPairing { + export function toolPairing(msgs: readonly unknown[]): ToolPairing { const callIds = new Set() const resultIds = new Set() - for (const msg of msgs) { - if (!Array.isArray(msg.content)) continue - for (const part of msg.content) { + for (const entry of msgs) { + if (!entry || typeof entry !== "object") continue + const content = (entry as { content?: unknown }).content + if (!Array.isArray(content)) continue + for (const part of content) { if (!part || typeof part !== "object") continue if (part.type === "tool-call" && typeof part.toolCallId === "string") callIds.add(part.toolCallId) if (part.type === "tool-result" && typeof part.toolCallId === "string") resultIds.add(part.toolCallId) @@ -151,7 +161,7 @@ export namespace KiloMessageDiagnostics { } } - export function describe(err: unknown, msgs: readonly ModelMessage[]) { + export function describe(err: unknown, msgs: readonly unknown[]) { return { issues: schemaIssues(err), messages: messageShape(msgs), @@ -161,7 +171,7 @@ export namespace KiloMessageDiagnostics { export function reportModelMessageError( err: unknown, - msgs: readonly ModelMessage[], + msgs: readonly unknown[], ): ReturnType | undefined { const mismatch = err instanceof Error && err.message.includes("do not match the ModelMessage[] schema") if (!mismatch) return undefined diff --git a/packages/opencode/test/kilocode/message-diagnostics.test.ts b/packages/opencode/test/kilocode/message-diagnostics.test.ts index 13803a13a85..49001c4e492 100644 --- a/packages/opencode/test/kilocode/message-diagnostics.test.ts +++ b/packages/opencode/test/kilocode/message-diagnostics.test.ts @@ -83,14 +83,14 @@ describe("messageShape", () => { expect(shape[2]?.parts).toEqual([]) }) - test("degrades malformed runtime parts instead of throwing", () => { - // The messages failed schema validation, so parts may be null/primitives at - // runtime. Diagnostics on the failure path must never throw. + test("degrades malformed runtime entries instead of throwing", () => { + // The messages failed schema validation, so entries may be null/primitives + // at runtime. Diagnostics on the failure path must never throw. const malformed = { role: "assistant", content: [null, 42, "bare-string", { type: "tool-call" }, { type: "text" }], } as unknown as ModelMessage - const shape = KiloMessageDiagnostics.messageShape([malformed]) + const shape = KiloMessageDiagnostics.messageShape([malformed, null, 42, "bare"] as unknown as ModelMessage[]) expect(shape[0]?.contentKind).toBe("array") expect(shape[0]?.parts).toEqual([ { type: "null" }, @@ -99,6 +99,9 @@ describe("messageShape", () => { { type: "tool-call", toolCallId: "undefined", toolName: "undefined" }, { type: "text" }, ]) + expect(shape[1]).toMatchObject({ index: 1, role: "undefined", contentKind: "absent", parts: [] }) + expect(shape[2]).toMatchObject({ index: 2, role: "undefined", contentKind: "absent", parts: [] }) + expect(shape[3]).toMatchObject({ index: 3, role: "undefined", contentKind: "absent", parts: [] }) }) }) From 07848e1a2ad60beeb7983cc185fdcae7faf4f7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=A8=E5=8D=93?= Date: Tue, 29 Sep 2026 14:30:45 +0800 Subject: [PATCH 028/353] fix(cli): surface terminal compaction failures --- .changeset/fix-compaction-terminal-errors.md | 5 + .../src/kilocode/session/compaction-chunks.ts | 3 +- .../opencode/src/kilocode/session/prompt.ts | 2 +- packages/opencode/src/session/compaction.ts | 13 ++- .../kilocode/session-compaction-cap.test.ts | 91 ++++++++++++++++++- .../session-compaction-chunks.test.ts | 43 ++++++--- 6 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-compaction-terminal-errors.md diff --git a/.changeset/fix-compaction-terminal-errors.md b/.changeset/fix-compaction-terminal-errors.md new file mode 100644 index 00000000000..2f53770f086 --- /dev/null +++ b/.changeset/fix-compaction-terminal-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Report empty summaries and unrecoverable context overflow during compaction instead of silently stopping. Preserve conversation history and explain when to retry compaction or start a new session. diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 0ae16f9970b..8575edd9a08 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -25,7 +25,8 @@ const DEPTH = 3 const OUTPUT = 2_048 export namespace KiloCompactionChunks { - export const EMPTY_SUMMARY = "Compaction did not run: the model returned an empty summary. Retry with /compact." + export const EMPTY_SUMMARY = + "Compaction did not run: the model returned an empty summary. Retry with /compact. If it keeps failing, start a new session to continue." type Chunk = { index: number diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 5fe197c9b31..891758a4c44 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -605,7 +605,7 @@ export namespace KiloSessionPrompt { }) { if (input.attempts < MAX_COMPACTION_ATTEMPTS) return { exhausted: false as const } const error = new MessageV2.ContextOverflowError({ - message: `Compaction exhausted: context still exceeds model limits after ${MAX_COMPACTION_ATTEMPTS} attempts`, + message: `Compaction exhausted: context still exceeds model limits after ${MAX_COMPACTION_ATTEMPTS} attempts. Start a new session to continue.`, }).toObject() input.closeReasons.set(input.sessionID, "error") if (input.message) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ee4fa7a8d46..a1db53ace7a 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -537,11 +537,16 @@ const layer = Layer.effect( // kilocode_change end processor.message.error = new SessionV1.ContextOverflowError({ message: replay - ? "Conversation history too large to compact - exceeds model context limit" - : "Session too large to compact - context exceeds model limit even after stripping media", + ? "Conversation history too large to compact - exceeds model context limit. Start a new session to continue." // kilocode_change + : "Session too large to compact - context exceeds model limit even after stripping media. Start a new session to continue.", // kilocode_change }).toObject() processor.message.finish = "error" + processor.message.time.completed = Date.now() // kilocode_change yield* session.updateMessage(processor.message) + // kilocode_change start - publish the terminal failure after all compaction recovery paths + yield* Effect.logError("compaction failed", { "session.id": input.sessionID, error: processor.message.error }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: processor.message.error }) + // kilocode_change end return "stop" } @@ -707,6 +712,10 @@ const layer = Layer.effect( text: KiloCompactionChunks.EMPTY_SUMMARY, }) } + if (empty) { + yield* Effect.logError("compaction failed", { "session.id": input.sessionID, error }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error }) + } return "stop" } if (fallback === "continue") { diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index e8fa8f40143..0a41a73976b 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -55,7 +55,7 @@ import * as Log from "@opencode-ai/core/util/log" import { MemoryService } from "@kilocode/kilo-memory/effect/service" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { TestLLMServer } from "../lib/llm-server" +import { reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) @@ -132,6 +132,7 @@ const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: Memor const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) const root = LayerNode.group([ SessionPrompt.node, + SessionCompaction.node, Session.node, SessionProjector.node, MessageV2.node, @@ -233,6 +234,78 @@ function providerCfg(url: string) { const overflowBody = { type: "error", error: { code: "context_length_exceeded" } } describe("session compaction cap", () => { + for (const scenario of [ + { name: "automatic", auto: true, text: "please overflow", blank: "" }, + { name: "chunked", auto: true, text: "x".repeat(400_000), blank: "" }, + { name: "manual", auto: false, text: "please compact", blank: "" }, + { name: "whitespace", auto: true, text: "please overflow", blank: " \n " }, + ]) { + it.live( + `surfaces ${scenario.name} empty compaction and stops without losing history`, + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compaction = yield* SessionCompaction.Service + const events = yield* EventV2Bridge.Service + const chat = yield* sessions.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const errors: Array = [] + const off = yield* events.listen((evt) => { + if (evt.type !== Session.Event.Error.type) return Effect.void + const data = evt.data as typeof Session.Event.Error.data.Type + if (data.sessionID === chat.id && data.error?.name === "APIError") errors.push(data) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + const closed = yield* Deferred.make() + const unsub = Bus.subscribe(KiloSession.Event.TurnClose, (evt) => { + if (evt.properties.sessionID === chat.id) + Deferred.doneUnsafe(closed, Effect.succeed(evt.properties.reason)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) + + if (scenario.auto) yield* llm.error(400, overflowBody) + yield* llm.push(reply().text(scenario.blank).finish("stop").usage({ input: 0, output: 0 })) + const user = yield* prompt.prompt({ + sessionID: chat.id, + agent: "code", + noReply: true, + parts: [{ type: "text", text: scenario.text }], + }) + if (!scenario.auto) yield* compaction.create({ sessionID: chat.id, agent: "code", model: ref, auto: false }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(yield* Deferred.await(closed).pipe(Effect.timeout("2 seconds"))).toBe("error") + expect(yield* llm.calls).toBe(scenario.auto ? 2 : 1) + expect(yield* (yield* SessionStatus.Service).get(chat.id)).toEqual({ type: "idle" }) + expect(errors).toHaveLength(1) + expect(errors.at(0)?.error).toEqual(result.info.role === "assistant" ? result.info.error : undefined) + expect(result.info.role).toBe("assistant") + if (result.info.role !== "assistant") return + expect(result.info.finish).toBe("error") + expect(result.info.error?.name).toBe("APIError") + if (result.info.error?.name !== "APIError") return + expect(result.info.error.data.message).toContain("empty summary") + expect(result.info.error.data.message).toContain("start a new session") + const history = yield* sessions.messages({ sessionID: chat.id }) + expect(history.some((msg) => msg.info.id === user.info.id)).toBe(true) + expect( + MessageV2.filterCompacted(yield* MessageV2.stream(chat.id)).some((msg) => msg.info.id === user.info.id), + ).toBe(true) + expect(history.filter((msg) => msg.info.role === "assistant" && msg.info.summary)).toHaveLength(1) + expect( + history.flatMap((msg) => msg.parts).some((part) => part.type === "compaction" && part.tail_start_id), + ).toBe(false) + }), + { git: true, config: providerCfg }, + ), + 30_000, + ) + } + it.live( "closes the turn with reason=error after MAX_COMPACTION_ATTEMPTS compactions", () => @@ -258,6 +331,20 @@ describe("session compaction cap", () => { yield* llm.text("summary 3") // 6 yield* llm.error(400, overflowBody) // 7 — exhausts, breaks + const errors: Array = [] + const off = yield* (yield* EventV2Bridge.Service).listen((evt) => { + if (evt.type !== Session.Event.Error.type) return Effect.void + const data = evt.data as typeof Session.Event.Error.data.Type + if ( + data.sessionID === chat.id && + data.error?.name === "ContextOverflowError" && + data.error.data.message.startsWith("Compaction exhausted") + ) + errors.push(data) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + const turnClose = yield* Deferred.make() const unsub = Bus.subscribe(KiloSession.Event.TurnClose, (evt) => { if (evt.properties.sessionID === chat.id) @@ -284,6 +371,8 @@ describe("session compaction cap", () => { expect(result.info.error?.name).toBe("ContextOverflowError") if (result.info.error?.name !== "ContextOverflowError") return expect(result.info.error.data.message).toContain("Compaction exhausted") + expect(result.info.error.data.message).toContain("Start a new session") + expect(errors).toEqual([{ sessionID: chat.id, error: result.info.error }]) }), { git: true, config: providerCfg }, ), diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index 994bb2fc93e..5686fa5587a 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -234,19 +234,22 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error calls, outputs, rt: ManagedRuntime.make( - LayerNode.compile(LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node]), [ - [SessionProcessorModule.SessionProcessor.node, processorNode], - [Provider.node, ProviderTest.fake({ model }).layer], - [Agent.node, agents], - [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], + LayerNode.compile( + LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node, EventV2Bridge.node]), [ - Config.node, - Layer.mock(Config.Service)({ - get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), - directories: () => Effect.succeed([]), - }), + [SessionProcessorModule.SessionProcessor.node, processorNode], + [Provider.node, ProviderTest.fake({ model }).layer], + [Agent.node, agents], + [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], + [ + Config.node, + Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), + }), + ], ], - ]), + ), ), } } @@ -270,6 +273,17 @@ async function failure(error?: MessageV2.Assistant["error"], empty = false) { const { rt } = fakeRuntime(undefined, error, empty) try { + const errors: Array = [] + const off = await rt.runPromise( + EventV2Bridge.Service.use((svc) => + svc.listen((evt) => { + if (evt.type !== SessionNs.Event.Error.type) return Effect.void + const data = evt.data as typeof SessionNs.Event.Error.data.Type + if (data.sessionID === session.id) errors.push(data) + return Effect.void + }), + ), + ) const msgs = await svc.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() @@ -285,7 +299,8 @@ async function failure(error?: MessageV2.Assistant["error"], empty = false) { ) const all = await svc.messages({ sessionID: session.id }) const summary = all.find((msg) => msg.info.role === "assistant" && msg.info.summary) - return { result, summary } + await rt.runPromise(off) + return { result, summary, errors } } finally { await rt.dispose() } @@ -396,19 +411,21 @@ describe("KiloCompactionChunks", () => { }).toObject(), ) + expect(result.errors).toHaveLength(1) expect(result.result).toBe("stop") expect(result.summary?.info.role).toBe("assistant") if (result.summary?.info.role !== "assistant") return expect(result.summary.info.error?.name).toBe("ContextOverflowError") if (result.summary.info.error?.name !== "ContextOverflowError") return expect(result.summary.info.error.data.message).toBe( - "Session too large to compact - context exceeds model limit even after stripping media", + "Session too large to compact - context exceeds model limit even after stripping media. Start a new session to continue.", ) }) test("reports empty chunk worker responses as API errors", async () => { const result = await failure(undefined, true) + expect(result.errors).toHaveLength(1) expect(result.result).toBe("stop") expect(result.summary?.info.role).toBe("assistant") if (result.summary?.info.role !== "assistant") return From 27f72dd1712c679af8bb13fef9daddc52d222126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=A8=E5=8D=93?= Date: Tue, 29 Sep 2026 14:45:05 +0800 Subject: [PATCH 029/353] fix(cli): avoid duplicate compaction overflow errors --- packages/opencode/src/session/processor.ts | 5 +- .../kilocode/session-compaction-cap.test.ts | 68 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index ffd2e2e52cf..1532aae4433 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -918,7 +918,10 @@ const layer = Layer.effect( return } ctx.needsCompaction = true - yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) + // kilocode_change start - let compaction publish overflow only after recovery is exhausted + if (!ctx.assistantMessage.summary) + yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) + // kilocode_change end return } ctx.assistantMessage.error = error diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index 0a41a73976b..cbfd652a117 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -6,7 +6,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Deferred, Effect, Layer } from "effect" +import { Deferred, Effect, Layer, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" @@ -234,6 +234,72 @@ function providerCfg(url: string) { const overflowBody = { type: "error", error: { code: "context_length_exceeded" } } describe("session compaction cap", () => { + for (const scenario of [ + { name: "ordinary overflow", text: "please compact", recovery: false, auto: true, calls: 2 }, + { name: "chunk worker overflow", text: "x".repeat(400_000), recovery: false, auto: true, calls: 1 }, + { name: "successful chunk recovery", text: "please compact", recovery: true, auto: true, calls: 3 }, + { name: "manual recovery with auto disabled", text: "please compact", recovery: true, auto: false, calls: 3 }, + ]) { + it.live( + `publishes only terminal errors during ${scenario.name}`, + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compaction = yield* SessionCompaction.Service + const chat = yield* sessions.create({}) + const errors: Array = [] + const off = yield* (yield* EventV2Bridge.Service).listen((evt) => { + if (evt.type !== Session.Event.Error.type) return Effect.void + const data = Schema.decodeUnknownSync(Session.Event.Error.data)(evt.data) + if (data.sessionID === chat.id) errors.push(data) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + yield* llm.error(400, overflowBody) + if (scenario.recovery) { + yield* llm.text("partial summary") + yield* llm.text("recovered summary") + } + if (!scenario.recovery && scenario.calls === 2) yield* llm.error(400, overflowBody) + const user = yield* prompt.prompt({ + sessionID: chat.id, + agent: "code", + model: ref, + noReply: true, + parts: [{ type: "text", text: scenario.text }], + }) + const result = yield* compaction.process({ + sessionID: chat.id, + parentID: user.info.id, + messages: yield* sessions.messages({ sessionID: chat.id }), + auto: false, + }) + expect(yield* llm.calls).toBe(scenario.calls) + expect(result).toBe(scenario.recovery ? "continue" : "stop") + const history = yield* sessions.messages({ sessionID: chat.id }) + expect(history.some((msg) => msg.info.id === user.info.id)).toBe(true) + const summary = history.find((msg) => msg.info.role === "assistant" && msg.info.summary) + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role !== "assistant") return + if (scenario.recovery) { + expect(errors).toEqual([]) + expect(summary.info.error).toBeUndefined() + expect(summary.parts.some((part) => part.type === "text" && part.text === "recovered summary")).toBe(true) + return + } + expect(summary.info.error?.name).toBe("ContextOverflowError") + expect(errors).toEqual([{ sessionID: chat.id, error: summary.info.error }]) + expect(JSON.stringify(summary.info.error)).toContain("Start a new session") + }), + { git: true, config: (url) => ({ ...providerCfg(url), compaction: { auto: scenario.auto } }) }, + ), + 30_000, + ) + } + for (const scenario of [ { name: "automatic", auto: true, text: "please overflow", blank: "" }, { name: "chunked", auto: true, text: "x".repeat(400_000), blank: "" }, From 97adfa0542d412350a7195031eb19d729eb7efb9 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Tue, 29 Sep 2026 15:07:53 +0530 Subject: [PATCH 030/353] fix(tui): drop protected asks answered while the SSE stream was down --- packages/tui/src/context/sync.tsx | 21 ++++++++------- .../test/kilocode/sync-pending-asks.test.tsx | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0746abc6bd..9d98d48d0a7 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -252,16 +252,17 @@ export const { void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) } // kilocode_change start - keep protected asks visible; clear only what was settled - const kept = new Map() - for (const request of [...Object.values(store.permission).flat(), ...permissions]) { - if (terminal.has(request.id)) continue - if (!temporaryPermission(request)) continue - kept.set(request.id, request) - } - const next: Record = {} - for (const request of kept.values()) (next[request.sessionID] ??= []).push(request) - for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id)) - setStore("permission", reconcile(next)) + const protectedList = permissions.filter((request)=> temporaryPermission(request)) + const protectedCurrent: Record = {} + + for(const list of Object.values(store.permission)){ + for(const request of list){ + if(!temporaryPermission(request)) continue + (protectedCurrent[request.sessionID] ??= []).push(request) + } + } + setStore("permission", reconcile(mergePending(protectedList, protectedCurrent, before.permission, terminal))) + // kilocode_change end } else { setStore( diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index 92f03d538ca..fdb38b2e72c 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -431,3 +431,29 @@ test("auto mode retries a failed reply on the next recovery", async () => { app.renderer.destroy() } }) + +test("auto mode drops a protected ask answered while the SSE stream was down", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, sync } = await mount( + (url) => { + if (url.pathname === "/permission") return json([]) + return serveSessions([parent, child], () => ({}))(url) + }, + tmp.path, + { auto: true }, + ) + + try { + // The protected ask is in the store, but it was answered elsewhere while + // the SSE stream was down: the replied event was missed (so the ask was + // never removed or terminal-marked) and the server no longer holds it. + sync.set("permission", { [childID]: [protectedPermission("per_protected")] }) + await sync.session.sync(childID) + + // The recovery must drop it instead of resurrecting an unanswerable prompt. + expect(sync.data.permission[childID]).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) From 606d96077421d06836f4a435a17d161ecbe755c0 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Tue, 29 Sep 2026 16:20:47 +0530 Subject: [PATCH 031/353] fix(tui): satisfy prettier, close kilocode_change block, log recovery failures --- .changeset/tui-permission-eviction.md | 6 +- packages/tui/src/context/sync.tsx | 39 +++---- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 5 +- .../test/kilocode/sync-pending-asks.test.tsx | 103 +++++++++--------- 4 files changed, 71 insertions(+), 82 deletions(-) diff --git a/.changeset/tui-permission-eviction.md b/.changeset/tui-permission-eviction.md index c121b818052..66530ab9c2c 100644 --- a/.changeset/tui-permission-eviction.md +++ b/.changeset/tui-permission-eviction.md @@ -2,8 +2,6 @@ "@kilocode/cli": patch --- -Fix pending permission and question prompts disappearing when switching sessions in the TUI. +Fix permission and question prompts disappearing when switching sessions in the TUI. -Viewing another session and returning used to drop the approval popup of a running subagent or session, leaving it blocked forever. Pending asks now survive session switches and are refetched from the server when a session becomes visible, so the prompt can always be answered. - -Auto mode now keeps protected prompts (skill shell batches, sandbox escalations) visible for a human decision, and treats answered prompts as settled so stale server lists cannot bring them back. +Approval prompts for running subagents and sessions now persist across session switches and are restored when switching back, so sessions are never left blocked on an unanswered prompt. diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 9d98d48d0a7..9f05eda5edb 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -188,11 +188,10 @@ export const { } // pending asks are one-shot events; refetch them so an evicted or missed ask cannot strand a session - // kilocode_change start - skill shell batches and sandbox escalations need an interactive human decision: + // skill shell batches and sandbox escalations need an interactive human decision: // the server refuses machine replies for them, mirroring temporaryPermission in cli/cmd/run/permission.shared const temporaryPermission = (request: PermissionRequest) => request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true - // kilocode_change end function mergePending( list: T[], current: Record, @@ -243,32 +242,26 @@ export const { ]) if (permission.mode === "auto") { for (const request of permissions) { - // kilocode_change start - skill shell batches and sandbox escalations cannot be settled by a + // skill shell batches and sandbox escalations cannot be settled by a // machine reply: the server refuses non-interactive approvals, so they stay pending for a // human decision and must remain visible instead of being cleared if (temporaryPermission(request)) continue - // kilocode_change end if (terminal.has(request.id)) continue // kilocode_change - already answered, ignore straggler events void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) } - // kilocode_change start - keep protected asks visible; clear only what was settled - const protectedList = permissions.filter((request)=> temporaryPermission(request)) + // keep protected asks visible; clear only what was settled + const protectedList = permissions.filter((request) => temporaryPermission(request)) const protectedCurrent: Record = {} - for(const list of Object.values(store.permission)){ - for(const request of list){ - if(!temporaryPermission(request)) continue - (protectedCurrent[request.sessionID] ??= []).push(request) + for (const list of Object.values(store.permission)) { + for (const request of list) { + if (!temporaryPermission(request)) continue + ;(protectedCurrent[request.sessionID] ??= []).push(request) } - } + } setStore("permission", reconcile(mergePending(protectedList, protectedCurrent, before.permission, terminal))) - - // kilocode_change end } else { - setStore( - "permission", - reconcile(mergePending(permissions, store.permission, before.permission, terminal)), - ) + setStore("permission", reconcile(mergePending(permissions, store.permission, before.permission, terminal))) } setStore("question", reconcile(mergePending(questions, store.question, before.question, terminal))) } @@ -281,7 +274,6 @@ export const { const fullSyncedSessions = new Set() const deleted = new Set() // kilocode_change - // kilocode_change - request IDs already replied/rejected; a stale pending list must not resurrect them const terminal = new Set() // kilocode_change // replied/rejected asks are terminal: a stale pending list must not resurrect them; cap the set so it cannot grow unbounded const terminalCap = 512 @@ -886,11 +878,7 @@ export const { setStore("provider_default", reconcile(providers.default)) setStore("provider_next", reconcile(providerList)) // kilocode_change start - fail closed when the backend omits the capability - setStore( - "capabilities", - "experimentalBackgroundSubagents", - capabilities?.backgroundSubagents === true, - ) + setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true) // kilocode_change end setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) @@ -954,7 +942,8 @@ export const { sdk.client.indexing .status() .then((result) => setStore("indexing", reconcile(result.data ?? store.indexing))), - syncPending().catch(() => {}), // kilocode_change - recover pending asks missed while disconnected + syncPending().catch((err) => console.error("pending-ask recovery failed", err)), // kilocode_change - recover pending asks missed while disconnected + // kilocode_change end ]).then(() => { setStore("status", "complete") }) @@ -1096,7 +1085,7 @@ export const { ) fullSyncedSessions.add(sessionID) // a failed pending-ask recovery must not fail the session load; the next visit retries it - await syncPending().catch(() => {}) // kilocode_change - recover pending asks lost to eviction or a missed one-shot event + await syncPending().catch((err) => console.error("pending-ask recovery failed", err)) // kilocode_change - recover pending asks lost to eviction or a missed one-shot event })().finally(() => { syncingSessions.delete(sessionID) hydratingSessions.delete(sessionID) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index 1fa991421f6..bf54f96a888 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -23,7 +23,8 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } -export async function mount(override?: FetchHandler, state?: string, args?: Args) { // kilocode_change - optional TUI args for tests +export async function mount(override?: FetchHandler, state?: string, args?: Args) { + // kilocode_change - optional TUI args for tests const calls = createFetch(override) const events = createEventSource() let sync!: ReturnType @@ -49,7 +50,7 @@ export async function mount(override?: FetchHandler, state?: string, args?: Args {/* kilocode_change start - optional TUI args for tests */} - {/* kilocode_change end */} + {/* kilocode_change end */} {/* kilocode_change start */} diff --git a/packages/tui/test/kilocode/sync-pending-asks.test.tsx b/packages/tui/test/kilocode/sync-pending-asks.test.tsx index fdb38b2e72c..85469f68f2c 100644 --- a/packages/tui/test/kilocode/sync-pending-asks.test.tsx +++ b/packages/tui/test/kilocode/sync-pending-asks.test.tsx @@ -63,13 +63,17 @@ function wrap(payload: GlobalEvent["payload"]): GlobalEvent { return { directory, project: "proj_test", payload } } -function serveSessions(sessions: Session[], asks: () => { permission?: PermissionRequest[]; question?: QuestionRequest[] }) { +function serveSessions( + sessions: Session[], + asks: () => { permission?: PermissionRequest[]; question?: QuestionRequest[] }, +) { return (url: URL) => { if (url.pathname === "/session") return json(sessions) for (const session of sessions) { if (url.pathname === `/session/${session.id}`) return json(session) if (url.pathname === `/session/${session.id}/message`) return json([]) - if (url.pathname === `/session/${session.id}/todo` || url.pathname === `/session/${session.id}/diff`) return json([]) + if (url.pathname === `/session/${session.id}/todo` || url.pathname === `/session/${session.id}/diff`) + return json([]) } if (url.pathname === "/permission") return json(asks().permission ?? []) if (url.pathname === "/question") return json(asks().question ?? []) @@ -80,7 +84,10 @@ function serveSessions(sessions: Session[], asks: () => { permission?: Permissio test("evicting a parent session keeps pending child permission and question asks", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") - const { app, emit, sync } = await mount(serveSessions([parent, child], () => ({})), tmp.path) + const { app, emit, sync } = await mount( + serveSessions([parent, child], () => ({})), + tmp.path, + ) try { emit(wrap({ id: "evt_ask", type: "permission.asked", properties: permission("per_1") })) @@ -99,7 +106,11 @@ test("evicting a parent session keeps pending child permission and question asks expect(sync.data.question[childID]).toHaveLength(1) emit( - wrap({ id: "evt_replied", type: "permission.replied", properties: { sessionID: childID, requestID: "per_1", reply: "once" } }), + wrap({ + id: "evt_replied", + type: "permission.replied", + properties: { sessionID: childID, requestID: "per_1", reply: "once" }, + }), ) emit( wrap({ @@ -119,7 +130,10 @@ test("session sync refetches pending permission and question asks and drops stal await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") let pending = { permission: [permission("per_1")], question: [question("que_1")] } - const { app, sync } = await mount(serveSessions([parent, child], () => pending), tmp.path) + const { app, sync } = await mount( + serveSessions([parent, child], () => pending), + tmp.path, + ) try { // Eviction no longer wipes the asks; simulate losing them anyway (e.g. the @@ -147,17 +161,14 @@ test("an ask arriving while the pending refetch is in flight survives it", async await Bun.write(`${tmp.path}/kv.json`, "{}") const held = Promise.withResolvers() let seen = 0 - const { app, emit, sync } = await mount( - (url) => { - if (url.pathname === "/permission") { - seen += 1 - // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : held.promise.then((data) => json(data)) - } - return serveSessions([parent, child], () => ({}))(url) - }, - tmp.path, - ) + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, tmp.path) try { // The store starts empty (e.g. the ask raced the SSE stream). @@ -181,17 +192,14 @@ test("an ask answered while the pending refetch is in flight is not resurrected" await Bun.write(`${tmp.path}/kv.json`, "{}") const held = Promise.withResolvers() let seen = 0 - const { app, emit, sync } = await mount( - (url) => { - if (url.pathname === "/permission") { - seen += 1 - // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : held.promise.then((data) => json(data)) - } - return serveSessions([parent, child], () => ({}))(url) - }, - tmp.path, - ) + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, tmp.path) try { // The store holds a pending ask; the refetch starts while it is still live. @@ -220,17 +228,14 @@ test("a failed pending list fetch keeps existing asks", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") let seen = 0 - const { app, sync } = await mount( - (url) => { - if (url.pathname === "/permission") { - seen += 1 - // bootstrap consumes the first list call; the session sync gets a 500 - return seen === 1 ? json([]) : json({ message: "boom" }, { status: 500 }) - } - return serveSessions([parent, child], () => ({}))(url) - }, - tmp.path, - ) + const { app, sync } = await mount((url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync gets a 500 + return seen === 1 ? json([]) : json({ message: "boom" }, { status: 500 }) + } + return serveSessions([parent, child], () => ({}))(url) + }, tmp.path) try { // A live ask is in the store; the refetch then fails. @@ -251,17 +256,14 @@ test("an ask created and answered during the refetch is not resurrected", async await Bun.write(`${tmp.path}/kv.json`, "{}") const held = Promise.withResolvers() let seen = 0 - const { app, emit, sync } = await mount( - (url) => { - if (url.pathname === "/permission") { - seen += 1 - // bootstrap consumes the first list call; the session sync holds the second - return seen === 1 ? json([]) : held.promise.then((data) => json(data)) - } - return serveSessions([parent, child], () => ({}))(url) - }, - tmp.path, - ) + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/permission") { + seen += 1 + // bootstrap consumes the first list call; the session sync holds the second + return seen === 1 ? json([]) : held.promise.then((data) => json(data)) + } + return serveSessions([parent, child], () => ({}))(url) + }, tmp.path) try { // The store is empty when the refetch snapshots, so the new ask's ID is @@ -302,8 +304,7 @@ test("auto mode settles recovered asks but keeps protected ones visible", async replies.push(match.at(1) ?? "") return json(true) } - if (url.pathname === "/permission") - return json([permission("per_normal"), protectedPermission("per_protected")]) + if (url.pathname === "/permission") return json([permission("per_normal"), protectedPermission("per_protected")]) return serveSessions([parent, child], () => ({}))(url) }, tmp.path, From d6ca872683311bad384c738fc79adbbf00da83dd Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 14:39:51 -0400 Subject: [PATCH 032/353] feat(jetbrains): add MCP OAuth sign-in Expose remote MCP authentication across Settings, Marketplace, and the prompt toolbar so servers that require OAuth can be connected without hand-editing configuration. Preserve custom OAuth client settings and surface browser, timeout, and server errors to make sign-in recoverable. --- .changeset/jetbrains-mcp-oauth-sign-in.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 53 +++++ .../rpc/KiloAgentBehaviorRpcApiImpl.kt | 45 +++- .../backend/cli/KiloCliDataParserTest.kt | 182 ++++++++++++++++ .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 28 ++- .../kilocode/backend/testing/MockCliServer.kt | 9 +- .../client/app/KiloAgentBehaviorService.kt | 14 +- .../kilocode/client/app/KiloMcpAuthService.kt | 160 ++++++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 65 ++++++ .../client/session/ui/prompt/PromptPanel.kt | 26 +++ .../settings/agents/McpAuthUrlDialog.kt | 73 +++++++ .../client/settings/agents/McpConfigurable.kt | 51 ++++- .../client/settings/agents/McpEditDialog.kt | 100 ++++++++- .../marketplace/MarketplaceSettingsUi.kt | 50 ++++- .../resources/messages/KiloBundle.properties | 36 ++++ .../messages/KiloBundle_ar.properties | 36 ++++ .../messages/KiloBundle_bs.properties | 36 ++++ .../messages/KiloBundle_da.properties | 36 ++++ .../messages/KiloBundle_de.properties | 36 ++++ .../messages/KiloBundle_es.properties | 36 ++++ .../messages/KiloBundle_fr.properties | 36 ++++ .../messages/KiloBundle_ja.properties | 36 ++++ .../messages/KiloBundle_ko.properties | 36 ++++ .../messages/KiloBundle_nl.properties | 36 ++++ .../messages/KiloBundle_no.properties | 36 ++++ .../messages/KiloBundle_pl.properties | 36 ++++ .../messages/KiloBundle_pt_BR.properties | 36 ++++ .../messages/KiloBundle_ru.properties | 36 ++++ .../messages/KiloBundle_th.properties | 36 ++++ .../messages/KiloBundle_tr.properties | 36 ++++ .../messages/KiloBundle_uk.properties | 36 ++++ .../messages/KiloBundle_zh_CN.properties | 36 ++++ .../messages/KiloBundle_zh_TW.properties | 36 ++++ .../app/KiloAgentBehaviorServiceTest.kt | 22 +- .../client/app/KiloMcpAuthServiceTest.kt | 197 ++++++++++++++++++ .../client/plugin/KiloBundleLocaleTest.kt | 56 +++++ .../client/session/ui/PromptPanelTest.kt | 52 +++++ .../settings/agents/McpAuthUrlDialogTest.kt | 74 +++++++ .../settings/agents/McpEditDialogTest.kt | 94 +++++++++ .../settings/agents/McpSettingsUiTest.kt | 84 +++++++- .../marketplace/MarketplaceSettingsUiTest.kt | 66 +++++- .../client/testing/FakeAgentBehaviorRpcApi.kt | 26 ++- .../kilocode/rpc/KiloAgentBehaviorRpcApi.kt | 9 +- .../ai/kilocode/rpc/dto/AgentBehaviorDto.kt | 12 ++ .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 12 ++ 45 files changed, 2229 insertions(+), 20 deletions(-) create mode 100644 .changeset/jetbrains-mcp-oauth-sign-in.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt diff --git a/.changeset/jetbrains-mcp-oauth-sign-in.md b/.changeset/jetbrains-mcp-oauth-sign-in.md new file mode 100644 index 00000000000..83da075f9fb --- /dev/null +++ b/.changeset/jetbrains-mcp-oauth-sign-in.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Sign in to remote MCP servers from JetBrains. MCP Settings now shows the real sign-in error, lets you cancel an in-progress sign-in, and offers a copyable authorization URL when a browser cannot be opened automatically. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt toolbar shows an indicator when a configured MCP server needs sign-in. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index ef289ffca77..fbc0a9023d5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -33,7 +33,10 @@ import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.ModelAutoRoutingDto import ai.kilocode.rpc.dto.ModelCacheCostDto @@ -692,10 +695,25 @@ object KiloCliDataParser { headers = item.map("headers").takeIf { it.isNotEmpty() }, enabled = item.flagOrNull("enabled"), timeout = item.long("timeout"), + oauth = parseMcpOAuth(item["oauth"]), ) }.toMap() } + private fun parseMcpOAuth(elem: JsonElement?): McpOAuthDto? { + if (elem == null || elem is JsonNull) return null + runCatching { elem.jsonPrimitive.booleanOrNull }.getOrNull()?.let { return McpOAuthDto(enabled = it) } + val obj = elem.obj() ?: return null + return McpOAuthDto( + enabled = true, + clientId = obj.str("clientId"), + clientSecret = obj.str("clientSecret"), + scope = obj.str("scope"), + callbackPort = obj.long("callbackPort")?.toInt(), + redirectUri = obj.str("redirectUri"), + ) + } + private fun parseAgentConfig(obj: JsonObject?): Map { if (obj == null) return emptyMap() return obj.entries.mapNotNull { (name, elem) -> @@ -819,6 +837,28 @@ object KiloCliDataParser { } } + fun parseMcpAuthResult(code: Int, raw: String): McpAuthResultDto { + val obj = tryParseObject(raw) + if (code in 200..299) { + return McpAuthResultDto(status = obj?.str("status") ?: "failed", error = obj?.str("error")) + } + val status = when (code) { + 400 -> "unsupported" + 404 -> "not_found" + else -> "failed" + } + return McpAuthResultDto(status = status, error = obj?.str("error") ?: obj?.str("message") ?: "HTTP $code") + } + + fun parseMcpBrowserOpenFailed(raw: String): McpAuthEventDto? { + val obj = tryParseObject(raw) ?: return null + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: obj + val name = props.str("mcpName") ?: return null + val url = props.str("url") ?: return null + return McpAuthEventDto(name = name, url = url) + } + private fun String.array(): JsonArray { val root = runCatching { json.parseToJsonElement(this) }.getOrNull() return when (root) { @@ -1065,6 +1105,19 @@ object KiloCliDataParser { } if (it.enabled != null) put("enabled", it.enabled) if (it.timeout != null) put("timeout", it.timeout) + val oauth = it.oauth + when { + oauth == null -> Unit + oauth.clear -> put("oauth", JsonNull) + oauth.enabled == false -> put("oauth", JsonPrimitive(false)) + else -> put("oauth", buildJsonObject { + if (oauth.clientId != null) put("clientId", oauth.clientId) + if (oauth.clientSecret != null) put("clientSecret", oauth.clientSecret) + if (oauth.scope != null) put("scope", oauth.scope) + if (oauth.callbackPort != null) put("callbackPort", oauth.callbackPort) + if (oauth.redirectUri != null) put("redirectUri", oauth.redirectUri) + }) + } } } ?: JsonNull) }) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index 244e5b2f2da..264e400d07d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -12,12 +12,17 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.PermissionRuleItemDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -40,6 +45,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = private val saved = ConcurrentHashMap() private val port = AtomicInteger(-1) private val extensions = setOf("md", "markdown", "txt", "text", "html", "htm") + private const val BROWSER_OPEN_FAILED = "mcp.browser.open.failed" } private val app: KiloBackendAppService get() = backend ?: service() @@ -217,8 +223,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun mcpDisconnect(directory: String, name: String): Boolean = post(directory, "/mcp/${encodePath(name)}/disconnect") - override suspend fun mcpAuthenticate(directory: String, name: String): Boolean = - post(directory, "/mcp/${encodePath(name)}/auth/authenticate") + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto { + val reply = send(directory, "/mcp/${encodePath(name)}/auth/authenticate", "POST") + val result = KiloCliDataParser.parseMcpAuthResult(reply.code, reply.body) + LOG.info("MCP auth dir=$directory name=$name http=${reply.code} status=${result.status}") + return result + } + + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + val reply = send(directory, "/mcp/${encodePath(name)}/auth", "DELETE") + LOG.info("MCP auth remove dir=$directory name=$name http=${reply.code}") + return reply.code in 200..299 + } + + override suspend fun mcpAuthEvents(): Flow = channelFlow { + app.events.collect { event -> + val type = if (event.type == BROWSER_OPEN_FAILED) event.type else KiloCliDataParser.extractEventType(event.data) + if (type != BROWSER_OPEN_FAILED) return@collect + val dto = KiloCliDataParser.parseMcpBrowserOpenFailed(event.data) ?: return@collect + LOG.info("MCP auth browser open failed name=${dto.name}") + trySendBlocking(dto) + } + } override suspend fun claudeCodeCompat(): Boolean = KiloClaudeCompatSettings.get() @@ -417,6 +443,21 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = } } + private data class Reply(val code: Int, val body: String) + + private suspend fun send(directory: String, path: String, method: String): Reply = withContext(Dispatchers.IO) { + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val url = "http://127.0.0.1:${app.port}$path?directory=${encode(directory)}" + val builder = Request.Builder().url(url) + when (method) { + "DELETE" -> builder.delete() + else -> builder.post(JsonObject(emptyMap()).toString().toRequestBody(JSON)) + } + http.newCall(builder.build()).execute().use { response -> + Reply(response.code, response.body?.string().orEmpty()) + } + } + private suspend fun request(directory: String, path: String, body: JsonObject?): String = withContext(Dispatchers.IO) { val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") val url = "http://127.0.0.1:${app.port}$path?directory=${encode(directory)}" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index f225e55e405..02d3fc31b20 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRuleDto @@ -1295,6 +1296,43 @@ class KiloCliDataParserTest { assertEquals(false, cfg.mcp["off"]?.enabled) } + @Test + fun `parseConfig - mcp oauth absent`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test"}}}""" + ) + + assertNull(cfg.mcp["remote"]?.oauth) + } + + @Test + fun `parseConfig - mcp oauth disabled`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test","oauth":false}}}""" + ) + val oauth = cfg.mcp["remote"]?.oauth + + assertNotNull(oauth) + assertEquals(false, oauth.enabled) + assertNull(oauth.clientId) + } + + @Test + fun `parseConfig - mcp oauth custom client object`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test","oauth":{"clientId":"abc","clientSecret":"shh","scope":"read","callbackPort":19999,"redirectUri":"http://127.0.0.1:19999/cb"}}}}""" + ) + val oauth = cfg.mcp["remote"]?.oauth + + assertNotNull(oauth) + assertEquals(true, oauth.enabled) + assertEquals("abc", oauth.clientId) + assertEquals("shh", oauth.clientSecret) + assertEquals("read", oauth.scope) + assertEquals(19999, oauth.callbackPort) + assertEquals("http://127.0.0.1:19999/cb", oauth.redirectUri) + } + @Test fun `parseConfig - scalars instructions and skills`() { val cfg = KiloCliDataParser.parseConfig( @@ -2193,6 +2231,90 @@ class KiloCliDataParserTest { } } + @Nested + inner class McpAuth { + + // ---- parseMcpAuthResult ---- + + @Test + fun `parseMcpAuthResult - 200 with connected status`() { + val result = KiloCliDataParser.parseMcpAuthResult(200, """{"status":"connected"}""") + + assertEquals("connected", result.status) + assertNull(result.error) + } + + @Test + fun `parseMcpAuthResult - 200 with failed status and error`() { + val result = KiloCliDataParser.parseMcpAuthResult(200, """{"status":"failed","error":"denied"}""") + + assertEquals("failed", result.status) + assertEquals("denied", result.error) + } + + @Test + fun `parseMcpAuthResult - 400 maps to unsupported`() { + val result = KiloCliDataParser.parseMcpAuthResult(400, """{"error":"UnsupportedOAuthError"}""") + + assertEquals("unsupported", result.status) + assertEquals("UnsupportedOAuthError", result.error) + } + + @Test + fun `parseMcpAuthResult - 404 maps to not_found`() { + val result = KiloCliDataParser.parseMcpAuthResult(404, """{"message":"no such server"}""") + + assertEquals("not_found", result.status) + assertEquals("no such server", result.error) + } + + @Test + fun `parseMcpAuthResult - non-JSON body falls back to HTTP status`() { + val result = KiloCliDataParser.parseMcpAuthResult(500, "not json") + + assertEquals("failed", result.status) + assertEquals("HTTP 500", result.error) + } + + // ---- parseMcpBrowserOpenFailed ---- + + @Test + fun `parseMcpBrowserOpenFailed - wrapped payload shape`() { + val event = KiloCliDataParser.parseMcpBrowserOpenFailed( + """{"directory":"/test","payload":{"type":"mcp.browser.open.failed","properties":{"mcpName":"linear","url":"https://auth.example.test/authorize"}}}""" + ) + + assertNotNull(event) + assertEquals("linear", event.name) + assertEquals("https://auth.example.test/authorize", event.url) + } + + @Test + fun `parseMcpBrowserOpenFailed - flat payload shape`() { + val event = KiloCliDataParser.parseMcpBrowserOpenFailed( + """{"properties":{"mcpName":"linear","url":"https://auth.example.test/authorize"}}""" + ) + + assertNotNull(event) + assertEquals("linear", event.name) + assertEquals("https://auth.example.test/authorize", event.url) + } + + @Test + fun `parseMcpBrowserOpenFailed - missing url returns null`() { + val event = KiloCliDataParser.parseMcpBrowserOpenFailed( + """{"properties":{"mcpName":"linear"}}""" + ) + + assertNull(event) + } + + @Test + fun `parseMcpBrowserOpenFailed - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseMcpBrowserOpenFailed("not json")) + } + } + // ================================================================ // Group 3 — Request / body builders and local model state // ================================================================ @@ -2702,6 +2824,66 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildConfigPatch - mcp oauth disabled writes false`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(enabled = false), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\",\"oauth\":false}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - mcp oauth custom client writes object`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(enabled = true, clientId = "abc", scope = "read"), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\",\"oauth\":{\"clientId\":\"abc\",\"scope\":\"read\"}}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - mcp oauth clear writes null`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(clear = true), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\",\"oauth\":null}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - mcp without oauth omits the key`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto(type = "remote", url = "https://mcp.example.test"), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\"}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + @Test fun `buildConfigPatch - full agent permission object`() { val patch = ConfigPatchDto(agents = linkedMapOf("custom" to AgentConfigPatchDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 6daa8b0096f..ca57321680b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -353,10 +353,36 @@ class KiloAgentBehaviorRpcApiImplTest { assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/connect") assertTrue(rpc.mcpDisconnect("/test", "local server")) assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/disconnect") - assertTrue(rpc.mcpAuthenticate("/test", "local server")) + val authResult = rpc.mcpAuthenticate("/test", "local server") + assertEquals("connected", authResult.status) assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/auth/authenticate") } + @Test + fun `mcp auth remove and browser open failed event`() = runBlocking { + val rpc = rpc() + + mock.mcpAuthRemoveStatus = 200 + assertTrue(rpc.mcpAuthRemove("/test", "linear")) + assertContains(mock.lastMcpAuthDeletePath.orEmpty(), "/mcp/linear/auth") + + mock.mcpAuthRemoveStatus = 404 + assertFalse(rpc.mcpAuthRemove("/test", "missing")) + } + + @Test + fun `mcp authenticate maps failure statuses`() = runBlocking { + val rpc = rpc() + + mock.mcpActionStatus = 400 + mock.mcpAuthenticateResponse = """{"error":"unsupported"}""" + assertEquals("unsupported", rpc.mcpAuthenticate("/test", "linear").status) + + mock.mcpActionStatus = 404 + mock.mcpAuthenticateResponse = """{"error":"not found"}""" + assertEquals("not_found", rpc.mcpAuthenticate("/test", "linear").status) + } + private suspend fun rpc(): KiloAgentBehaviorRpcApiImpl = KiloAgentBehaviorRpcApiImpl(app()) private suspend fun app(): KiloBackendAppService { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 60706cedff1..3d8f06794f1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -67,11 +67,14 @@ class MockCliServer : AutoCloseable { @Volatile var mcp = "[]" @Volatile var mcpStatus = 200 @Volatile var mcpActionStatus = 200 + @Volatile var mcpAuthenticateResponse = """{"status":"connected"}""" + @Volatile var mcpAuthRemoveStatus = 200 @Volatile var agentRemoveStatus = 200 @Volatile var commandRemoveStatus = 200 @Volatile var skillRemoveStatus = 200 @Volatile var agentBuilderStatus = 200 @Volatile var lastMcpActionPath: String? = null + @Volatile var lastMcpAuthDeletePath: String? = null @Volatile var lastAgentRemoveBody: String? = null @Volatile var sessionBoard = """{"ownerSessionID":"ses_root","revision":1,"hasMore":false,"messages":[]}""" @Volatile var sessionBoardStatus = 200 @@ -501,7 +504,11 @@ class MockCliServer : AutoCloseable { } bare.matches(Regex("/mcp/[^/]+/auth/authenticate")) && method == "POST" -> { lastMcpActionPath = path - respond(output, mcpActionStatus, "true") + respond(output, mcpActionStatus, mcpAuthenticateResponse) + } + bare.matches(Regex("/mcp/[^/]+/auth")) && method == "DELETE" -> { + lastMcpAuthDeletePath = bare + respond(output, mcpAuthRemoveStatus, "true") } bare == "/experimental/session" -> { lastExperimentalSessionPath = path diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt index b168d900c61..ae78d57b2a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt @@ -8,13 +8,17 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.CommandFileDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.Service import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow @Service(Service.Level.APP) class KiloAgentBehaviorService internal constructor( @@ -80,7 +84,13 @@ class KiloAgentBehaviorService internal constructor( suspend fun mcpDisconnect(directory: String, name: String): Boolean = safe(false) { call { mcpDisconnect(directory, name) } } - suspend fun mcpAuthenticate(directory: String, name: String): Boolean = safe(false) { call { mcpAuthenticate(directory, name) } } + suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto = + safe(McpAuthResultDto("failed", "RPC failed")) { call { mcpAuthenticate(directory, name) } } + + suspend fun mcpAuthRemove(directory: String, name: String): Boolean = + safe(false) { call { mcpAuthRemove(directory, name) } } + + suspend fun mcpAuthEvents(): Flow = call { mcpAuthEvents() } suspend fun claudeCodeCompat(): Boolean = safe(false) { call { claudeCodeCompat() } } @@ -88,6 +98,8 @@ class KiloAgentBehaviorService internal constructor( private suspend fun safe(fallback: T, block: suspend () -> T): T = try { block() + } catch (err: CancellationException) { + throw err } catch (e: Exception) { LOG.warn("agent behavior RPC failed", e) fallback diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt new file mode 100644 index 00000000000..6e7eae9b6d0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt @@ -0,0 +1,160 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.app + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.agents.McpAuthUrlDialog +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import com.intellij.openapi.application.EDT +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** + * App-level service that centralizes remote MCP OAuth sign-in for Settings, Marketplace, and the + * session prompt toolbar. + */ +@Service(Service.Level.APP) +class KiloMcpAuthService internal constructor( + private val cs: CoroutineScope, + private val behavior: KiloAgentBehaviorService?, + private val authTimeoutMs: Long = DEFAULT_AUTH_TIMEOUT_MS, + private val dedupeWindowMs: Long = DEFAULT_DEDUPE_WINDOW_MS, + private val showAuthUrl: (String, String) -> Unit = { name, url -> McpAuthUrlDialog(name, url).show() }, +) { + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloMcpAuthService::class.java) + + // Above the CLI's 5-minute OAuth callback timeout (packages/opencode/src/mcp/oauth-callback.ts), + // so the CLI names the failure first. + private const val DEFAULT_AUTH_TIMEOUT_MS = 6 * 60 * 1000L + private const val DEFAULT_DEDUPE_WINDOW_MS = 4000L + } + + private fun svc(): KiloAgentBehaviorService = behavior ?: service() + + private val _needsAuth = MutableStateFlow>>(emptyMap()) + val needsAuth: StateFlow>> = _needsAuth.asStateFlow() + + private val _busy = MutableStateFlow>(emptySet()) + val busy: StateFlow> = _busy.asStateFlow() + private val active = ConcurrentHashMap.newKeySet() + + private val started = AtomicBoolean(false) + private val lastEventUrl = AtomicReference(null) + private val lastEventAt = AtomicLong(0) + + /** Refreshes the needs-auth set for [dir] and returns it. Returns an empty set for a blank directory. */ + suspend fun refresh(dir: String): Set { + start() + if (dir.isBlank()) return emptySet() + val names = attempt("mcp auth refresh failed dir=$dir", emptyList()) { svc().mcpStatus(dir) } + .filter { it.status == "needs_auth" } + .map { it.name } + .toSet() + _needsAuth.update { it + (dir to names) } + return names + } + + /** Starts (or resumes) sign-in for [name] in [dir]. Single-flight per directory/name pair. */ + suspend fun signIn(dir: String, name: String): McpAuthResultDto { + val key = busyKey(dir, name) + if (!active.add(key)) return McpAuthResultDto("failed", null) + _busy.update { it + key } + return try { + val result = withTimeoutOrNull(authTimeoutMs) { + attempt("mcp auth signIn failed dir=$dir name=$name", McpAuthResultDto("failed", null)) { + svc().mcpAuthenticate(dir, name) + } + } + if (result != null) return result + attempt("mcp auth timeout cleanup failed dir=$dir name=$name", false) { + svc().mcpAuthRemove(dir, name) + } + McpAuthResultDto("timeout", null) + } finally { + active.remove(key) + _busy.update { it - key } + refresh(dir) + } + } + + /** Cancels a pending sign-in and clears any stored credentials for [name]. */ + suspend fun cancel(dir: String, name: String): Boolean = + attempt("mcp auth cancel failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } + + /** Reports a sign-in [result] for [name] via a Kilo notification. Must run on EDT. */ + @RequiresEdt + fun report(name: String, result: McpAuthResultDto) { + when (result.status) { + "connected" -> KiloNotifications.info( + KiloBundle.message("settings.agentBehavior.mcp.signIn.success", name), + ) + "timeout" -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.timeout", name), + ) + "unsupported" -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.unsupported", name), + ) + else -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + result.error, + ) + } + } + + private fun busyKey(dir: String, name: String) = "$dir\u0000$name" + + private suspend fun attempt(message: String, fallback: T, block: suspend () -> T): T = try { + block() + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + LOG.warn(message, err) + fallback + } + + private fun start() { + if (!started.compareAndSet(false, true)) return + cs.launch { + durable { + svc().mcpAuthEvents().collect { event -> onBrowserOpenFailed(event) } + } + } + } + + private suspend fun onBrowserOpenFailed(event: McpAuthEventDto) { + val now = System.currentTimeMillis() + val prevUrl = lastEventUrl.get() + val prevAt = lastEventAt.get() + if (event.url == prevUrl && now - prevAt < dedupeWindowMs) return + lastEventUrl.set(event.url) + lastEventAt.set(now) + withContext(Dispatchers.EDT) { + showAuthUrl(event.name, event.url) + } + } +} 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 83625e6bccd..602a84914f2 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 @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace @@ -76,6 +77,7 @@ import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.KiloSettingsConfigurable +import ai.kilocode.client.settings.agents.McpConfigurable import ai.kilocode.client.settings.checkpoints.CheckpointsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.telemetry.Telemetry @@ -103,6 +105,7 @@ import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsListener @@ -110,6 +113,7 @@ import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry @@ -120,6 +124,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -710,7 +717,9 @@ class SessionUi( prompt.onReset = { controller.clearModelOverride() } prompt.onChange = { scroll.refresh() } prompt.onAutoApproveToggle = ::setAuto + prompt.onMcpAuthClick = ::openMcpAuth prompt.setAutoApprove(controller.autoApprove) + startMcpAuthTracking() prompt.model.favorites = { app.favorites.value } prompt.model.onFavoriteToggle = { item -> Telemetry.send( @@ -768,6 +777,7 @@ class SessionUi( prompt.setResetVisible(m.modelOverride) prompt.setReady(m.isReady()) prompt.refreshHighlights() + cs.launch { service().refresh(workspace.directory) } } is SessionControllerEvent.ViewChanged.ShowProgress -> { @@ -1442,6 +1452,61 @@ class SessionUi( ) } + private fun openMcpSettings() { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == McpConfigurable.ID + }, + { _: Configurable -> }, + ) + } + + /** + * Collects [KiloMcpAuthService] state for this session's directory and reflects it on the prompt + * toolbar indicator. Refresh is event-driven (workspace ready, after a sign-in attempt) rather + * than polled. + */ + private fun startMcpAuthTracking() { + val auth = service() + cs.launch { + auth.needsAuth.combine(auth.busy) { needs, busy -> needs[workspace.directory].orEmpty() to (busy.any { it.startsWith("${workspace.directory}\u0000") }) } + .distinctUntilChanged() + .collect { (servers, busy) -> + withContext(Dispatchers.EDT) { prompt.setMcpAuth(servers.toList(), busy) } + } + } + } + + @RequiresEdt + private fun openMcpAuth() { + val auth = service() + val servers = auth.needsAuth.value[workspace.directory].orEmpty().toList() + if (servers.isEmpty()) { + openMcpSettings() + return + } + if (servers.size == 1) { + signInFromPrompt(servers.first()) + return + } + JBPopupFactory.getInstance() + .createPopupChooserBuilder(servers + KiloBundle.message("prompt.mcp.openSettings")) + .setItemChosenCallback { choice -> + if (choice == KiloBundle.message("prompt.mcp.openSettings")) openMcpSettings() else signInFromPrompt(choice) + } + .createPopup() + .showUnderneathOf(prompt) + } + + private fun signInFromPrompt(name: String) { + val auth = service() + cs.launch { + val result = auth.signIn(workspace.directory, name) + withContext(Dispatchers.EDT) { auth.report(name, result) } + } + } + private fun openKiloSettings() { ShowSettingsUtil.getInstance().showSettingsDialog( project, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 5ba849fd631..82b61f40b60 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -155,6 +155,7 @@ class PromptPanel( var onChange: () -> Unit = {} var onAutoApproveToggle: (Boolean) -> Unit = {} var onFileDrag: (Boolean) -> Unit = {} + var onMcpAuthClick: () -> Unit = {} private var style = SessionEditorStyle.current() private var focused = false private val shell = BorderLayoutPanel().apply { @@ -259,6 +260,12 @@ class PromptPanel( addActionListener { onAutoApproveToggle(!autoApprove) } } + private val mcpAuth = HoverIcon().apply { + icon = AllIcons.General.Warning + isVisible = false + addActionListener { onMcpAuthClick() } + } + /** * Opens the Kilo.Session.PromptMenu popup (auto-approve + sharing). Resolves its context from * DataManager, so it reads live SessionActionsKeys.ACTIONS from the session ancestor chain rather @@ -355,6 +362,8 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(reset) bar.add(Box.createHorizontalGlue()) + bar.add(mcpAuth) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) if (approve) { bar.add(menu) bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) @@ -522,6 +531,21 @@ class PromptPanel( repaint() } + @RequiresEdt + fun setMcpAuth(servers: List, busy: Boolean) { + mcpAuth.isVisible = servers.isNotEmpty() || busy + mcpAuth.isEnabled = !busy + mcpAuth.icon = if (busy) SpinnerIcon.icon else AllIcons.General.Warning + mcpAuth.toolTipText = when { + busy -> KiloBundle.message("prompt.mcp.needsAuth.busy") + servers.size == 1 -> KiloBundle.message("prompt.mcp.needsAuth.one", servers.first()) + else -> KiloBundle.message("prompt.mcp.needsAuth.many", servers.size) + } + mcpAuth.accessibleContext.accessibleName = mcpAuth.toolTipText + revalidate() + repaint() + } + @RequiresEdt fun text(): String = editor.text.trim() @@ -555,6 +579,8 @@ class PromptPanel( internal fun resetForTest(): JComponent = reset + internal fun mcpAuthForTest(): JComponent = mcpAuth + internal fun shellForTest(): JComponent = shell internal fun buttonForTest(): JButton = button diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt new file mode 100644 index 00000000000..7e4e4045553 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt @@ -0,0 +1,73 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.auth.copyToClipboard +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import java.awt.event.ActionEvent +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.AbstractAction +import javax.swing.Action +import javax.swing.JComponent + +/** + * Shown when the CLI could not open a browser to finish an MCP OAuth sign-in. Lets the user copy or + * open the authorization URL manually. + */ +internal class McpAuthUrlDialog( + private val name: String, + private val url: String, +) : DialogWrapper(false) { + private val urlField = JBTextField(url).apply { + isEditable = false + columns = 40 + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectAll() + }) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = selectAll() + }) + } + private var center: JComponent? = null + + private val openAction: Action = object : AbstractAction(KiloBundle.message("settings.agentBehavior.mcp.authUrl.open")) { + override fun actionPerformed(e: ActionEvent) { + BrowserUtil.browse(url) + } + } + + private val copyAction: Action = object : AbstractAction(KiloBundle.message("settings.agentBehavior.mcp.authUrl.copy")) { + override fun actionPerformed(e: ActionEvent) { + copyToClipboard(url, KiloBundle.message("settings.agentBehavior.mcp.authUrl.copy"), urlField) + } + } + + init { + title = KiloBundle.message("settings.agentBehavior.mcp.authUrl.title") + isModal = false + init() + } + + internal fun contentForTest(): JComponent = center ?: error("center panel not built") + + override fun createCenterPanel(): JComponent { + val panel = Stack.vertical(UiStyle.Gap.sm()) + .next(JBLabel(KiloBundle.message("settings.agentBehavior.mcp.authUrl.message", name))) + .next(urlField) + panel.border = JBUI.Borders.empty(UiStyle.Gap.pad()) + center = panel + return panel + } + + override fun createActions(): Array = arrayOf(openAction, copyAction, okAction) + + override fun getPreferredFocusedComponent(): JComponent = urlField +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index e67abf569b1..ca005149c7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloDocs import ai.kilocode.client.settings.base.DirectoryReadyConfigurable @@ -26,10 +27,12 @@ import com.intellij.openapi.application.asContextElement import com.intellij.openapi.components.service import com.intellij.openapi.ui.Messages import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import javax.swing.JComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() @@ -47,7 +50,7 @@ class McpConfigurable : DirectoryReadyConfigurable() { } internal class McpSettingsUi( - cs: CoroutineScope, + private val cs: CoroutineScope, dir: String, private val create: (String, McpConfigDto) -> McpEditDialogHandle = ::McpEditDialog, ) : SettingsListPanel(cs, ActiveListConfig.Equal.copy(description = false)) { @@ -87,7 +90,8 @@ internal class McpSettingsUi( when (cellId) { CONNECT_CELL -> mutate(key) { service().mcpConnect(dir, key) } DISCONNECT_CELL -> mutate(key) { service().mcpDisconnect(dir, key) } - AUTH_CELL -> mutate(key) { service().mcpAuthenticate(dir, key) } + AUTH_CELL -> signIn(key) + RESET_AUTH_CELL -> resetAuth(key) EDIT_CELL -> edit(key) REMOVE_CELL -> remove(key) } @@ -134,6 +138,9 @@ internal class McpSettingsUi( ActiveListCell(AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.signIn")).takeIf { status?.status == NEEDS_AUTH }, + ActiveListCell(RESET_AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.resetAuth")).takeIf { + cfg?.type == "remote" + }, ActiveListCell( EDIT_CELL, KiloBundle.message("settings.agentBehavior.edit"), @@ -166,6 +173,45 @@ internal class McpSettingsUi( } } + @RequiresEdt + private fun signIn(name: String) { + checkEdt() + val auth = service() + if (!launch("mcp sign in name=$name") { id -> + val result = auth.signIn(dir, name) + if (!withContext(edt) { active(id) }) return@launch + withContext(edt) { auth.report(name, result) } + if (result.status != "connected") { + throw SettingsMessageException( + result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + ) + } + val items = fetch() + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + view.update(items, ActiveListSelection.Key(name)) + clearProgress() + } + }) return + showProgress( + KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", name), + KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"), + ) { cs.launch { auth.cancel(dir, name) } } + } + + private fun resetAuth(name: String) { + val result = Messages.showYesNoDialog( + KiloBundle.message("settings.agentBehavior.mcp.resetAuth.message", name), + KiloBundle.message("settings.agentBehavior.mcp.resetAuth.title"), + KiloBundle.message("settings.agentBehavior.mcp.resetAuth"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (result != Messages.YES) return + mutate(name) { service().cancel(dir, name) } + } + private fun remove(name: String) { val result = Messages.showYesNoDialog( KiloBundle.message("settings.agentBehavior.mcp.delete.message", name), @@ -200,6 +246,7 @@ internal class McpSettingsUi( const val CONNECT_CELL = "connect" const val DISCONNECT_CELL = "disconnect" const val AUTH_CELL = "auth" + const val RESET_AUTH_CELL = "resetAuth" const val EDIT_CELL = "edit" const val REMOVE_CELL = "remove" val LOG = KiloLog.create(McpSettingsUi::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt index 8ba8ab8e89e..ea43c58ec8f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt @@ -11,12 +11,16 @@ import ai.kilocode.client.ui.layout.StackAxis import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListCell import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.CollectionListModel import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBTextField @@ -29,6 +33,8 @@ import java.awt.Dimension import java.awt.Rectangle import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.net.URI +import java.net.URISyntaxException import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel @@ -63,6 +69,31 @@ internal class McpEditDialog( columns = FIELD_COLUMNS emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.url.placeholder") } + private val oauthMode = ComboBox(arrayOf(OAUTH_AUTOMATIC, OAUTH_DISABLED, OAUTH_CUSTOM)).apply { + selectedItem = oauthModeFor(cfg.oauth) + addActionListener { syncOauthFields() } + } + private val oauthClientId = JBTextField(cfg.oauth?.clientId.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientId") + } + private val oauthClientSecret = JBPasswordField().apply { + columns = FIELD_COLUMNS + text = cfg.oauth?.clientSecret.orEmpty() + } + private val oauthScope = JBTextField(cfg.oauth?.scope.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.scope") + } + private val oauthCallbackPort = JBTextField(cfg.oauth?.callbackPort?.toString().orEmpty()).apply { + columns = ENV_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.callbackPort") + } + private val oauthRedirectUri = JBTextField(cfg.oauth?.redirectUri.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri") + } + private var oauthCustomRows: SettingsRows? = null private val env = linkedMapOf().apply { putAll(cfg.environment.orEmpty()) } private val key = JBTextField().apply { columns = ENV_COLUMNS @@ -83,7 +114,7 @@ internal class McpEditDialog( internal fun centerComponent(): JComponent = center ?: error("center panel not built") override fun result(): McpConfigDto { - if (type == REMOTE) return cfg.copy(url = text(url.text)) + if (type == REMOTE) return cfg.copy(url = text(url.text), oauth = oauthResult()) return cfg.copy( type = LOCAL, command = listOf(command.text.trim()) + args.text.lines().map { it.trim() }.filter { it.isNotEmpty() }, @@ -91,6 +122,19 @@ internal class McpEditDialog( ) } + private fun oauthResult(): McpOAuthDto? = when (oauthMode.selectedItem) { + OAUTH_DISABLED -> McpOAuthDto(enabled = false) + OAUTH_CUSTOM -> McpOAuthDto( + enabled = true, + clientId = text(oauthClientId.text), + clientSecret = String(oauthClientSecret.password).takeIf { it.isNotBlank() }, + scope = text(oauthScope.text), + callbackPort = oauthCallbackPort.text.trim().toIntOrNull(), + redirectUri = text(oauthRedirectUri.text), + ) + else -> if (cfg.oauth != null) McpOAuthDto(clear = true) else null + } + override fun createCenterPanel(): JComponent { val panel = BaseContentPanel().apply { border = JBUI.Borders.empty(UiStyle.Gap.pad()) @@ -112,6 +156,25 @@ internal class McpEditDialog( )) panel.next(this) } + panel.section( + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth"), + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.help"), + ).apply { + row(SettingsRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode"), null, oauthMode)) + oauthCustomRows = SettingsRows().apply { + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientId"), null, oauthClientId)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientSecret"), null, oauthClientSecret)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.scope"), null, oauthScope)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.callbackPort"), null, oauthCallbackPort)) + row(SettingsStackedRow( + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri"), + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri.help"), + oauthRedirectUri, + )) + } + row(oauthCustomRows!!) + } + syncOauthFields() } else { SettingsRows().apply { row(SettingsStackedRow( @@ -143,6 +206,32 @@ internal class McpEditDialog( override fun getDimensionServiceKey(): String = "Kilo.McpEditDialog" + override fun doValidate(): ValidationInfo? { + if (type != REMOTE || oauthMode.selectedItem != OAUTH_CUSTOM) return null + val port = oauthCallbackPort.text.trim() + if (port.isNotEmpty() && (port.toIntOrNull() == null || port.toInt() !in 1..65535)) { + return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.port.invalid"), oauthCallbackPort) + } + if (String(oauthClientSecret.password).isNotBlank() && text(oauthClientId.text) == null) { + return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.secret.invalid"), oauthClientId) + } + val redirect = oauthRedirectUri.text.trim() + if (redirect.isNotEmpty()) { + val valid = try { + URI(redirect).isAbsolute + } catch (_: URISyntaxException) { + false + } + if (!valid) return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid"), oauthRedirectUri) + } + return null + } + + private fun syncOauthFields() { + val custom = oauthMode.selectedItem == OAUTH_CUSTOM + oauthCustomRows?.isVisible = custom + } + private fun envEditor(): JComponent = Stack.vertical(UiStyle.Gap.sm()) .next(Stack.horizontal(UiStyle.Gap.sm()) .next(key) @@ -304,6 +393,15 @@ internal class McpEditDialog( const val FIELD_COLUMNS = 36 const val ENV_COLUMNS = 14 const val ENV_ROWS = 4 + val OAUTH_AUTOMATIC get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.automatic") + val OAUTH_DISABLED get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.disabled") + val OAUTH_CUSTOM get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + + fun oauthModeFor(oauth: McpOAuthDto?): String = when { + oauth == null -> OAUTH_AUTOMATIC + oauth.enabled == false -> OAUTH_DISABLED + else -> OAUTH_CUSTOM + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt index ffa12829bb6..a51824a65e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.marketplace import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloMarketplaceService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.SettingsBannerKind import ai.kilocode.client.settings.base.SettingsListPanel @@ -43,12 +44,13 @@ import javax.swing.JSeparator import javax.swing.SwingConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() internal class MarketplaceSettingsUi( - cs: CoroutineScope, + private val cs: CoroutineScope, dir: String, private val create: (MarketplaceItemDto, Boolean) -> MarketplaceInstallDialogHandle = ::MarketplaceInstallDialog, private val browse: (String) -> Unit = { BrowserUtil.browse(it) }, @@ -65,6 +67,8 @@ internal class MarketplaceSettingsUi( /** Set while a row action is in flight, so the finished reload hands focus back to the list. */ private var refocus = false + /** An MCP item that just installed needing sign-in. Drained by [afterApply] once the reload lands. */ + private var authPrompt: MarketplaceItemDto? = null private val allButton = textAction(this, allLabel()) { selectAllTypes() } private val agentChip = FilterChip(agentLabel(), UiStyle.Badge::typeAgent) { toggleType("agent") } @@ -254,6 +258,10 @@ internal class MarketplaceSettingsUi( if (request.remove) "Marketplace Item Removed" else "Marketplace Item Installed", mapOf("type" to item.type, "id" to item.id, "target" to request.target), ) + if (!request.remove && item.type == "mcp") { + val needs = service().refresh(dir) + if (item.id in needs) withContext(edt) { authPrompt = item } + } ok = true true } finally { @@ -294,10 +302,42 @@ internal class MarketplaceSettingsUi( override fun afterApply() { syncFilters() - if (!refocus) return - refocus = false - // Puts the row's action overlay back after a mutation, without stealing focus on a plain reload. - view.focusList() + if (refocus) { + refocus = false + // Puts the row's action overlay back after a mutation, without stealing focus on a plain reload. + view.focusList() + } + val item = authPrompt ?: return + authPrompt = null + promptSignIn(item) + } + + @RequiresEdt + private fun promptSignIn(item: MarketplaceItemDto) { + val answer = Messages.showYesNoDialog( + KiloBundle.message("settings.marketplace.mcp.signIn.message", item.name), + KiloBundle.message("settings.marketplace.mcp.signIn.title"), + KiloBundle.message("settings.marketplace.mcp.signIn.confirm"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (answer != Messages.YES) return + val name = item.id + val auth = service() + if (!launch("marketplace mcp sign in name=$name") { id -> + val result = auth.signIn(dir, name) + if (!withContext(edt) { active(id) }) return@launch + withContext(edt) { auth.report(name, result) } + if (result.status != "connected") { + throw SettingsMessageException( + result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + ) + } + }) return + showProgress( + KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", name), + KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"), + ) { cs.launch { auth.cancel(dir, name) } } } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 6f8be283769..726fba96e40 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -889,6 +889,42 @@ settings.agentBehavior.mcp.status.failed=failed settings.agentBehavior.mcp.status.needsAuth=needs auth settings.agentBehavior.mcp.status.needsRegistration=needs registration settings.agentBehavior.mcp.status.disabled=disabled +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.skills.info=Skills are instruction packs the agent loads only when the task at hand matches one. settings.agentBehavior.skills.info.more=Each skill is a SKILL.md file with a short description. Kilo keeps only that description in context and reads the full body when the agent decides the skill applies, so many skills stay cheap. Skills come from this project, your global Kilo config, and any extra folders or URLs listed under Additional Skill Sources. New and changed skills apply after the core reloads, which is deferred while a session is running. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 0ab58655956..9d4659ec1b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=لإضافة خادم MCP، اطلب من الوكيل إضافته. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=المهارات settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index e3b6f80decc..90f0b3f65a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Da dodate MCP server, zamolite agenta da ga doda. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Vještine settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 611975680a3..1bfcfdb3cc1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=For at tilføje en MCP-server skal du bede agenten om at gøre det. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Færdigheder settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 8e1f29454c3..48bca4c5904 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Um einen MCP-Server hinzuzufügen, bitten Sie den Agenten darum. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 16e13e5c956..41e20fd90ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Para agregar un servidor MCP, pídele al agente que lo haga. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 1c895d3ef16..e68a1c87d19 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Pour ajouter un serveur MCP, demandez à l’agent de le faire. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Compétences settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 1679ac90615..d9a89c931f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCPサーバーを追加するには、エージェントに依頼してください。 +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=スキル settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 0c56257777e..434a370c619 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCP 서버를 추가하려면 에이전트에게 요청하세요. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=스킬 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 487be1983f8..a7da9e11843 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Vraag de agent om een MCP-server toe te voegen. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Vaardigheden settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 448524bafd8..963982f0ffc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Be agenten om å legge til en MCP-server. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Ferdigheter settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 6ab9e339149..45da4281872 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Aby dodać serwer MCP, poproś agenta, aby to zrobił. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Umiejętności settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index cdff5c1f8f1..6fbb0a67e2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Para adicionar um servidor MCP, peça ao agente para fazer isso. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index a76af6133e0..dd8516864ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Чтобы добавить MCP-сервер, попросите агента сделать это. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Навыки settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 73fc997e6f8..88a524a9f79 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=หากต้องการเพิ่มเซิร์ฟเวอร์ MCP ให้ขอให้เอเจนต์เพิ่มให้ +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=ทักษะ settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index e92774183fa..7d2921d1343 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCP sunucusu eklemek için ajandan bunu yapmasını isteyin. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Beceriler settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index e9c65c6ad12..5c606cd8866 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Щоб додати сервер MCP, попросіть агента зробити це. +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=Навички settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index bd75134cf3f..0aa966b39a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=要添加 MCP 服务器,请让代理为你添加。 +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 35e96edf8e2..5583785d7c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -439,6 +439,42 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=若要新增 MCP 伺服器,請請代理為你新增。 +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open MCP Settings settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt index d4ff442a93d..e669de63bf1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt @@ -3,13 +3,17 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandFileDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.test.assertFailsWith @@ -144,7 +148,7 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() { assertTrue(connected) assertTrue(disconnected) - assertTrue(authenticated) + assertEquals("connected", authenticated.status) assertEquals(listOf("filesystem"), rpc.mcpConnects) assertEquals(listOf("github"), rpc.mcpDisconnects) assertEquals(listOf("linear"), rpc.mcpAuthentications) @@ -161,4 +165,20 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() { assertFalse(ok) assertTrue(rpc.mcpConnects.isEmpty()) } + + fun `test mcp auth remove forwards name`() = runBlocking { + val ok = withContext(Dispatchers.Default) { service.mcpAuthRemove("/test", "linear") } + + assertTrue(ok) + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + } + + fun `test mcp auth events pass through`() = runBlocking { + val events = withContext(Dispatchers.Default) { service.mcpAuthEvents() } + val first = async(Dispatchers.Default) { events.first() } + rpc.mcpAuthEventsFlow.subscriptionCount.first { it > 0 } + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://example.com/authorize")) + + assertEquals(McpAuthEventDto("linear", "https://example.com/authorize"), first.await()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt new file mode 100644 index 00000000000..ee076522db0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt @@ -0,0 +1,197 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import ai.kilocode.rpc.dto.McpStatusDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * Covers [KiloMcpAuthService]. + * + * The service takes test-only constructor seams for the auth timeout, the dedupe window, and the + * browser-open-failed dialog action, so this timeout and dedupe behavior can be exercised directly + * instead of waiting out the real 6-minute production timeout or opening a real modal dialog. + */ +@Suppress("UnstableApiUsage") +class KiloMcpAuthServiceTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeAgentBehaviorRpcApi + private lateinit var behavior: KiloAgentBehaviorService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeAgentBehaviorRpcApi() + behavior = KiloAgentBehaviorService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + private fun service( + authTimeoutMs: Long = 60_000L, + dedupeWindowMs: Long = 4000L, + showAuthUrl: (String, String) -> Unit = { _, _ -> }, + ) = KiloMcpAuthService(scope, behavior, authTimeoutMs, dedupeWindowMs, showAuthUrl) + + private fun settle() = runBlocking { + repeat(3) { + delay(50) + UIUtil.dispatchAllInvocationEvents() + } + } + + fun `test refresh collects only needs_auth servers`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf( + McpStatusDto("linear", "needs_auth"), + McpStatusDto("filesystem", "connected"), + McpStatusDto("github", "needs_auth"), + ) + val service = service() + + val needs = service.refresh("/test") + + assertEquals(setOf("linear", "github"), needs) + assertEquals(setOf("linear", "github"), service.needsAuth.value["/test"]) + } + + fun `test refresh with blank directory returns empty and does not call rpc`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + val service = service() + + val needs = service.refresh("") + + assertTrue(needs.isEmpty()) + assertTrue(rpc.mcpCalls.isEmpty()) + } + + fun `test signIn returns connected result and clears needs_auth`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + val service = service() + service.refresh("/test") + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + + val result = service.signIn("/test", "linear") + + assertEquals("connected", result.status) + assertTrue(service.needsAuth.value["/test"].orEmpty().isEmpty()) + } + + fun `test signIn is single-flight per directory and name`() = runBlocking(Dispatchers.Default) { + // Gates the fake's authenticate call itself, so signIn has already recorded the busy key + // (which happens before the authenticate call) by the time the second call races it. + val gate = CompletableDeferred() + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + rpc.beforeAuthenticate = { gate.await() } + val service = service() + val first = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + // The first call has claimed the busy key and is blocked inside authenticate; the second + // call must see the busy key and refuse to start a duplicate authenticate request. + val second = service.signIn("/test", "linear") + assertEquals("failed", second.status) + assertEquals(0, rpc.mcpAuthentications.size) + + gate.complete(Unit) + val firstResult = first.await() + assertEquals("connected", firstResult.status) + assertEquals(1, rpc.mcpAuthentications.size) + } + + fun `test signIn on timeout removes credentials and reports timeout`() = runBlocking(Dispatchers.Default) { + // The fake's mcpAuthenticate never returns, so withTimeoutOrNull must fire first and the + // service must fall back to removing credentials and reporting a timeout status. + val slowRpc = SlowAuthenticateRpc(CompletableDeferred()) + val slowBehavior = KiloAgentBehaviorService(scope, slowRpc) + val slowService = KiloMcpAuthService(scope, slowBehavior, authTimeoutMs = 100L, dedupeWindowMs = 4000L) { _, _ -> } + + val result = slowService.signIn("/test", "linear") + + assertEquals("timeout", result.status) + assertEquals(listOf("linear"), slowRpc.mcpAuthRemovals) + } + + fun `test cancel calls mcpAuthRemove`() = runBlocking(Dispatchers.Default) { + rpc.mcpAuthRemoveResult = true + val service = service() + + val ok = service.cancel("/test", "linear") + + assertTrue(ok) + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + } + + fun `test duplicate browser open failed events within the dedupe window are collapsed`() { + val opened = mutableListOf>() + val service = service(dedupeWindowMs = 60_000L, showAuthUrl = { name, url -> opened.add(name to url) }) + + runBlocking(Dispatchers.Default) { + service.refresh("/test") + } + settle() + + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize")) + } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize")) + } + settle() + + assertEquals(listOf("linear" to "https://auth.example.test/authorize"), opened) + } + + fun `test browser open failed events with different urls both open`() { + val opened = mutableListOf>() + val service = service(dedupeWindowMs = 0L, showAuthUrl = { name, url -> opened.add(name to url) }) + + runBlocking(Dispatchers.Default) { + service.refresh("/test") + } + settle() + + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize?a=1")) + } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize?a=2")) + } + settle() + + assertEquals(2, opened.size) + } + + /** A [KiloAgentBehaviorService] backed by an RPC fake whose `mcpAuthenticate` never returns. */ + private class SlowAuthenticateRpc(private val never: CompletableDeferred) : + ai.kilocode.rpc.KiloAgentBehaviorRpcApi by FakeAgentBehaviorRpcApi() { + val mcpAuthRemovals = mutableListOf() + + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto = never.await() + + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + mcpAuthRemovals.add(name) + return true + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt index 549a5e1995c..4b31c6c0a2f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -107,6 +107,23 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { } } + fun `test mcp auth keys are present and format in every locale`() { + for (locale in LOCALES) { + val props = load(locale) + for ((key, args) in MCP_AUTH) { + val pattern = props.getProperty(key) + assertNotNull("$locale: missing $key", pattern) + assertTrue("$locale: $key is blank", pattern!!.isNotBlank()) + assertEscaped(locale, key, pattern) + val rendered = format(pattern, *args.toTypedArray()) + for (arg in args) { + assertTrue("$locale: $key dropped $arg -> $rendered", rendered.contains(arg)) + } + assertClean(locale, key, rendered) + } + } + } + fun `test localized bundles do not carry stale keys`() { val base = load("en").stringPropertyNames() for (locale in LOCALES.filterNot { it == "en" }) { @@ -330,6 +347,32 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "session.empty.worktree.unknown", "action.Kilo.NewSession.toolbar", "action.Kilo.NewWorktree.toolbar", + "settings.agentBehavior.mcp.signIn.cancel", + "settings.agentBehavior.mcp.resetAuth", + "settings.agentBehavior.mcp.resetAuth.title", + "settings.agentBehavior.mcp.resetAuth.failed", + "settings.agentBehavior.mcp.authUrl.title", + "settings.agentBehavior.mcp.authUrl.open", + "settings.agentBehavior.mcp.authUrl.copy", + "settings.agentBehavior.mcp.edit.oauth", + "settings.agentBehavior.mcp.edit.oauth.help", + "settings.agentBehavior.mcp.edit.oauth.mode", + "settings.agentBehavior.mcp.edit.oauth.mode.automatic", + "settings.agentBehavior.mcp.edit.oauth.mode.disabled", + "settings.agentBehavior.mcp.edit.oauth.mode.custom", + "settings.agentBehavior.mcp.edit.oauth.clientId", + "settings.agentBehavior.mcp.edit.oauth.clientSecret", + "settings.agentBehavior.mcp.edit.oauth.scope", + "settings.agentBehavior.mcp.edit.oauth.callbackPort", + "settings.agentBehavior.mcp.edit.oauth.redirectUri", + "settings.agentBehavior.mcp.edit.oauth.redirectUri.help", + "settings.agentBehavior.mcp.edit.oauth.port.invalid", + "settings.agentBehavior.mcp.edit.oauth.secret.invalid", + "settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid", + "settings.marketplace.mcp.signIn.title", + "settings.marketplace.mcp.signIn.confirm", + "prompt.mcp.needsAuth.busy", + "prompt.mcp.openSettings", ) val AGENT = mapOf( @@ -439,5 +482,18 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "worktree.diagnostics.noProject" to emptyList(), "settings.agentBehavior.extended.title" to emptyList(), ) + + val MCP_AUTH = mapOf( + "settings.agentBehavior.mcp.signIn.progress" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.success" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.failed" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.timeout" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.unsupported" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.resetAuth.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.authUrl.message" to listOf("SERVER_NAME"), + "settings.marketplace.mcp.signIn.message" to listOf("SERVER_NAME"), + "prompt.mcp.needsAuth.one" to listOf("SERVER_NAME"), + "prompt.mcp.needsAuth.many" to listOf("7"), + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 31d7de70c9b..1cf60a34bea 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1117,6 +1117,58 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.resetVisibleForTest()) } + fun `test mcp auth indicator is hidden with no servers and not busy`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + panel.setMcpAuth(emptyList(), busy = false) + + assertFalse(panel.mcpAuthForTest().isVisible) + } + + fun `test mcp auth indicator shows singular tooltip for one server`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + panel.setMcpAuth(listOf("linear"), busy = false) + + val icon = panel.mcpAuthForTest() + assertTrue(icon.isVisible) + assertTrue(icon.isEnabled) + assertEquals(KiloBundle.message("prompt.mcp.needsAuth.one", "linear"), icon.toolTipText) + } + + fun `test mcp auth indicator shows plural tooltip for several servers`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + panel.setMcpAuth(listOf("linear", "github"), busy = false) + + assertEquals( + KiloBundle.message("prompt.mcp.needsAuth.many", 2), + panel.mcpAuthForTest().toolTipText, + ) + } + + fun `test mcp auth indicator disables and swaps icon while busy`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + panel.setMcpAuth(listOf("linear"), busy = true) + + val icon = panel.mcpAuthForTest() + assertTrue("busy still shows the affordance", icon.isVisible) + assertFalse(icon.isEnabled) + assertEquals(KiloBundle.message("prompt.mcp.needsAuth.busy"), icon.toolTipText) + } + + fun `test mcp auth indicator click invokes onMcpAuthClick`() { + var clicked = false + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + panel.onMcpAuthClick = { clicked = true } + panel.setMcpAuth(listOf("linear"), busy = false) + + (panel.mcpAuthForTest() as JButton).doClick() + + assertTrue(clicked) + } + fun `test prompt editor exposes send context`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt new file mode 100644 index 00000000000..7e45d2de146 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextField +import java.awt.Component +import java.awt.Container + +class McpAuthUrlDialogTest : BasePlatformTestCase() { + private var dialog: McpAuthUrlDialog? = null + + override fun tearDown() { + try { + dialog?.let { item -> edt { Disposer.dispose(item.disposable); true } } + dialog = null + } finally { + super.tearDown() + } + } + + fun `test url is rendered read only in the content panel`() { + val d = open("linear", "https://auth.example.test/authorize?state=abc") + + edt { + val field = descendants(d.contentForTest()).filterIsInstance().single() + assertEquals("https://auth.example.test/authorize?state=abc", field.text) + assertFalse("the URL field must not be user-editable", field.isEditable) + true + } + } + + fun `test message includes the server name`() { + val d = open("linear", "https://auth.example.test/authorize") + + edt { + val expected = KiloBundle.message("settings.agentBehavior.mcp.authUrl.message", "linear") + assertTrue(text(d.contentForTest()).contains(expected)) + true + } + } + + fun `test title includes finish sign in wording`() { + val d = open("linear", "https://auth.example.test/authorize") + + edt { + assertEquals(KiloBundle.message("settings.agentBehavior.mcp.authUrl.title"), d.title) + true + } + } + + private fun open(name: String, url: String): McpAuthUrlDialog { + val item = edt { McpAuthUrlDialog(name, url) } + dialog = item + return item + } + + private fun text(root: Component): String = descendants(root) + .filterIsInstance() + .joinToString(" ") { it.text.orEmpty() } + + private fun descendants(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach(::visit) + } + visit(root) + return out + } + + private fun edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt index e61524dce0b..c663bf878a0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt @@ -6,10 +6,13 @@ import ai.kilocode.client.settings.base.SettingsRow import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.testing.fire import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBTextField import java.awt.Component @@ -120,6 +123,93 @@ class McpEditDialogTest : BasePlatformTestCase() { assertEquals(mapOf("TOKEN" to "x", "EMPTY" to ""), result.environment) } + fun `test oauth section only shown for remote servers`() { + val local = open(local()) + edt { + assertFalse(hasLabel(local.centerComponent(), title("oauth"))) + true + } + val remote = open(remote()) + edt { + assertTrue(hasLabel(remote.centerComponent(), title("oauth"))) + true + } + } + + fun `test oauth defaults to automatic when config has no oauth block`() { + val d = open(remote()) + + val result = edt { d.result() } + + assertNull(result.oauth) + } + + fun `test oauth mode disabled writes enabled false`() { + val d = open(remote()) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.disabled") + d.result() + } + + assertEquals(McpOAuthDto(enabled = false), result.oauth) + } + + fun `test oauth mode custom writes client fields`() { + val d = open(remote()) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + field(root, title("oauth.clientId")).text = "abc" + descendants(root).filterIsInstance().single().text = "shh" + field(root, title("oauth.scope")).text = "read" + field(root, title("oauth.callbackPort")).text = "19999" + d.result() + } + + assertEquals( + McpOAuthDto(enabled = true, clientId = "abc", clientSecret = "shh", scope = "read", callbackPort = 19999), + result.oauth, + ) + } + + fun `test switching back to automatic after custom clears the oauth block when one existed`() { + val cfg = remote().copy(oauth = McpOAuthDto(enabled = true, clientId = "abc")) + val d = open(cfg) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.automatic") + d.result() + } + + assertEquals(McpOAuthDto(clear = true), result.oauth) + } + + /** + * Regression guard for the silent-drop bug: `Config.mergeDeep` clears `headers`/`oauth` whenever + * a remote server's `type` or `url` changes unless the patch explicitly carries them forward. + * Editing only the URL must not lose a previously configured OAuth client. + */ + fun `test editing only the url preserves an existing custom oauth client`() { + val cfg = remote().copy(oauth = McpOAuthDto(enabled = true, clientId = "abc", scope = "read")) + val d = open(cfg) + + val result = edt { + val root = d.centerComponent() + field(root, title("url")).text = "https://retargeted.example.test/mcp" + d.result() + } + + assertEquals("https://retargeted.example.test/mcp", result.url) + assertEquals(McpOAuthDto(enabled = true, clientId = "abc", scope = "read"), result.oauth) + } + + private fun oauthModeBox(root: Component): ComboBox = + descendants(root).filterIsInstance>().single() + private fun local() = McpConfigDto( type = "local", command = listOf("node", "server.js", "--flag"), @@ -160,6 +250,10 @@ class McpEditDialogTest : BasePlatformTestCase() { descendants(item).any { it is JLabel && it.text == title } } + /** Broader than [hasRow]: matches any descendant label text, including section titles. */ + private fun hasLabel(root: Component, text: String): Boolean = + descendants(root).filterIsInstance().any { it.text == text } + private fun envLabels(root: Component): List { val list = descendants(root).filterIsInstance>().singleOrNull() ?: return emptyList() return (0 until list.model.size).map { list.model.getElementAt(it).toString() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index 86d546961f6..a598985e729 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.DirectoryReadyConfigurable import ai.kilocode.client.settings.base.SettingsInfo @@ -15,11 +16,13 @@ import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto import com.intellij.icons.AllIcons import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager @@ -41,6 +44,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class McpSettingsUiTest : BasePlatformTestCase() { @@ -88,7 +92,7 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("failed"), rows.single { it.key == "runtime" }.badges.map { it.text }) assertEquals("crashed", rows.single { it.key == "runtime" }.description) val github = rows.single { it.key == "github" } - assertEquals(listOf("connect", "auth", "edit", "remove"), github.cells.map { it.id }) + assertEquals(listOf("connect", "auth", "resetAuth", "edit", "remove"), github.cells.map { it.id }) assertTrue(github.cells.single { it.id == "edit" }.primary) assertEquals(KiloBundle.message("settings.agentBehavior.mcp.connect"), github.cells.single { it.id == "connect" }.label) val remove = github.cells.single { it.id == "remove" } @@ -331,6 +335,80 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertTrue(edt { rows(panel).any { it.key == "runtime" } }) } + fun `test sign in success reconnects and clears the auth cell`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + + click(panel, "github", "auth") + // The fake authenticate call succeeds instantly; the ensuing reload picks up a status that + // the test flips to connected, mirroring what a real reconnect would report. + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == "github" } + McpStatusDto("github", "connected") + + flushUntil { rows(panel).single { it.key == "github" }.badges.first().text == "connected" } + assertEquals(listOf("github"), agentRpc.mcpAuthentications) + } + + fun `test sign in failure shows the CLI error`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthenticateResult = McpAuthResultDto("failed", "denied by server") + + click(panel, "github", "auth") + + flushUntil { text(panel).contains("denied by server") } + assertTrue(edt { rows(panel).any { it.key == "github" } }) + } + + fun `test sign in cancel calls mcpAuthRemove`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthRemoveResult = true + + edt { + // Directly exercises the cancel path the progress overlay's Cancel button wires to, + // since driving the real overlay button requires a longer-lived in-flight sign-in. + scope!!.launch { service().cancel(DIR, "github") } + true + } + + flushUntil { agentRpc.mcpAuthRemovals.contains("github") } + } + + fun `test reset sign in cell only appears for remote servers`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val rows = rows(panel) + assertTrue(rows.single { it.key == "github" }.cells.any { it.id == "resetAuth" }) + assertFalse(rows.single { it.key == "filesystem" }.cells.any { it.id == "resetAuth" }) + true + } + } + + fun `test reset sign in requires confirmation and calls mcpAuthRemove on accept`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthRemoveResult = true + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "github", "resetAuth") + + flushUntil { agentRpc.mcpAuthRemovals.contains("github") } + } + + fun `test reset sign in decline does not call mcpAuthRemove`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "github", "resetAuth") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(agentRpc.mcpAuthRemovals.isEmpty()) + } + fun `test missing directory still shows configured mcp servers`() { install() val panel = edt { McpSettingsUi(scope!!, "") } @@ -389,7 +467,9 @@ class McpSettingsUiTest : BasePlatformTestCase() { app._state.value = ready appRpc.state.value = ready ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) - ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + val behavior = KiloAgentBehaviorService(cs, agentRpc) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, behavior, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloMcpAuthService::class.java, KiloMcpAuthService(cs, behavior), testRootDisposable) } private fun click(panel: McpSettingsUi, key: String, id: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt index 12be982a470..bd768fc2102 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.marketplace import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloMarketplaceService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.DirectoryReadyConfigurable import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi @@ -15,6 +16,8 @@ import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -375,6 +378,65 @@ class MarketplaceSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf(DIR), agentRpc.skillReloads) } + fun `test successful MCP install that needs auth prompts to sign in`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + var prompted = 0 + TestDialogManager.setTestDialog { + prompted++ + Messages.NO + } + + click(panel, "mcp:context7", "install") + + flushUntil { prompted == 1 } + assertEquals(1, prompted) + assertTrue(agentRpc.mcpAuthentications.isEmpty()) + } + + fun `test declining the sign in prompt leaves the install successful`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "mcp:context7", "install") + + flushUntil { marketRpc.listCalls.size == 2 } + assertTrue(marketRpc.installCalls.isNotEmpty()) + assertTrue(agentRpc.mcpAuthentications.isEmpty()) + } + + fun `test accepting the sign in prompt calls mcpAuthenticate`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "mcp:context7", "install") + + flushUntil { agentRpc.mcpAuthentications.contains("context7") } + } + + fun `test a connected MCP install shows no sign in prompt`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "connected")) + var prompted = 0 + TestDialogManager.setTestDialog { + prompted++ + Messages.NO + } + + click(panel, "mcp:context7", "install") + + flushUntil { marketRpc.listCalls.size == 2 } + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertEquals(0, prompted) + } + fun `test switching the install dialog to an installed MCP scope removes and refreshes Skills`() { val panel = panel { item, directory -> val dialog = MarketplaceInstallDialog(item, directory) @@ -633,7 +695,9 @@ class MarketplaceSettingsUiTest : BasePlatformTestCase() { appRpc.state.value = ready ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) ApplicationManager.getApplication().replaceService(KiloMarketplaceService::class.java, KiloMarketplaceService(cs, marketRpc), testRootDisposable) - ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + val behavior = KiloAgentBehaviorService(cs, agentRpc) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, behavior, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloMcpAuthService::class.java, KiloMcpAuthService(cs, behavior), testRootDisposable) } private fun click(panel: MarketplaceSettingsUi, key: String, id: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index 73fbec775df..e35323ab422 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -5,11 +5,15 @@ import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var agents = emptyList() @@ -33,6 +37,8 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { val mcpConnects = mutableListOf() val mcpDisconnects = mutableListOf() val mcpAuthentications = mutableListOf() + val mcpAuthRemovals = mutableListOf() + val mcpAuthEventsFlow = MutableSharedFlow(extraBufferCapacity = 8) val creations = mutableListOf() val createDirs = mutableListOf() var afterCreate: (suspend (String, AgentCreateDto) -> Unit)? = null @@ -56,7 +62,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var saveCommandResult = true var mcpConnectResult = true var mcpDisconnectResult = true - var mcpAuthenticateResult = true + var mcpAuthenticateResult = McpAuthResultDto("connected") + var mcpAuthRemoveResult = true + @Volatile var mcpAuthenticateStarted = false + var beforeAuthenticate: (suspend () -> Unit)? = null var claudeCodeCompat = false val compatSaves = mutableListOf() @@ -199,12 +208,25 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { return mcpDisconnectResult } - override suspend fun mcpAuthenticate(directory: String, name: String): Boolean { + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto { assertNotEdt("agentBehavior.mcpAuthenticate") + mcpAuthenticateStarted = true + beforeAuthenticate?.invoke() mcpAuthentications.add(name) return mcpAuthenticateResult } + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + assertNotEdt("agentBehavior.mcpAuthRemove") + mcpAuthRemovals.add(name) + return mcpAuthRemoveResult + } + + override suspend fun mcpAuthEvents(): Flow { + assertNotEdt("agentBehavior.mcpAuthEvents") + return mcpAuthEventsFlow + } + override suspend fun claudeCodeCompat(): Boolean { assertNotEdt("agentBehavior.claudeCodeCompat") return claudeCodeCompat diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt index ac82b9de313..2d0cfe92247 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt @@ -4,6 +4,8 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto @@ -12,6 +14,7 @@ import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow @Rpc interface KiloAgentBehaviorRpcApi : RemoteApi { @@ -57,7 +60,11 @@ interface KiloAgentBehaviorRpcApi : RemoteApi { suspend fun mcpDisconnect(directory: String, name: String): Boolean - suspend fun mcpAuthenticate(directory: String, name: String): Boolean + suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto + + suspend fun mcpAuthRemove(directory: String, name: String): Boolean + + suspend fun mcpAuthEvents(): Flow suspend fun claudeCodeCompat(): Boolean diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentBehaviorDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentBehaviorDto.kt index cb14dab8223..2af452a653b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentBehaviorDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentBehaviorDto.kt @@ -44,3 +44,15 @@ data class McpServerConfigDto( val config: McpConfigDto, val scope: String, ) + +@Serializable +data class McpAuthResultDto( + val status: String, + val error: String? = null, +) + +@Serializable +data class McpAuthEventDto( + val name: String, + val url: String, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index a9a3e66b40c..416adbdb31e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -112,6 +112,18 @@ data class McpConfigDto( val headers: Map? = null, val enabled: Boolean? = null, val timeout: Long? = null, + val oauth: McpOAuthDto? = null, +) + +@Serializable +data class McpOAuthDto( + val enabled: Boolean? = null, + val clientId: String? = null, + val clientSecret: String? = null, + val scope: String? = null, + val callbackPort: Int? = null, + val redirectUri: String? = null, + val clear: Boolean = false, ) typealias PermissionConfigDto = Map From 0c0a9e7f7dd9a2efacbf259d5f08b421d1ffb2e5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 17:14:10 -0400 Subject: [PATCH 033/353] fix(jetbrains): address MCP OAuth review feedback Keep Marketplace authentication progress visible, treat user cancellation as a quiet outcome, and bound cached MCP authentication state across Agent Manager worktrees. --- .../kilocode/client/app/KiloMcpAuthService.kt | 24 ++++++++++++++--- .../client/settings/agents/McpConfigurable.kt | 2 +- .../marketplace/MarketplaceSettingsUi.kt | 10 +++++-- .../client/app/KiloMcpAuthServiceTest.kt | 26 +++++++++++++++++++ .../marketplace/MarketplaceSettingsUiTest.kt | 23 ++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt index 6e7eae9b6d0..28ce8341ccd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt @@ -49,6 +49,7 @@ class KiloMcpAuthService internal constructor( // so the CLI names the failure first. private const val DEFAULT_AUTH_TIMEOUT_MS = 6 * 60 * 1000L private const val DEFAULT_DEDUPE_WINDOW_MS = 4000L + private const val MAX_DIRECTORIES = 64 } private fun svc(): KiloAgentBehaviorService = behavior ?: service() @@ -59,6 +60,7 @@ class KiloMcpAuthService internal constructor( private val _busy = MutableStateFlow>(emptySet()) val busy: StateFlow> = _busy.asStateFlow() private val active = ConcurrentHashMap.newKeySet() + private val cancelled = ConcurrentHashMap.newKeySet() private val started = AtomicBoolean(false) private val lastEventUrl = AtomicReference(null) @@ -72,7 +74,11 @@ class KiloMcpAuthService internal constructor( .filter { it.status == "needs_auth" } .map { it.name } .toSet() - _needsAuth.update { it + (dir to names) } + _needsAuth.update { current -> + val next = current + (dir to names) + if (next.size <= MAX_DIRECTORIES) return@update next + next.entries.drop(next.size - MAX_DIRECTORIES).associate { it.toPair() } + } return names } @@ -87,26 +93,36 @@ class KiloMcpAuthService internal constructor( svc().mcpAuthenticate(dir, name) } } - if (result != null) return result + if (result != null) { + if (cancelled.contains(key)) return McpAuthResultDto("cancelled", null) + return result + } attempt("mcp auth timeout cleanup failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } McpAuthResultDto("timeout", null) } finally { active.remove(key) + cancelled.remove(key) _busy.update { it - key } refresh(dir) } } /** Cancels a pending sign-in and clears any stored credentials for [name]. */ - suspend fun cancel(dir: String, name: String): Boolean = - attempt("mcp auth cancel failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } + suspend fun cancel(dir: String, name: String): Boolean { + val key = busyKey(dir, name) + if (active.contains(key)) cancelled.add(key) + val removed = attempt("mcp auth cancel failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } + if (!removed) cancelled.remove(key) + return removed + } /** Reports a sign-in [result] for [name] via a Kilo notification. Must run on EDT. */ @RequiresEdt fun report(name: String, result: McpAuthResultDto) { when (result.status) { + "cancelled" -> Unit "connected" -> KiloNotifications.info( KiloBundle.message("settings.agentBehavior.mcp.signIn.success", name), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index ca005149c7e..d0b4e15330b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -181,7 +181,7 @@ internal class McpSettingsUi( val result = auth.signIn(dir, name) if (!withContext(edt) { active(id) }) return@launch withContext(edt) { auth.report(name, result) } - if (result.status != "connected") { + if (result.status != "connected" && result.status != "cancelled") { throw SettingsMessageException( result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt index a51824a65e5..143674ccf73 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt @@ -27,6 +27,7 @@ import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.asContextElement @@ -309,7 +310,7 @@ internal class MarketplaceSettingsUi( } val item = authPrompt ?: return authPrompt = null - promptSignIn(item) + ApplicationManager.getApplication().invokeLater({ promptSignIn(item) }, ModalityState.any()) } @RequiresEdt @@ -328,11 +329,16 @@ internal class MarketplaceSettingsUi( val result = auth.signIn(dir, name) if (!withContext(edt) { active(id) }) return@launch withContext(edt) { auth.report(name, result) } - if (result.status != "connected") { + if (result.status != "connected" && result.status != "cancelled") { throw SettingsMessageException( result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), ) } + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + clearProgress() + } }) return showProgress( KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", name), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt index ee076522db0..a4e819d49ab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt @@ -82,6 +82,17 @@ class KiloMcpAuthServiceTest : BasePlatformTestCase() { assertTrue(rpc.mcpCalls.isEmpty()) } + fun `test refresh bounds directory state`() = runBlocking(Dispatchers.Default) { + val service = service() + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + + repeat(65) { service.refresh("/test/$it") } + + assertEquals(64, service.needsAuth.value.size) + assertFalse(service.needsAuth.value.containsKey("/test/0")) + assertTrue(service.needsAuth.value.containsKey("/test/64")) + } + fun `test signIn returns connected result and clears needs_auth`() = runBlocking(Dispatchers.Default) { rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) val service = service() @@ -140,6 +151,21 @@ class KiloMcpAuthServiceTest : BasePlatformTestCase() { assertEquals(listOf("linear"), rpc.mcpAuthRemovals) } + fun `test cancelling an active sign in returns a quiet cancelled result`() = runBlocking(Dispatchers.Default) { + val gate = CompletableDeferred() + rpc.beforeAuthenticate = { gate.await() } + rpc.mcpAuthenticateResult = McpAuthResultDto("failed", "Browser authorization failed: Authorization cancelled") + val service = service() + val result = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + assertTrue(service.cancel("/test", "linear")) + gate.complete(Unit) + + assertEquals("cancelled", result.await().status) + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + } + fun `test duplicate browser open failed events within the dedupe window are collapsed`() { val opened = mutableListOf>() val service = service(dedupeWindowMs = 60_000L, showAuthUrl = { name, url -> opened.add(name to url) }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt index bd768fc2102..6400c6b33c7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt @@ -420,6 +420,29 @@ class MarketplaceSettingsUiTest : BasePlatformTestCase() { flushUntil { agentRpc.mcpAuthentications.contains("context7") } } + fun `test marketplace sign in keeps progress and cancel visible while authentication waits`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + val gate = CompletableDeferred() + agentRpc.beforeAuthenticate = { gate.await() } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "mcp:context7", "install") + + flushUntil { agentRpc.mcpAuthenticateStarted } + edt { + val visible = text(panel) + assertTrue(visible.contains(KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", "context7"))) + assertTrue(visible.contains(KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"))) + true + } + + gate.complete(Unit) + flushUntil { agentRpc.mcpAuthentications.contains("context7") } + } + fun `test a connected MCP install shows no sign in prompt`() { val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } flushUntil { rows(panel).size == 3 } From 09a6ff36f1ad820fbe1ba9fd53d19805e8a851be Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 18:10:04 -0400 Subject: [PATCH 034/353] fix(jetbrains): refresh MCP auth after reset Reconnect remote MCP servers after clearing credentials so the runtime immediately reports needs_auth and the chat toolbar can offer sign-in before another tool call fails. --- .../ai/kilocode/client/app/KiloMcpAuthService.kt | 10 ++++++++++ .../client/settings/agents/McpConfigurable.kt | 2 +- .../kilocode/client/app/KiloMcpAuthServiceTest.kt | 13 +++++++++++++ .../client/settings/agents/McpSettingsUiTest.kt | 8 +++++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt index 28ce8341ccd..383bb22ecb2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt @@ -118,6 +118,16 @@ class KiloMcpAuthService internal constructor( return removed } + /** Clears stored credentials and reconnects so the runtime immediately reports [needsAuth]. */ + suspend fun reset(dir: String, name: String): Boolean { + val removed = attempt("mcp auth reset failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } + if (!removed) return false + val disconnected = svc().mcpDisconnect(dir, name) + val connected = svc().mcpConnect(dir, name) + refresh(dir) + return disconnected && connected + } + /** Reports a sign-in [result] for [name] via a Kilo notification. Must run on EDT. */ @RequiresEdt fun report(name: String, result: McpAuthResultDto) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index d0b4e15330b..fcfa62356fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -209,7 +209,7 @@ internal class McpSettingsUi( Messages.getQuestionIcon(), ) if (result != Messages.YES) return - mutate(name) { service().cancel(dir, name) } + mutate(name) { service().reset(dir, name) } } private fun remove(name: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt index a4e819d49ab..f4719300ab8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt @@ -151,6 +151,19 @@ class KiloMcpAuthServiceTest : BasePlatformTestCase() { assertEquals(listOf("linear"), rpc.mcpAuthRemovals) } + fun `test reset reconnects and refreshes needs auth state`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + rpc.afterMcpConnect = { _, name -> rpc.mcps = listOf(McpStatusDto(name, "needs_auth")) } + val service = service() + + assertTrue(service.reset("/test", "linear")) + + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + assertEquals(listOf("linear"), rpc.mcpDisconnects) + assertEquals(listOf("linear"), rpc.mcpConnects) + assertEquals(setOf("linear"), service.needsAuth.value["/test"]) + } + fun `test cancelling an active sign in returns a quiet cancelled result`() = runBlocking(Dispatchers.Default) { val gate = CompletableDeferred() rpc.beforeAuthenticate = { gate.await() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index a598985e729..cf5cb10dfb8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -391,11 +391,17 @@ class McpSettingsUiTest : BasePlatformTestCase() { val panel = panel() flushUntil { rows(panel).size == 3 } agentRpc.mcpAuthRemoveResult = true + agentRpc.afterMcpConnect = { _, name -> + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, "needs_auth") + } TestDialogManager.setTestDialog(TestDialog.YES) click(panel, "github", "resetAuth") - flushUntil { agentRpc.mcpAuthRemovals.contains("github") } + flushUntil { service().needsAuth.value[DIR].orEmpty().contains("github") } + assertEquals(listOf("github"), agentRpc.mcpAuthRemovals) + assertEquals(listOf("github"), agentRpc.mcpDisconnects) + assertEquals(listOf("github"), agentRpc.mcpConnects) } fun `test reset sign in decline does not call mcpAuthRemove`() { From 22e73bddc86286d44aeb68b7fd290a442e9f526e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 18:30:36 -0400 Subject: [PATCH 035/353] feat(jetbrains): group actionable session issues Move MCP sign-in into a reusable session issue action menu in the prompt input, keeping the control hidden when no session-scoped recovery actions are available. --- .changeset/jetbrains-mcp-oauth-sign-in.md | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 59 +++++++---------- .../client/session/ui/prompt/PromptPanel.kt | 65 ++++++++++++++----- .../client/session/ui/prompt/SessionIssue.kt | 13 ++++ .../resources/messages/KiloBundle.properties | 3 + .../messages/KiloBundle_ar.properties | 3 + .../messages/KiloBundle_bs.properties | 3 + .../messages/KiloBundle_da.properties | 3 + .../messages/KiloBundle_de.properties | 3 + .../messages/KiloBundle_es.properties | 3 + .../messages/KiloBundle_fr.properties | 3 + .../messages/KiloBundle_ja.properties | 3 + .../messages/KiloBundle_ko.properties | 3 + .../messages/KiloBundle_nl.properties | 3 + .../messages/KiloBundle_no.properties | 3 + .../messages/KiloBundle_pl.properties | 3 + .../messages/KiloBundle_pt_BR.properties | 3 + .../messages/KiloBundle_ru.properties | 3 + .../messages/KiloBundle_th.properties | 3 + .../messages/KiloBundle_tr.properties | 3 + .../messages/KiloBundle_uk.properties | 3 + .../messages/KiloBundle_zh_CN.properties | 3 + .../messages/KiloBundle_zh_TW.properties | 3 + .../client/plugin/KiloBundleLocaleTest.kt | 3 + .../client/session/ui/PromptPanelTest.kt | 54 +++++++-------- 25 files changed, 167 insertions(+), 86 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt diff --git a/.changeset/jetbrains-mcp-oauth-sign-in.md b/.changeset/jetbrains-mcp-oauth-sign-in.md index 83da075f9fb..5d78a8f797a 100644 --- a/.changeset/jetbrains-mcp-oauth-sign-in.md +++ b/.changeset/jetbrains-mcp-oauth-sign-in.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Sign in to remote MCP servers from JetBrains. MCP Settings now shows the real sign-in error, lets you cancel an in-progress sign-in, and offers a copyable authorization URL when a browser cannot be opened automatically. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt toolbar shows an indicator when a configured MCP server needs sign-in. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. +Sign in to remote MCP servers from JetBrains. MCP Settings now shows the real sign-in error, lets you cancel an in-progress sign-in, and offers a copyable authorization URL when a browser cannot be opened automatically. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt shows a session-issues menu with a sign-in action when a configured MCP server needs authentication. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. 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 602a84914f2..38872aa8b3f 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 @@ -39,6 +39,7 @@ import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.MentionAction import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SessionIssue import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener @@ -77,7 +78,6 @@ import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.KiloSettingsConfigurable -import ai.kilocode.client.settings.agents.McpConfigurable import ai.kilocode.client.settings.checkpoints.CheckpointsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.telemetry.Telemetry @@ -95,6 +95,7 @@ import ai.kilocode.rpc.dto.SessionRevertDto import ai.kilocode.rpc.dto.WorktreePrDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog +import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil import com.intellij.ide.TextCopyProvider import com.intellij.openapi.actionSystem.ActionUpdateThread @@ -113,7 +114,6 @@ import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry @@ -126,7 +126,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -717,7 +716,6 @@ class SessionUi( prompt.onReset = { controller.clearModelOverride() } prompt.onChange = { scroll.refresh() } prompt.onAutoApproveToggle = ::setAuto - prompt.onMcpAuthClick = ::openMcpAuth prompt.setAutoApprove(controller.autoApprove) startMcpAuthTracking() prompt.model.favorites = { app.favorites.value } @@ -1452,51 +1450,40 @@ class SessionUi( ) } - private fun openMcpSettings() { - ShowSettingsUtil.getInstance().showSettingsDialog( - project, - Predicate { cfg: Configurable -> - cfg is ConfigurableWithId && cfg.getId() == McpConfigurable.ID - }, - { _: Configurable -> }, - ) - } - /** * Collects [KiloMcpAuthService] state for this session's directory and reflects it on the prompt - * toolbar indicator. Refresh is event-driven (workspace ready, after a sign-in attempt) rather + * issue actions. Refresh is event-driven (workspace ready, after a sign-in attempt) rather * than polled. */ private fun startMcpAuthTracking() { val auth = service() cs.launch { - auth.needsAuth.combine(auth.busy) { needs, busy -> needs[workspace.directory].orEmpty() to (busy.any { it.startsWith("${workspace.directory}\u0000") }) } + auth.needsAuth.combine(auth.busy) { needs, busy -> + val servers = needs[workspace.directory].orEmpty() + val prefix = "${workspace.directory}\u0000" + val active = busy.mapNotNull { key -> if (key.startsWith(prefix)) key.removePrefix(prefix) else null }.toSet() + servers to active + } .distinctUntilChanged() - .collect { (servers, busy) -> - withContext(Dispatchers.EDT) { prompt.setMcpAuth(servers.toList(), busy) } + .collect { (servers, active) -> + withContext(Dispatchers.EDT) { + prompt.setIssues(servers.sorted().map { name -> mcpAuthIssue(name, name in active) }) + } } } } @RequiresEdt - private fun openMcpAuth() { - val auth = service() - val servers = auth.needsAuth.value[workspace.directory].orEmpty().toList() - if (servers.isEmpty()) { - openMcpSettings() - return - } - if (servers.size == 1) { - signInFromPrompt(servers.first()) - return - } - JBPopupFactory.getInstance() - .createPopupChooserBuilder(servers + KiloBundle.message("prompt.mcp.openSettings")) - .setItemChosenCallback { choice -> - if (choice == KiloBundle.message("prompt.mcp.openSettings")) openMcpSettings() else signInFromPrompt(choice) - } - .createPopup() - .showUnderneathOf(prompt) + private fun mcpAuthIssue(name: String, busy: Boolean): SessionIssue { + val display = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + return SessionIssue( + id = "mcp-auth:$name", + title = KiloBundle.message(if (busy) "prompt.mcp.signingIn" else "prompt.mcp.signIn", display), + description = KiloBundle.message("prompt.mcp.needsAuth.one", display), + icon = if (busy) SpinnerIcon.icon else AllIcons.General.Warning, + enabled = !busy, + action = { signInFromPrompt(name) }, + ) } private fun signInFromPrompt(name: String) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 82b61f40b60..0b2c42559a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -23,6 +23,9 @@ import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.editor.EditorFolds import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.PromptPartDto @@ -45,6 +48,7 @@ import com.intellij.openapi.actionSystem.ActionUiKind import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.IdeActions @@ -155,7 +159,6 @@ class PromptPanel( var onChange: () -> Unit = {} var onAutoApproveToggle: (Boolean) -> Unit = {} var onFileDrag: (Boolean) -> Unit = {} - var onMcpAuthClick: () -> Unit = {} private var style = SessionEditorStyle.current() private var focused = false private val shell = BorderLayoutPanel().apply { @@ -260,11 +263,15 @@ class PromptPanel( addActionListener { onAutoApproveToggle(!autoApprove) } } - private val mcpAuth = HoverIcon().apply { + private var issues = emptyList() + private val issuesButton = HoverIcon().apply { icon = AllIcons.General.Warning + toolTipText = KiloBundle.message("prompt.issues.title") + accessibleContext.accessibleName = toolTipText isVisible = false - addActionListener { onMcpAuthClick() } + addActionListener { showIssues() } } + private val issuesSlot = issuesButton.align(HAlign.RIGHT, VAlign.BOTTOM).apply { isVisible = false } /** * Opens the Kilo.Session.PromptMenu popup (auto-approve + sharing). Resolves its context from @@ -347,7 +354,12 @@ class PromptPanel( } }) shell.add(strip, BorderLayout.NORTH) - shell.add(editor, BorderLayout.CENTER) + val input = BorderLayoutPanel().apply { + isOpaque = false + add(editor, BorderLayout.CENTER) + add(issuesSlot, BorderLayout.EAST) + } + shell.add(input, BorderLayout.CENTER) val bar = BorderLayoutPanel().apply { layout = BoxLayout(this, BoxLayout.X_AXIS) @@ -362,8 +374,6 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(reset) bar.add(Box.createHorizontalGlue()) - bar.add(mcpAuth) - bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) if (approve) { bar.add(menu) bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) @@ -532,16 +542,11 @@ class PromptPanel( } @RequiresEdt - fun setMcpAuth(servers: List, busy: Boolean) { - mcpAuth.isVisible = servers.isNotEmpty() || busy - mcpAuth.isEnabled = !busy - mcpAuth.icon = if (busy) SpinnerIcon.icon else AllIcons.General.Warning - mcpAuth.toolTipText = when { - busy -> KiloBundle.message("prompt.mcp.needsAuth.busy") - servers.size == 1 -> KiloBundle.message("prompt.mcp.needsAuth.one", servers.first()) - else -> KiloBundle.message("prompt.mcp.needsAuth.many", servers.size) - } - mcpAuth.accessibleContext.accessibleName = mcpAuth.toolTipText + internal fun setIssues(value: List) { + issues = value + val visible = value.isNotEmpty() + issuesButton.isVisible = visible + issuesSlot.isVisible = visible revalidate() repaint() } @@ -579,7 +584,9 @@ class PromptPanel( internal fun resetForTest(): JComponent = reset - internal fun mcpAuthForTest(): JComponent = mcpAuth + internal fun issuesForTest(): List = issues + + internal fun issuesButtonForTest(): JComponent = issuesButton internal fun shellForTest(): JComponent = shell @@ -716,6 +723,30 @@ class PromptPanel( popup.show(PopupShowOptions.aboveComponent(menu)) } + @RequiresEdt + private fun showIssues() { + if (issues.isEmpty()) return + val group = DefaultActionGroup() + for (issue in issues) { + group.add(object : DumbAwareAction(issue.title, issue.description, issue.icon) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = issue.enabled + } + + override fun actionPerformed(e: AnActionEvent) { + issue.action() + } + }) + } + JBPopupFactory.getInstance().createActionGroupPopup( + KiloBundle.message("prompt.issues.title"), + group, + DataManager.getInstance().getDataContext(issuesButton), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ).show(PopupShowOptions.aboveComponent(issuesButton)) + } + @RequiresEdt private fun enhance() { if (!enhance.isEnabled) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt new file mode 100644 index 00000000000..4c54cdbb617 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt @@ -0,0 +1,13 @@ +package ai.kilocode.client.session.ui.prompt + +import javax.swing.Icon + +/** An actionable problem scoped to one chat session. */ +internal data class SessionIssue( + val id: String, + val title: String, + val description: String? = null, + val icon: Icon? = null, + val enabled: Boolean = true, + val action: () -> Unit, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 726fba96e40..b09ac770a37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -925,6 +925,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.skills.info=Skills are instruction packs the agent loads only when the task at hand matches one. settings.agentBehavior.skills.info.more=Each skill is a SKILL.md file with a short description. Kilo keeps only that description in context and reads the full body when the agent decides the skill applies, so many skills stay cheap. Skills come from this project, your global Kilo config, and any extra folders or URLs listed under Additional Skill Sources. New and changed skills apply after the core reloads, which is deferred while a session is running. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 9d4659ec1b0..26a2ac01a3a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=المهارات settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 90f0b3f65a3..febadbc11e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Vještine settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 1bfcfdb3cc1..a897ab8b071 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Færdigheder settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 48bca4c5904..0e0a6e12c22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 41e20fd90ef..d31bd63e1d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index e68a1c87d19..9362daedb43 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Compétences settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index d9a89c931f4..d39053a7773 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=スキル settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 434a370c619..b0b91eeb479 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=스킬 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index a7da9e11843..01708576ec1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Vaardigheden settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 963982f0ffc..bed8251ef7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Ferdigheter settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 45da4281872..d80ec17ccec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Umiejętności settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 6fbb0a67e2d..cc083403044 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index dd8516864ec..dead8370fb7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Навыки settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 88a524a9f79..d616dcfe1bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=ทักษะ settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 7d2921d1343..a8035f5cc3b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Beceriler settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 5c606cd8866..7b2fede74dd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Навички settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 0aa966b39a9..1a69a6e3ff5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 5583785d7c3..99e6df56b9f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -475,6 +475,9 @@ prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 prompt.mcp.openSettings=Open MCP Settings +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt index 4b31c6c0a2f..c4037f9736c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -373,6 +373,7 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "settings.marketplace.mcp.signIn.confirm", "prompt.mcp.needsAuth.busy", "prompt.mcp.openSettings", + "prompt.issues.title", ) val AGENT = mapOf( @@ -494,6 +495,8 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "settings.marketplace.mcp.signIn.message" to listOf("SERVER_NAME"), "prompt.mcp.needsAuth.one" to listOf("SERVER_NAME"), "prompt.mcp.needsAuth.many" to listOf("7"), + "prompt.mcp.signIn" to listOf("SERVER_NAME"), + "prompt.mcp.signingIn" to listOf("SERVER_NAME"), ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 1cf60a34bea..7f722e52568 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -15,6 +15,7 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SessionIssue import ai.kilocode.client.session.ui.prompt.PromptTextPasteProvider import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.selection.SessionSelection @@ -1117,54 +1118,43 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.resetVisibleForTest()) } - fun `test mcp auth indicator is hidden with no servers and not busy`() { + fun `test session issues button is hidden when there are no issues`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - panel.setMcpAuth(emptyList(), busy = false) + panel.setIssues(emptyList()) - assertFalse(panel.mcpAuthForTest().isVisible) + assertFalse(panel.issuesButtonForTest().isVisible) } - fun `test mcp auth indicator shows singular tooltip for one server`() { + fun `test session issues button appears for an actionable issue`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val issue = SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") {} - panel.setMcpAuth(listOf("linear"), busy = false) + panel.setIssues(listOf(issue)) - val icon = panel.mcpAuthForTest() - assertTrue(icon.isVisible) - assertTrue(icon.isEnabled) - assertEquals(KiloBundle.message("prompt.mcp.needsAuth.one", "linear"), icon.toolTipText) + val button = panel.issuesButtonForTest() + assertTrue(button.isVisible) + assertEquals(KiloBundle.message("prompt.issues.title"), button.toolTipText) + assertEquals(listOf(issue), panel.issuesForTest()) } - fun `test mcp auth indicator shows plural tooltip for several servers`() { + fun `test session issues keep multiple future actions`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - - panel.setMcpAuth(listOf("linear", "github"), busy = false) - - assertEquals( - KiloBundle.message("prompt.mcp.needsAuth.many", 2), - panel.mcpAuthForTest().toolTipText, + val issues = listOf( + SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") {}, + SessionIssue("other", "Resolve another session issue") {}, ) + + panel.setIssues(issues) + + assertEquals(issues, panel.issuesForTest()) } - fun `test mcp auth indicator disables and swaps icon while busy`() { - val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - - panel.setMcpAuth(listOf("linear"), busy = true) - - val icon = panel.mcpAuthForTest() - assertTrue("busy still shows the affordance", icon.isVisible) - assertFalse(icon.isEnabled) - assertEquals(KiloBundle.message("prompt.mcp.needsAuth.busy"), icon.toolTipText) - } - - fun `test mcp auth indicator click invokes onMcpAuthClick`() { + fun `test session issue action invokes its supplied recovery`() { var clicked = false - val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - panel.onMcpAuthClick = { clicked = true } - panel.setMcpAuth(listOf("linear"), busy = false) + val issue = SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") { clicked = true } - (panel.mcpAuthForTest() as JButton).doClick() + issue.action() assertTrue(clicked) } From 776f81ca274f26eb5cdd8ae0add59f8f6a569db9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 19:53:01 -0400 Subject: [PATCH 036/353] fix(jetbrains): harden session issue lifecycle Scope OAuth cancellation to individual attempts, keep reset state consistent through reconnect failures, cancel session issue tracking on disposal, and cover the prompt action wiring directly. --- .../kilocode/client/app/KiloMcpAuthService.kt | 62 +++++++++++++------ .../ai/kilocode/client/session/SessionUi.kt | 5 +- .../client/session/ui/prompt/PromptPanel.kt | 25 ++++---- .../client/app/KiloMcpAuthServiceTest.kt | 30 +++++++++ .../client/session/ui/PromptPanelTest.kt | 14 ++++- 5 files changed, 105 insertions(+), 31 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt index 383bb22ecb2..0188005c143 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt @@ -59,8 +59,8 @@ class KiloMcpAuthService internal constructor( private val _busy = MutableStateFlow>(emptySet()) val busy: StateFlow> = _busy.asStateFlow() - private val active = ConcurrentHashMap.newKeySet() - private val cancelled = ConcurrentHashMap.newKeySet() + private val active = ConcurrentHashMap() + private val cancelled = ConcurrentHashMap.newKeySet() private val started = AtomicBoolean(false) private val lastEventUrl = AtomicReference(null) @@ -74,18 +74,15 @@ class KiloMcpAuthService internal constructor( .filter { it.status == "needs_auth" } .map { it.name } .toSet() - _needsAuth.update { current -> - val next = current + (dir to names) - if (next.size <= MAX_DIRECTORIES) return@update next - next.entries.drop(next.size - MAX_DIRECTORIES).associate { it.toPair() } - } + updateNeedsAuth(dir, names) return names } /** Starts (or resumes) sign-in for [name] in [dir]. Single-flight per directory/name pair. */ suspend fun signIn(dir: String, name: String): McpAuthResultDto { val key = busyKey(dir, name) - if (!active.add(key)) return McpAuthResultDto("failed", null) + val token = Any() + if (active.putIfAbsent(key, token) != null) return McpAuthResultDto("failed", null) _busy.update { it + key } return try { val result = withTimeoutOrNull(authTimeoutMs) { @@ -94,7 +91,7 @@ class KiloMcpAuthService internal constructor( } } if (result != null) { - if (cancelled.contains(key)) return McpAuthResultDto("cancelled", null) + if (cancelled.contains(token)) return McpAuthResultDto("cancelled", null) return result } attempt("mcp auth timeout cleanup failed dir=$dir name=$name", false) { @@ -102,8 +99,8 @@ class KiloMcpAuthService internal constructor( } McpAuthResultDto("timeout", null) } finally { - active.remove(key) - cancelled.remove(key) + active.remove(key, token) + cancelled.remove(token) _busy.update { it - key } refresh(dir) } @@ -112,20 +109,37 @@ class KiloMcpAuthService internal constructor( /** Cancels a pending sign-in and clears any stored credentials for [name]. */ suspend fun cancel(dir: String, name: String): Boolean { val key = busyKey(dir, name) - if (active.contains(key)) cancelled.add(key) + val token = active[key] + if (token != null) cancelled.add(token) val removed = attempt("mcp auth cancel failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } - if (!removed) cancelled.remove(key) + if (!removed && token != null) cancelled.remove(token) return removed } /** Clears stored credentials and reconnects so the runtime immediately reports [needsAuth]. */ suspend fun reset(dir: String, name: String): Boolean { + val key = busyKey(dir, name) + val auth = active[key] + if (auth != null) cancelled.add(auth) val removed = attempt("mcp auth reset failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } - if (!removed) return false - val disconnected = svc().mcpDisconnect(dir, name) - val connected = svc().mcpConnect(dir, name) - refresh(dir) - return disconnected && connected + if (!removed) { + if (auth != null) cancelled.remove(auth) + return false + } + var reconnected = false + try { + val disconnected = attempt("mcp disconnect after auth reset failed dir=$dir name=$name", false) { + svc().mcpDisconnect(dir, name) + } + val connected = attempt("mcp connect after auth reset failed dir=$dir name=$name", false) { + svc().mcpConnect(dir, name) + } + reconnected = disconnected && connected + return reconnected + } finally { + refresh(dir) + if (!reconnected) markNeedsAuth(dir, name) + } } /** Reports a sign-in [result] for [name] via a Kilo notification. Must run on EDT. */ @@ -154,6 +168,18 @@ class KiloMcpAuthService internal constructor( private fun busyKey(dir: String, name: String) = "$dir\u0000$name" + private fun markNeedsAuth(dir: String, name: String) { + updateNeedsAuth(dir, _needsAuth.value[dir].orEmpty() + name) + } + + private fun updateNeedsAuth(dir: String, names: Set) { + _needsAuth.update { current -> + val next = current + (dir to names) + if (next.size <= MAX_DIRECTORIES) return@update next + next.entries.drop(next.size - MAX_DIRECTORIES).associate { it.toPair() } + } + } + private suspend fun attempt(message: String, fallback: T, block: suspend () -> T): T = try { block() } catch (err: CancellationException) { 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 38872aa8b3f..b67bb499c70 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 @@ -258,6 +258,7 @@ class SessionUi( private var refreshJob: Job? = null private var branchJob: Job? = null private var boardJob: Job? = null + private var issuesJob: Job? = null private var boardHasMessages = false private var disposed = false @@ -1457,7 +1458,8 @@ class SessionUi( */ private fun startMcpAuthTracking() { val auth = service() - cs.launch { + issuesJob?.cancel() + issuesJob = cs.launch { auth.needsAuth.combine(auth.busy) { needs, busy -> val servers = needs[workspace.directory].orEmpty() val prefix = "${workspace.directory}\u0000" @@ -1508,6 +1510,7 @@ class SessionUi( disposed = true refreshJob?.cancel() branchJob?.cancel() + issuesJob?.cancel() hide.stop() popup.hideAll() modalFocus = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 0b2c42559a5..084c697d5a1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -727,17 +727,7 @@ class PromptPanel( private fun showIssues() { if (issues.isEmpty()) return val group = DefaultActionGroup() - for (issue in issues) { - group.add(object : DumbAwareAction(issue.title, issue.description, issue.icon) { - override fun update(e: AnActionEvent) { - e.presentation.isEnabled = issue.enabled - } - - override fun actionPerformed(e: AnActionEvent) { - issue.action() - } - }) - } + issueActions().forEach(group::add) JBPopupFactory.getInstance().createActionGroupPopup( KiloBundle.message("prompt.issues.title"), group, @@ -747,6 +737,19 @@ class PromptPanel( ).show(PopupShowOptions.aboveComponent(issuesButton)) } + @RequiresEdt + internal fun issueActions(): List = issues.map { issue -> + object : DumbAwareAction(issue.title, issue.description, issue.icon) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = issue.enabled + } + + override fun actionPerformed(e: AnActionEvent) { + issue.action() + } + } + } + @RequiresEdt private fun enhance() { if (!enhance.isEnabled) return diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt index f4719300ab8..ce2165b6094 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt @@ -164,6 +164,31 @@ class KiloMcpAuthServiceTest : BasePlatformTestCase() { assertEquals(setOf("linear"), service.needsAuth.value["/test"]) } + fun `test reset marks an active sign in as quietly cancelled`() = runBlocking(Dispatchers.Default) { + val gate = CompletableDeferred() + rpc.beforeAuthenticate = { gate.await() } + rpc.mcpAuthenticateResult = McpAuthResultDto("failed", "Authorization cancelled") + rpc.afterMcpConnect = { _, name -> rpc.mcps = listOf(McpStatusDto(name, "needs_auth")) } + val service = service() + val signIn = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + assertTrue(service.reset("/test", "linear")) + gate.complete(Unit) + + assertEquals("cancelled", signIn.await().status) + } + + fun `test reset keeps needs auth state when reconnect fails`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + rpc.mcpConnectError = RuntimeException("offline") + val service = service() + + assertFalse(service.reset("/test", "linear")) + + assertEquals(setOf("linear"), service.needsAuth.value["/test"]) + } + fun `test cancelling an active sign in returns a quiet cancelled result`() = runBlocking(Dispatchers.Default) { val gate = CompletableDeferred() rpc.beforeAuthenticate = { gate.await() } @@ -177,6 +202,11 @@ class KiloMcpAuthServiceTest : BasePlatformTestCase() { assertEquals("cancelled", result.await().status) assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + + rpc.beforeAuthenticate = null + rpc.mcpAuthenticateStarted = false + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + assertEquals("connected", service.signIn("/test", "linear").status) } fun `test duplicate browser open failed events within the dedupe window are collapsed`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 7f722e52568..4e562b1edcd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1152,9 +1152,21 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test session issue action invokes its supplied recovery`() { var clicked = false + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val issue = SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") { clicked = true } + panel.setIssues(listOf(issue)) + val action = panel.issueActions().single() - issue.action() + action.actionPerformed( + AnActionEvent.createEvent( + action, + DataContext.EMPTY_CONTEXT, + null, + ActionPlaces.UNKNOWN, + ActionUiKind.NONE, + null, + ), + ) assertTrue(clicked) } From cf1b9d6e266a166e34e84fd06453b2aa573adf83 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 20:16:54 -0400 Subject: [PATCH 037/353] fix(jetbrains): refine session issue actions Anchor the session issue trigger at the prompt top-right, group recovery actions by MCP provider without item icons or a popup header, and open MCP Settings with the affected provider pre-filtered. --- .../ai/kilocode/client/session/SessionUi.kt | 29 +++++++++++---- .../client/session/ui/prompt/PromptPanel.kt | 20 ++++++----- .../client/session/ui/prompt/SessionIssue.kt | 9 +++-- .../client/settings/agents/McpConfigurable.kt | 19 +++++++++- .../client/settings/base/SettingsListPanel.kt | 9 +++++ .../resources/messages/KiloBundle.properties | 3 +- .../messages/KiloBundle_ar.properties | 3 +- .../messages/KiloBundle_bs.properties | 3 +- .../messages/KiloBundle_da.properties | 3 +- .../messages/KiloBundle_de.properties | 3 +- .../messages/KiloBundle_es.properties | 3 +- .../messages/KiloBundle_fr.properties | 3 +- .../messages/KiloBundle_ja.properties | 3 +- .../messages/KiloBundle_ko.properties | 3 +- .../messages/KiloBundle_nl.properties | 3 +- .../messages/KiloBundle_no.properties | 3 +- .../messages/KiloBundle_pl.properties | 3 +- .../messages/KiloBundle_pt_BR.properties | 3 +- .../messages/KiloBundle_ru.properties | 3 +- .../messages/KiloBundle_th.properties | 3 +- .../messages/KiloBundle_tr.properties | 3 +- .../messages/KiloBundle_uk.properties | 3 +- .../messages/KiloBundle_zh_CN.properties | 3 +- .../messages/KiloBundle_zh_TW.properties | 3 +- .../client/plugin/KiloBundleLocaleTest.kt | 1 + .../client/session/ui/PromptPanelTest.kt | 35 ++++++++++++++++--- .../settings/agents/McpSettingsUiTest.kt | 11 ++++++ 27 files changed, 148 insertions(+), 42 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 b67bb499c70..af35198733c 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 @@ -40,6 +40,7 @@ import ai.kilocode.client.session.ui.prompt.MentionAction import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SessionIssue +import ai.kilocode.client.session.ui.prompt.SessionIssueAction import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener @@ -78,6 +79,7 @@ import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.KiloSettingsConfigurable +import ai.kilocode.client.settings.agents.McpConfigurable import ai.kilocode.client.settings.checkpoints.CheckpointsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.telemetry.Telemetry @@ -95,7 +97,6 @@ import ai.kilocode.rpc.dto.SessionRevertDto import ai.kilocode.rpc.dto.WorktreePrDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog -import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil import com.intellij.ide.TextCopyProvider import com.intellij.openapi.actionSystem.ActionUpdateThread @@ -1480,11 +1481,27 @@ class SessionUi( val display = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } return SessionIssue( id = "mcp-auth:$name", - title = KiloBundle.message(if (busy) "prompt.mcp.signingIn" else "prompt.mcp.signIn", display), - description = KiloBundle.message("prompt.mcp.needsAuth.one", display), - icon = if (busy) SpinnerIcon.icon else AllIcons.General.Warning, - enabled = !busy, - action = { signInFromPrompt(name) }, + title = KiloBundle.message("prompt.mcp.provider", display), + actions = listOf( + SessionIssueAction( + title = KiloBundle.message(if (busy) "prompt.mcp.needsAuth.busy" else "settings.agentBehavior.mcp.signIn"), + enabled = !busy, + action = { signInFromPrompt(name) }, + ), + SessionIssueAction( + title = KiloBundle.message("prompt.mcp.openSettings"), + action = { openMcpSettings(name) }, + ), + ), + ) + } + + @RequiresEdt + private fun openMcpSettings(name: String) { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> cfg is ConfigurableWithId && cfg.getId() == McpConfigurable.ID }, + { cfg: Configurable -> (cfg as? McpConfigurable)?.filter(name) }, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 084c697d5a1..8855030eca1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -271,7 +271,7 @@ class PromptPanel( isVisible = false addActionListener { showIssues() } } - private val issuesSlot = issuesButton.align(HAlign.RIGHT, VAlign.BOTTOM).apply { isVisible = false } + private val issuesSlot = issuesButton.align(HAlign.RIGHT, VAlign.TOP).apply { isVisible = false } /** * Opens the Kilo.Session.PromptMenu popup (auto-approve + sharing). Resolves its context from @@ -729,7 +729,7 @@ class PromptPanel( val group = DefaultActionGroup() issueActions().forEach(group::add) JBPopupFactory.getInstance().createActionGroupPopup( - KiloBundle.message("prompt.issues.title"), + null, group, DataManager.getInstance().getDataContext(issuesButton), JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, @@ -739,13 +739,17 @@ class PromptPanel( @RequiresEdt internal fun issueActions(): List = issues.map { issue -> - object : DumbAwareAction(issue.title, issue.description, issue.icon) { - override fun update(e: AnActionEvent) { - e.presentation.isEnabled = issue.enabled - } + DefaultActionGroup(issue.title, true).apply { + for (item in issue.actions) { + add(object : DumbAwareAction(item.title, item.description, null) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = item.enabled + } - override fun actionPerformed(e: AnActionEvent) { - issue.action() + override fun actionPerformed(e: AnActionEvent) { + item.action() + } + }) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt index 4c54cdbb617..e267bf60982 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt @@ -1,13 +1,16 @@ package ai.kilocode.client.session.ui.prompt -import javax.swing.Icon - /** An actionable problem scoped to one chat session. */ internal data class SessionIssue( val id: String, + val title: String, + val actions: List, +) + +/** One recovery path shown under a [SessionIssue] provider group. */ +internal data class SessionIssueAction( val title: String, val description: String? = null, - val icon: Icon? = null, val enabled: Boolean = true, val action: () -> Unit, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index fcfa62356fe..5a425d5a82e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -38,14 +38,31 @@ import kotlinx.coroutines.withContext private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() class McpConfigurable : DirectoryReadyConfigurable() { + private var ui: McpSettingsUi? = null + private var query: String? = null + override fun getId(): String = ID override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.mcp.displayName") - override fun create(cs: CoroutineScope, dir: String): JComponent = McpSettingsUi(cs, dir) + override fun create(cs: CoroutineScope, dir: String): JComponent = McpSettingsUi(cs, dir).also { panel -> + ui = panel + query?.let(panel::filter) + } override fun update(ui: JComponent, dir: String) { (ui as? McpSettingsUi)?.setDirectory(dir) } override fun scrollReadyShell() = false + @RequiresEdt + internal fun filter(query: String) { + this.query = query + ui?.filter(query) + } + + override fun disposeReadyComponent(component: JComponent) { + if (ui === component) ui = null + query = null + } + companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.mcp" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt index 3e9b8c8fc69..8b0f1cc7e9f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt @@ -93,6 +93,15 @@ internal abstract class SettingsListPanel( return pending } + /** Pre-populate the list search when another UI opens this settings page for a specific item. */ + @RequiresEdt + internal fun filter(query: String) { + checkEdt() + search.text = query + view.filter(query) + search.textEditor.requestFocusInWindow() + } + @RequiresEdt protected fun mutateAndReload( selection: ActiveListSelection = ActiveListSelection.Preserve, diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index b09ac770a37..03b7966a13b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -924,7 +924,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 26a2ac01a3a..d77410b9566 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index febadbc11e7..3c4bc043b55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index a897ab8b071..a8797df3cfb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 0e0a6e12c22..53efb36eecb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index d31bd63e1d9..eb5bb3cffa5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 9362daedb43..d761bd8931c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index d39053a7773..09c39197eca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index b0b91eeb479..1c0e1d97c03 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 01708576ec1..47e2f7ef963 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index bed8251ef7d..26b29a88597 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index d80ec17ccec..69a6d8a6876 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index cc083403044..77e5d486bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index dead8370fb7..0e7e7aea2bb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index d616dcfe1bc..760a128da3c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index a8035f5cc3b..c159b24b302 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 7b2fede74dd..c7ca3675d57 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 1a69a6e3ff5..8521f66b608 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 99e6df56b9f..e5d63f8ab11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -474,7 +474,8 @@ settings.marketplace.mcp.signIn.confirm=Sign In prompt.mcp.needsAuth.one={0} needs sign-in prompt.mcp.needsAuth.many={0} MCP servers need sign-in prompt.mcp.needsAuth.busy=Signing in\u2026 -prompt.mcp.openSettings=Open MCP Settings +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP prompt.issues.title=Session issues prompt.mcp.signIn=Sign in to {0} prompt.mcp.signingIn=Signing in to {0}\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt index c4037f9736c..9c29a95607a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -497,6 +497,7 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "prompt.mcp.needsAuth.many" to listOf("7"), "prompt.mcp.signIn" to listOf("SERVER_NAME"), "prompt.mcp.signingIn" to listOf("SERVER_NAME"), + "prompt.mcp.provider" to listOf("SERVER_NAME"), ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 4e562b1edcd..32e18d3a93d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -16,6 +16,7 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SessionIssue +import ai.kilocode.client.session.ui.prompt.SessionIssueAction import ai.kilocode.client.session.ui.prompt.PromptTextPasteProvider import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.selection.SessionSelection @@ -44,6 +45,7 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.actionSystem.UiDataProvider @@ -1128,21 +1130,27 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test session issues button appears for an actionable issue`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - val issue = SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") {} + val issue = SessionIssue("mcp-auth:anaconda", "Anaconda MCP", listOf(SessionIssueAction("Sign in") {})) panel.setIssues(listOf(issue)) + realize(panel, 260, 400) + panel.setBounds(0, 0, 260, panel.preferredSize.height) + layoutTree(panel) val button = panel.issuesButtonForTest() assertTrue(button.isVisible) assertEquals(KiloBundle.message("prompt.issues.title"), button.toolTipText) assertEquals(listOf(issue), panel.issuesForTest()) + val point = SwingUtilities.convertPoint(button.parent, button.location, panel.shellForTest()) + assertTrue("issues button should be in the right half", point.x > panel.shellForTest().width / 2) + assertTrue("issues button should be in the top half", point.y < panel.shellForTest().height / 2) } fun `test session issues keep multiple future actions`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val issues = listOf( - SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") {}, - SessionIssue("other", "Resolve another session issue") {}, + SessionIssue("mcp-auth:anaconda", "Anaconda MCP", listOf(SessionIssueAction("Sign in") {})), + SessionIssue("other", "Other provider", listOf(SessionIssueAction("Resolve") {})), ) panel.setIssues(issues) @@ -1153,9 +1161,21 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test session issue action invokes its supplied recovery`() { var clicked = false val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - val issue = SessionIssue("mcp-auth:anaconda", "Sign in to Anaconda") { clicked = true } + val issue = SessionIssue( + "mcp-auth:anaconda", + "Anaconda MCP", + listOf( + SessionIssueAction("Sign in") { clicked = true }, + SessionIssueAction("Open in Settings") {}, + ), + ) panel.setIssues(listOf(issue)) - val action = panel.issueActions().single() + val provider = panel.issueActions().single() as DefaultActionGroup + val actions = provider.getChildren(null) + assertEquals("Anaconda MCP", provider.templatePresentation.text) + assertEquals(listOf("Sign in", "Open in Settings"), actions.map { it.templatePresentation.text }) + assertTrue(actions.all { it.templatePresentation.icon == null }) + val action = actions.first() action.actionPerformed( AnActionEvent.createEvent( @@ -1876,6 +1896,11 @@ class PromptPanelTest : BasePlatformTestCase() { return root } + private fun layoutTree(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach(::layoutTree) + } + private fun realizedEditor(panel: PromptPanel): EditorEx { realize(panel, 260, 400) val field = panel.defaultFocusedComponent as EditorTextField diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index cf5cb10dfb8..2ddb6d6791e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -109,6 +109,17 @@ class McpSettingsUiTest : BasePlatformTestCase() { } } + fun `test predefined search filters to one MCP server`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + panel.filter("github") + assertEquals(listOf("github"), rows(panel).map { it.key }) + true + } + } + fun `test mcp rows keep equal height`() { val panel = panel() flushUntil { rows(panel).size == 3 } From d75cc82d0b532228a5d127b0b06d098ddb523dde Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 29 Sep 2026 20:38:51 -0400 Subject: [PATCH 038/353] fix(jetbrains): recover Anaconda MCP installation Normalize legacy unauthorized MCP failures into sign-in-required state and use config overlay set/unset operations so installed MCP servers can be removed from Settings. --- .../kilocode/backend/cli/KiloCliDataParser.kt | 29 ++++++++++++- .../rpc/KiloAgentBehaviorRpcApiImpl.kt | 8 +--- .../backend/cli/KiloCliDataParserTest.kt | 41 +++++++++++++++++++ .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 7 +++- .../kilocode/backend/testing/MockCliServer.kt | 9 ++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index fbc0a9023d5..252275c22db 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -871,13 +871,24 @@ object KiloCliDataParser { private fun mcpStatus(item: JsonElement, fallback: String? = null): McpStatusDto? { val obj = item.obj() ?: return null val name = obj.str("name") ?: fallback ?: return null + val error = obj.str("error") + val raw = obj.str("status") ?: obj.str("state") ?: "unknown" + val status = if (raw == "failed" && error.isMcpAuthFailure()) "needs_auth" else raw return McpStatusDto( name = name, - status = obj.str("status") ?: obj.str("state") ?: "unknown", - error = obj.str("error"), + status = status, + error = error, ) } + private fun String?.isMcpAuthFailure(): Boolean { + val value = this?.lowercase() ?: return false + return value.contains("unauthorized") || + value.contains("authentication required") || + value.contains("needs authentication") || + value.contains("not authenticated") + } + private fun removable(obj: JsonObject): Boolean { if (obj.flagOrNull("native") == true) return false val opts = obj["options"].obj() @@ -1194,6 +1205,20 @@ object KiloCliDataParser { return json.encodeToString(JsonObject.serializer(), obj) } + fun buildMcpOverlayPatch(name: String, scope: String, config: McpConfigDto?): String = buildJsonObject { + put("scope", if (scope == "workspace") "project" else "global") + if (config == null) { + put("unset", buildJsonArray { + add(buildJsonArray { + add(JsonPrimitive("mcp")) + add(JsonPrimitive(name)) + }) + }) + return@buildJsonObject + } + put("set", json.parseToJsonElement(buildConfigPatch(ConfigPatchDto(mcp = mapOf(name to config))))) + }.toString() + fun buildDisabledProviderPatch(ids: List): String { val arr = JsonArray(ids.distinct().sorted().map { JsonPrimitive(it) }) return json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("disabled_providers" to arr))) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index 264e400d07d..cb8c0398315 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -209,12 +209,8 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun saveMcp(directory: String, name: String, scope: String, config: McpConfigDto?): Boolean { app.requireReady() - val patch = ConfigPatchDto(mcp = mapOf(name to config)) - if (scope == "workspace") { - patchConfig("/config?directory=${encode(directory)}", KiloCliDataParser.buildConfigPatch(patch)) - } else { - app.updateConfig(patch) - } + val patch = KiloCliDataParser.buildMcpOverlayPatch(name, scope, config) + patchConfig("/config/overlay?directory=${encode(directory)}", patch) saveMcpOverride(directory, name, scope, config) return true } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 02d3fc31b20..7ef5606f94b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2276,6 +2276,25 @@ class KiloCliDataParserTest { assertEquals("HTTP 500", result.error) } + @Test + fun `parseMcpStatus - legacy unauthorized failure needs auth`() { + val result = KiloCliDataParser.parseMcpStatus( + """{"anaconda":{"status":"failed","error":"Unauthorized: authentication required"}}""", + ).single() + + assertEquals("needs_auth", result.status) + assertEquals("Unauthorized: authentication required", result.error) + } + + @Test + fun `parseMcpStatus - non-auth failure remains failed`() { + val result = KiloCliDataParser.parseMcpStatus( + """{"anaconda":{"status":"failed","error":"Connection refused"}}""", + ).single() + + assertEquals("failed", result.status) + } + // ---- parseMcpBrowserOpenFailed ---- @Test @@ -2884,6 +2903,28 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildMcpOverlayPatch - sets a project MCP`() { + val result = KiloCliDataParser.buildMcpOverlayPatch( + "anaconda", + "workspace", + McpConfigDto(type = "remote", url = "https://anaconda.com/api/mcp"), + ) + + assertEquals( + "{\"scope\":\"project\",\"set\":{\"mcp\":{\"anaconda\":{\"type\":\"remote\",\"url\":\"https://anaconda.com/api/mcp\"}}}}", + result, + ) + } + + @Test + fun `buildMcpOverlayPatch - unsets a project MCP`() { + assertEquals( + "{\"scope\":\"project\",\"unset\":[[\"mcp\",\"anaconda\"]]}", + KiloCliDataParser.buildMcpOverlayPatch("anaconda", "workspace", null), + ) + } + @Test fun `buildConfigPatch - full agent permission object`() { val patch = ConfigPatchDto(agents = linkedMapOf("custom" to AgentConfigPatchDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index ca57321680b..0574d6e6fb4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -316,6 +316,8 @@ class KiloAgentBehaviorRpcApiImplTest { command = listOf("node", "server.js"), environment = mapOf("TOKEN" to "x"), ))) + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"scope\":\"global\"") + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"set\":{\"mcp\"") assertContains(mock.lastConfigPatchBody.orEmpty(), "\"global-added\"") assertContains(mock.lastConfigPatchBody.orEmpty(), "\"environment\":{\"TOKEN\":\"x\"}") assertEquals("local", rpc.mcpConfig("/test dir")["global"]?.config?.type) @@ -326,7 +328,8 @@ class KiloAgentBehaviorRpcApiImplTest { url = "https://mcp.example.test", headers = mapOf("Authorization" to "Bearer t"), ))) - assertEquals("/config?directory=%2Ftest+dir", mock.lastWorkspaceConfigPatchPath) + assertEquals("/config/overlay?directory=%2Ftest+dir", mock.lastWorkspaceConfigPatchPath) + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"scope\":\"project\"") assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-added\"") assertEquals("workspace", rpc.mcpConfig("/test dir")["workspace-added"]?.scope) @@ -336,7 +339,7 @@ class KiloAgentBehaviorRpcApiImplTest { assertEquals("global", rpc.mcpConfig("/test dir")["shared"]?.scope) assertTrue(rpc.saveMcp("/test dir", "workspace-added", "workspace", null)) - assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-added\":null") + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"unset\":[[\"mcp\",\"workspace-added\"]]") assertFalse(rpc.mcpConfig("/test dir").containsKey("workspace-added")) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 3d8f06794f1..de0beeb0c44 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -389,6 +389,15 @@ class MockCliServer : AutoCloseable { } bare == "/global/dispose" && method == "POST" -> respond(output, disposeStatus, "true") path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) + bare == "/config/overlay" && method == "PATCH" -> { + if (body.contains("\"scope\":\"global\"")) { + lastConfigPatchBody = body + } else { + lastWorkspaceConfigPatchPath = path + lastWorkspaceConfigPatchBody = body + } + respond(output, workspaceConfigStatus, "{}") + } bare == "/config" && method == "PATCH" -> { lastWorkspaceConfigPatchPath = path lastWorkspaceConfigPatchBody = body From 0e12deaca364913abd1b73be64dccf523a01e527 Mon Sep 17 00:00:00 2001 From: Rakshith N Date: Wed, 30 Sep 2026 12:47:40 +0530 Subject: [PATCH 039/353] fix: format changeset to single line --- .changeset/tui-permission-eviction.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.changeset/tui-permission-eviction.md b/.changeset/tui-permission-eviction.md index 66530ab9c2c..f2286d32440 100644 --- a/.changeset/tui-permission-eviction.md +++ b/.changeset/tui-permission-eviction.md @@ -1,7 +1,4 @@ --- "@kilocode/cli": patch --- - -Fix permission and question prompts disappearing when switching sessions in the TUI. - -Approval prompts for running subagents and sessions now persist across session switches and are restored when switching back, so sessions are never left blocked on an unanswered prompt. +Fix permission and question prompts disappearing when switching sessions in the TUI. Approval prompts for running subagents and sessions now persist across session switches and are restored when switching back, so sessions are never left blocked on an unanswered prompt. From 68d66545f5167ca54b0e2fe2c37cf2a714642200 Mon Sep 17 00:00:00 2001 From: Hardik Sharma Date: Wed, 30 Sep 2026 13:20:50 +0530 Subject: [PATCH 040/353] fix(vscode): preview plan documents from canonical plans dir in non-git projects For non-git projects, plan files are stored in the canonical plans dir (Global.Path.data/plans) outside the session directory, which PlanFile.written() already admits. readDocument() rejected any file outside the session root, so the Documents panel always failed with "Document is outside the worktree." for those plans. Exempt the canonical plans dir from the containment check by resolving it the same way the CLI does (XDG_DATA_HOME or ~/.local/share), return the absolute path for such documents so open-in-editor still works, and cover it with unit tests. Fixes #14588 --- .../src/documents/document-reader.ts | 24 +++++++- .../tests/unit/document-reader.test.ts | 60 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/documents/document-reader.ts b/packages/kilo-vscode/src/documents/document-reader.ts index 1ab6c6075a1..1e6f3f63637 100644 --- a/packages/kilo-vscode/src/documents/document-reader.ts +++ b/packages/kilo-vscode/src/documents/document-reader.ts @@ -1,4 +1,5 @@ import * as fs from "fs" +import * as os from "os" import * as path from "path" const MAX_TEXT_BYTES = 2_000_000 @@ -30,6 +31,24 @@ export function isInsideWorktree( return target === base || target.startsWith(base + sep) } +/** + * Canonical plans dir for non-git projects, mirroring the CLI's + * `Global.Path.data` resolution (`XDG_DATA_HOME` or `~/.local/share`, same + * fallback pattern as `config-file.ts` and `cli-resources.ts`). For non-git + * projects plan files legitimately live here, outside the worktree, and the + * CLI's plan-file logic already admits the dir — see #14588. + */ +export function plansDir(dataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")): string { + return path.join(dataHome, "kilo", "plans") +} + +/** Whether `resolved` lives in the canonical plans dir (realpath-folded for tmpfs/symlink cases). */ +function isPlan(resolved: string): boolean { + const dir = plansDir() + if (!fs.existsSync(dir)) return false + return isInsideWorktree(fs.realpathSync(dir), resolved) +} + function mime(file: string): string | undefined { const ext = path.extname(file).toLowerCase() if (ext === ".png") return "image/png" @@ -47,7 +66,8 @@ export function readDocument(root: string, file: string): DocumentResult { const base = fs.realpathSync(root) const target = path.isAbsolute(file) ? file : path.resolve(root, file) const resolved = fs.realpathSync(target) - if (!isInsideWorktree(base, resolved)) return { error: "Document is outside the worktree." } + const inside = isInsideWorktree(base, resolved) + if (!inside && !isPlan(resolved)) return { error: "Document is outside the worktree." } const stat = fs.statSync(resolved) if (!stat.isFile()) return { error: "Document is not a file." } @@ -56,7 +76,7 @@ export function readDocument(root: string, file: string): DocumentResult { const limit = type ? MAX_IMAGE_BYTES : MAX_TEXT_BYTES if (stat.size > limit) return { error: "Document is too large to preview." } - const relative = path.relative(base, resolved).split(path.sep).join("/") + const relative = inside ? path.relative(base, resolved).split(path.sep).join("/") : resolved if (type) return { file: relative, kind: "image", mime: type, data: fs.readFileSync(resolved).toString("base64") } const content = fs.readFileSync(resolved) diff --git a/packages/kilo-vscode/tests/unit/document-reader.test.ts b/packages/kilo-vscode/tests/unit/document-reader.test.ts index db58481c2d5..05b76c758e3 100644 --- a/packages/kilo-vscode/tests/unit/document-reader.test.ts +++ b/packages/kilo-vscode/tests/unit/document-reader.test.ts @@ -2,12 +2,24 @@ import { describe, expect, it } from "bun:test" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { isInsideWorktree, readDocument } from "../../src/documents/document-reader" +import { isInsideWorktree, plansDir, readDocument } from "../../src/documents/document-reader" function workspace() { return fs.mkdtempSync(path.join(os.tmpdir(), "kilo-document-")) } +function withDataHome(run: () => T): T { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-data-")) + const previous = process.env.XDG_DATA_HOME + process.env.XDG_DATA_HOME = home + try { + return run() + } finally { + if (previous === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = previous + } +} + describe("readDocument", () => { it("reads text files inside the worktree", () => { const root = workspace() @@ -30,6 +42,52 @@ describe("readDocument", () => { expect(readDocument(root, "data.bin")).toEqual({ error: "Binary files cannot be previewed." }) }) + + // Non-git projects store plans in the canonical plans dir, outside the + // worktree. The preview must still work there — see #14588. + it("reads plan files from the canonical plans dir outside the worktree", () => { + withDataHome(() => { + const root = workspace() + const dir = plansDir() + fs.mkdirSync(dir, { recursive: true }) + const plan = path.join(dir, "42-non-git.md") + fs.writeFileSync(plan, "# Global plan\n") + + expect(readDocument(root, plan)).toEqual({ + file: fs.realpathSync(plan), + kind: "text", + content: "# Global plan\n", + }) + }) + }) + + it("keeps rejecting files outside both the worktree and the plans dir", () => { + withDataHome(() => { + const root = workspace() + const outside = path.join(root, "..", "outside.md") + fs.writeFileSync(outside, "secret") + + expect(readDocument(root, outside)).toEqual({ error: "Document is outside the worktree." }) + }) + }) +}) + +describe("plansDir", () => { + it("honors XDG_DATA_HOME", () => { + withDataHome(() => { + expect(plansDir()).toEqual(path.join(process.env.XDG_DATA_HOME!, "kilo", "plans")) + }) + }) + + it("falls back to ~/.local/share", () => { + const previous = process.env.XDG_DATA_HOME + delete process.env.XDG_DATA_HOME + try { + expect(plansDir()).toEqual(path.join(os.homedir(), ".local", "share", "kilo", "plans")) + } finally { + if (previous !== undefined) process.env.XDG_DATA_HOME = previous + } + }) }) describe("isInsideWorktree", () => { From 07b18a1a250ac7b1b257e4571e7b6050e3abd092 Mon Sep 17 00:00:00 2001 From: YiMing Zhang Date: Wed, 30 Sep 2026 20:52:45 +0800 Subject: [PATCH 041/353] fix(cli): recover models after transient catalog failures Signed-off-by: YiMing Zhang --- .changeset/kilo-catalog-recovery.md | 5 + .../src/kilocode/provider/catalog-recovery.ts | 8 + packages/opencode/src/provider/model-cache.ts | 67 +++- packages/opencode/src/provider/provider.ts | 2 +- .../test/kilocode/catalog-recovery.test.ts | 329 ++++++++++++++++++ 5 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 .changeset/kilo-catalog-recovery.md create mode 100644 packages/opencode/src/kilocode/provider/catalog-recovery.ts create mode 100644 packages/opencode/test/kilocode/catalog-recovery.test.ts diff --git a/.changeset/kilo-catalog-recovery.md b/.changeset/kilo-catalog-recovery.md new file mode 100644 index 00000000000..d4e3bfa9d5a --- /dev/null +++ b/.changeset/kilo-catalog-recovery.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Recover worktree models after transient Kilo catalog failures without falling back to another account's catalog. diff --git a/packages/opencode/src/kilocode/provider/catalog-recovery.ts b/packages/opencode/src/kilocode/provider/catalog-recovery.ts new file mode 100644 index 00000000000..d503578086f --- /dev/null +++ b/packages/opencode/src/kilocode/provider/catalog-recovery.ts @@ -0,0 +1,8 @@ +import type { KiloModelsResult } from "@kilocode/kilo-gateway" + +export function retryable(result: KiloModelsResult) { + if (!result.error) return Object.keys(result.models).length === 0 + if (result.error.kind === "network") return true + const status = result.error.status ?? 0 + return result.error.kind === "http" && (status === 408 || status >= 500) +} diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index ac5b3195d42..95a45b69e66 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -1,10 +1,12 @@ // kilocode_change - new file import { fetchKiloModels, type KiloModelsResult } from "@kilocode/kilo-gateway" -import { Context, Deferred, Duration, Effect, Exit, Layer, Schema, Scope } from "effect" +import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Config } from "../config/config" import { Auth } from "../auth" import { compatible, organization, token } from "@/kilocode/provider/catalog" +import { retryable } from "@/kilocode/provider/catalog-recovery" +import * as ModelsRefresh from "@opencode-ai/core/kilocode/models-refresh" import type { Provider } from "@opencode-ai/core/models-dev" import * as Log from "@opencode-ai/core/util/log" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -16,7 +18,7 @@ type KiloOptions = NonNullable[0]> type Options = { -readonly [K in keyof KiloOptions]?: KiloOptions[K] } & { apiKey?: string } type Failure = NonNullable type Result = { readonly models: Models; readonly error?: Failure } -type View = { models?: Models; timestamp?: number } +type View = { models?: Models; timestamp?: number; empty?: boolean } type Flight = { readonly done: Deferred.Deferred; version: number } export interface KiloModels { @@ -37,6 +39,7 @@ type Cell = { readonly view: View cached?: { readonly result: Result; readonly expires: number } flight?: Flight + recovery?: Fiber.Fiber } export interface Interface { @@ -71,6 +74,7 @@ export const layer: Layer.Layer< const scope = yield* Scope.Scope const cells = new Map() const active = new Map() + const selected = new Map() const versions = new Map() const failures = new Map() @@ -163,6 +167,11 @@ export const layer: Layer.Layer< } const load = Effect.fn("ModelCache.load")(function* (providerID: string, options: Options) { + if (providerID === "kilo" && !compatible(options)) return { models: {}, error: { kind: "schema" as const } } + return yield* fetchModels(providerID, options) + }) + + const resolve = Effect.fn("ModelCache.resolve")(function* (providerID: string, options: Options) { const resolved = yield* authOptions(providerID).pipe( Effect.catchCause((cause) => Effect.sync(() => { @@ -171,9 +180,7 @@ export const layer: Layer.Layer< }), ), ) - const input = { ...resolved, ...options } - if (providerID === "kilo" && !compatible(input)) return { models: {}, error: { kind: "schema" as const } } - return yield* fetchModels(providerID, input) + return { ...resolved, ...options } }) const key = (providerID: string, options?: Options) => { @@ -185,11 +192,12 @@ export const layer: Layer.Layer< } const cell = Effect.fn("ModelCache.cell")(function* (providerID: string, options: Options = {}) { - const id = key(providerID, options) + const input = yield* resolve(providerID, options) + const id = key(providerID, input) const existing = cells.get(id) if (existing) return existing const view: View = {} - const next: Cell = { providerID, options, view } + const next: Cell = { providerID, options: input, view } cells.set(id, next) return next }) @@ -201,6 +209,7 @@ export const layer: Layer.Layer< const detach = (entry: Cell) => invalidate(entry).pipe( + Effect.tap(() => (entry.recovery ? Fiber.interrupt(entry.recovery) : Effect.void)), Effect.tap(() => Effect.sync(() => { entry.flight = undefined @@ -209,8 +218,10 @@ export const layer: Layer.Layer< ) const commit = (providerID: string, version: number, entry: Cell, result: Result) => - Effect.sync(() => { + Effect.gen(function* () { if ((versions.get(providerID) ?? 0) !== version) return result.models + const empty = Object.keys(result.models).length === 0 + const recovered = providerID === "kilo" && entry.view.empty && !empty && !result.error if (result.error) { failures.set(providerID, result.error) log.warn("model fetch error", { providerID, error: result.error }) @@ -219,13 +230,15 @@ export const layer: Layer.Layer< } entry.view.models = result.models entry.view.timestamp = Date.now() + entry.view.empty = empty active.set(providerID, entry) log.info("models fetched and cached", { providerID, count: Object.keys(result.models).length }) + if (recovered) yield* ModelsRefresh.notify() return result.models }) // A refresh belongs to the cache service, not the caller that happened to start it. - const evaluate = (entry: Cell, version: number) => + const evaluate = (entry: Cell, version: number): Effect.Effect => Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const cached = entry.cached @@ -254,6 +267,7 @@ export const layer: Layer.Layer< } } yield* Deferred.done(done, exit) + if (Exit.isSuccess(exit)) yield* recover(entry, exit.value) }), ).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(done)) @@ -281,11 +295,12 @@ export const layer: Layer.Layer< }) const fetch = Effect.fn("ModelCache.fetch")(function* (providerID: string, options?: Options) { - const cached = yield* get(providerID) + const entry = yield* cell(providerID, options) + selected.set(providerID, entry) + const cached = active.get(providerID) === entry ? yield* get(providerID) : undefined if (cached) return cached const version = (versions.get(providerID) ?? 0) + 1 versions.set(providerID, version) - const entry = yield* cell(providerID, options) log.info("fetching models", { providerID }) const result = yield* evaluate(entry, version) return result.models @@ -295,12 +310,41 @@ export const layer: Layer.Layer< const version = (versions.get(providerID) ?? 0) + 1 versions.set(providerID, version) const entry = yield* cell(providerID, options) + selected.set(providerID, entry) log.info("refreshing models", { providerID }) yield* invalidate(entry) const result = yield* evaluate(entry, version) return result.models }) + const recover = Effect.fn("ModelCache.recover")(function* (entry: Cell, result: Result) { + if ( + entry.providerID !== "kilo" || + selected.get(entry.providerID) !== entry || + active.get(entry.providerID) !== entry || + entry.recovery || + !retryable(result) + ) + return + entry.recovery = yield* Effect.gen(function* () { + for (let delay = 30; ; delay = Math.min(delay * 2, 300)) { + yield* Effect.sleep(Duration.seconds(delay)) + // A newer account or endpoint must not be replaced by this retry. + if (selected.get(entry.providerID) !== entry || active.get(entry.providerID) !== entry) return + if (entry.cached && !retryable(entry.cached.result)) return + yield* invalidate(entry) + const version = (versions.get(entry.providerID) ?? 0) + 1 + versions.set(entry.providerID, version) + const next = yield* evaluate(entry, version).pipe( + Effect.catch(() => Effect.succeed({ models: {}, error: { kind: "network" as const } })), + ) + if (selected.get(entry.providerID) !== entry || active.get(entry.providerID) !== entry) return + if (retryable(next)) continue + return + } + }).pipe(Effect.ensuring(Effect.sync(() => (entry.recovery = undefined))), Effect.forkIn(scope)) + }) + const clear = Effect.fn("ModelCache.clear")(function* (providerID: string) { versions.set(providerID, (versions.get(providerID) ?? 0) + 1) const entries = [...cells.entries()].filter(([, entry]) => entry.providerID === providerID) @@ -309,6 +353,7 @@ export const layer: Layer.Layer< { discard: true }, ) active.delete(providerID) + selected.delete(providerID) failures.delete(providerID) if (entries.some(([, entry]) => entry.view.models)) { log.info("cache cleared", { providerID }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2fa5d8610de..79b50cb088b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1990,7 +1990,7 @@ const layer = Layer.effect( : fuzzysort .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 }) .map((m) => m.target) - const empty = false // kilocode_change + const empty = !!catalogProvider && Object.keys(catalogProvider.models).length === 0 // kilocode_change return yield* new ModelNotFoundError({ providerID, modelID, suggestions, modelsEmpty: empty }) // kilocode_change } diff --git a/packages/opencode/test/kilocode/catalog-recovery.test.ts b/packages/opencode/test/kilocode/catalog-recovery.test.ts new file mode 100644 index 00000000000..3b7e212955e --- /dev/null +++ b/packages/opencode/test/kilocode/catalog-recovery.test.ts @@ -0,0 +1,329 @@ +import { expect, spyOn } from "bun:test" +import { Deferred, Effect, Fiber, Layer, Option, Ref, ScopedCache } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { FetchHttpClient } from "effect/unstable/http" +import type { KiloModelsResult } from "@kilocode/kilo-gateway" +import * as Core from "@opencode-ai/core/models-dev" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" +import { InstanceState } from "../../src/effect/instance-state" +import * as ModelsRefresh from "../../src/kilocode/provider/models-refresh" +import { ModelCache } from "../../src/provider/model-cache" +import { ModelsDev } from "../../src/provider/models" +import { Provider } from "../../src/provider/provider" +import { TestConfig } from "../fixture/config" +import { provideInstance, testInstanceStoreLayer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +type Options = Parameters[0] + +const catalog = { + models: { + allowed: { + id: "allowed", + name: "Allowed", + release_date: "", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 4096 }, + }, + }, +} + +function layer( + calls: Ref.Ref, + results: KiloModelsResult[], + cfg = TestConfig.layer(), + gates?: { started: Deferred.Deferred; wait: Deferred.Deferred }, +) { + return Layer.fresh(ModelCache.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(cfg), + Layer.provide(Layer.mock(Auth.Service)({ get: () => Effect.succeed(undefined) })), + Layer.provide( + Layer.succeed(ModelCache.KiloModelsService, { + fetch: (options) => + Effect.gen(function* () { + yield* Ref.update(calls, (list) => [...list, options]) + if (gates && (yield* Ref.get(calls)).length === 1) { + yield* Deferred.succeed(gates.started, undefined) + yield* Deferred.await(gates.wait) + } + return results.at((yield* Ref.get(calls)).length - 1) ?? results.at(-1)! + }), + }), + ), + ) +} + +const it = testEffect(testInstanceStoreLayer) +const options = { kilocodeOrganizationId: "org-a", kilocodeToken: "org-token" } + +it.effect("continues catalog recovery after its only caller times out", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const started = yield* Deferred.make() + const wait = yield* Deferred.make() + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + const caller = yield* cache.fetch("kilo", options).pipe(Effect.timeoutOption("1 second"), Effect.forkChild) + yield* Deferred.await(started) + yield* TestClock.adjust("1 second") + expect(Option.isNone(yield* Fiber.join(caller))).toBe(true) + yield* Deferred.succeed(wait, undefined) + yield* TestClock.adjust("30 seconds") + expect(yield* cache.get("kilo")).toEqual(catalog.models) + expect(yield* Ref.get(calls)).toEqual([options, options]) + }), + ).pipe( + Effect.provide( + layer(calls, [{ models: {}, error: { kind: "network" } }, catalog], TestConfig.layer(), { started, wait }), + ), + ) + }), +) + +for (const result of [ + { models: {}, error: { kind: "network" } }, + { models: {}, error: { kind: "http", status: 503 } }, + { models: {} }, +] satisfies KiloModelsResult[]) { + it.effect(`recovers initialized worktrees after ${result.error?.kind ?? "empty"} catalog results`, () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* Effect.gen(function* () { + const cache = yield* ModelCache.Service + const state = yield* InstanceState.make(() => cache.fetch("kilo", options)) + yield* ModelsRefresh.watch(state) + expect(yield* ScopedCache.get(state.cache, "first")).toEqual({}) + expect(yield* ScopedCache.get(state.cache, "second")).toEqual({}) + expect((yield* Ref.get(calls)).length).toBe(1) + yield* TestClock.adjust("29 seconds") + expect((yield* Ref.get(calls)).length).toBe(1) + yield* TestClock.adjust("1 second") + expect(yield* cache.get("kilo")).toEqual(catalog.models) + expect(yield* ScopedCache.get(state.cache, "first")).toEqual(catalog.models) + expect(yield* ScopedCache.get(state.cache, "second")).toEqual(catalog.models) + expect(yield* cache.getFailure("kilo")).toBeUndefined() + expect(yield* Ref.get(calls)).toEqual([options, options]) + yield* TestClock.adjust("10 minutes") + expect((yield* Ref.get(calls)).length).toBe(2) + }).pipe(Effect.provide(layer(calls, [result, catalog])), provideInstance(process.cwd())) + }), + ) +} + +it.effect("backs off repeated failures and keeps one retry across explicit refreshes", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const result: KiloModelsResult = { models: {}, error: { kind: "network" } } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + yield* cache.refresh("kilo", options) + yield* TestClock.adjust("30 seconds") + expect((yield* Ref.get(calls)).length).toBe(3) + yield* TestClock.adjust("59 seconds") + expect((yield* Ref.get(calls)).length).toBe(3) + yield* TestClock.adjust("1 second") + expect((yield* Ref.get(calls)).length).toBe(4) + expect(yield* cache.get("kilo")).toEqual(catalog.models) + }), + ).pipe(Effect.provide(layer(calls, [result, result, result, catalog]))) + }), +) + +for (const error of [ + { kind: "unauthorized", status: 401 }, + { kind: "unauthorized", status: 403 }, + { kind: "http", status: 404 }, + { kind: "http", status: 429 }, + { kind: "schema" }, +] satisfies NonNullable[]) { + it.effect(`does not retry permanent ${error.kind} ${error.status ?? ""} failures`, () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + yield* TestClock.adjust("10 minutes") + expect((yield* Ref.get(calls)).length).toBe(1) + expect(yield* cache.getFailure("kilo")).toEqual(error) + }), + ).pipe(Effect.provide(layer(calls, [{ models: {}, error }]))) + }), + ) +} + +it.effect("cancels pending recovery when switching organizations", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + yield* cache.clear("kilo") + const next = { ...options, kilocodeOrganizationId: "org-b" } + yield* cache.fetch("kilo", next) + yield* TestClock.adjust("10 minutes") + expect(yield* Ref.get(calls)).toEqual([options, next]) + expect(yield* cache.get("kilo")).toEqual(catalog.models) + }), + ).pipe(Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }, catalog]))) + }), +) + +it.effect("does not retry an old endpoint after a newer endpoint fails", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + const next = { ...options, baseURL: "https://other.test/api/organizations/org-a" } + yield* cache.refresh("kilo", next) + yield* TestClock.adjust("10 minutes") + expect(yield* Ref.get(calls)).toEqual([options, next]) + expect(yield* cache.getFailure("kilo")).toEqual({ kind: "unauthorized", status: 403 }) + }), + ).pipe( + Effect.provide( + layer(calls, [ + { models: {}, error: { kind: "network" } }, + { models: {}, error: { kind: "unauthorized", status: 403 } }, + ]), + ), + ) + }), +) + +it.effect("does not reuse another organization's warm catalog", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + expect(yield* cache.fetch("kilo", options)).toEqual(catalog.models) + const next = { ...options, kilocodeOrganizationId: "org-b" } + expect(yield* cache.fetch("kilo", next)).toEqual({}) + expect(yield* Ref.get(calls)).toEqual([options, next]) + expect(yield* cache.get("kilo")).toEqual({}) + }), + ).pipe(Effect.provide(layer(calls, [catalog, { models: {}, error: { kind: "unauthorized", status: 403 } }]))) + }), +) + +it.effect("selects a new endpoint through fetch and stops the old recovery", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + const next = { ...options, baseURL: "https://other.test/api/organizations/org-a" } + expect(yield* cache.fetch("kilo", next)).toEqual(catalog.models) + yield* TestClock.adjust("10 minutes") + expect(yield* Ref.get(calls)).toEqual([options, next]) + expect(yield* cache.get("kilo")).toEqual(catalog.models) + }), + ).pipe(Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }, catalog]))) + }), +) + +it.effect("keys implicit catalog requests by their resolved credentials", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const account = yield* Ref.make("org-a") + const cfg = TestConfig.layer({ + get: () => + Ref.get(account).pipe( + Effect.map((id) => ({ provider: { kilo: { options: { ...options, kilocodeOrganizationId: id } } } })), + ), + }) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + yield* Ref.set(account, "org-b") + expect(yield* cache.fetch("kilo")).toEqual({}) + expect((yield* Ref.get(calls)).map((call) => call.kilocodeOrganizationId)).toEqual(["org-a", "org-b"]) + }), + ).pipe(Effect.provide(layer(calls, [catalog, { models: {}, error: { kind: "unauthorized", status: 403 } }], cfg))) + }), +) + +it.effect("caps retry delays at five minutes", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + for (const delay of [30, 60, 120, 240, 300, 300]) { + const count = (yield* Ref.get(calls)).length + yield* TestClock.adjust(`${delay - 1} seconds`) + expect((yield* Ref.get(calls)).length).toBe(count) + yield* TestClock.adjust("1 second") + expect((yield* Ref.get(calls)).length).toBe(count + 1) + } + }), + ).pipe(Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }]))) + }), +) + +it.effect("invalidates worktree state when a normal fetch recovers an expired catalog", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const clock = yield* Effect.acquireRelease( + Effect.sync(() => spyOn(Date, "now").mockReturnValue(0)), + (clock) => Effect.sync(() => clock.mockRestore()), + ) + yield* Effect.gen(function* () { + const cache = yield* ModelCache.Service + const state = yield* InstanceState.make(() => cache.fetch("kilo", options)) + yield* ModelsRefresh.watch(state) + expect(yield* ScopedCache.get(state.cache, "worktree")).toEqual({}) + clock.mockReturnValue(300001) + expect(yield* cache.fetch("kilo", options)).toEqual(catalog.models) + expect(yield* ScopedCache.get(state.cache, "worktree")).toEqual(catalog.models) + yield* TestClock.adjust("30 seconds") + expect((yield* Ref.get(calls)).length).toBe(2) + }).pipe( + Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }, catalog])), + provideInstance(process.cwd()), + ) + }), +) + +it.effect("reports an empty catalog and recovers the real Provider.getModel path", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const cfg = TestConfig.layer({ get: () => Effect.succeed({ provider: { kilo: { options } } }) }) + const access = Layer.mock(Auth.Service)({ get: () => Effect.succeed(undefined), all: () => Effect.succeed({}) }) + const models = ModelsDev.layer.pipe( + Layer.provide(layer(calls, [{ models: {}, error: { kind: "network" } }, catalog], cfg)), + Layer.provide(cfg), + Layer.provide(access), + Layer.provide(Layer.mock(Core.Service)({ get: () => Effect.succeed({}), refresh: () => Effect.void })), + ) + const graph = LayerNode.compile(Provider.node, [ + [ModelsDev.node, models], + [Config.node, cfg], + [Auth.node, access], + [Plugin.node, Layer.mock(Plugin.Service)({ list: () => Effect.succeed([]) })], + ]) + yield* Provider.Service.use((provider) => + Effect.gen(function* () { + const id = ProviderV2.ID.make("kilo") + const model = ModelV2.ID.make("allowed") + const missing = yield* provider.getModel(id, model).pipe(Effect.flip) + expect(missing._tag).toBe("ProviderModelNotFoundError") + expect(missing.modelsEmpty).toBe(true) + yield* TestClock.adjust("30 seconds") + expect(yield* provider.getModel(id, model)).toMatchObject({ id: "allowed", providerID: "kilo" }) + const typo = yield* provider.getModel(id, ModelV2.ID.make("typo")).pipe(Effect.flip) + expect(typo.modelsEmpty).toBe(false) + }), + ).pipe(Effect.provide(graph), provideInstance(process.cwd())) + }), +) From b1642e87c9cefa313bf6fa052edcd842f126e768 Mon Sep 17 00:00:00 2001 From: YiMing Zhang Date: Wed, 30 Sep 2026 21:17:23 +0800 Subject: [PATCH 042/353] fix(cli): rearm and bound catalog recovery retries Signed-off-by: YiMing Zhang --- packages/opencode/src/provider/model-cache.ts | 10 ++++- .../test/kilocode/catalog-recovery.test.ts | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 95a45b69e66..4db558999f5 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -244,6 +244,7 @@ export const layer: Layer.Layer< const cached = entry.cached if (cached && cached.expires > Date.now()) { yield* commit(entry.providerID, version, entry, cached.result) + yield* recover(entry, cached.result) return cached.result } @@ -298,7 +299,10 @@ export const layer: Layer.Layer< const entry = yield* cell(providerID, options) selected.set(providerID, entry) const cached = active.get(providerID) === entry ? yield* get(providerID) : undefined - if (cached) return cached + if (cached) { + if (entry.cached) yield* recover(entry, entry.cached.result) + return cached + } const version = (versions.get(providerID) ?? 0) + 1 versions.set(providerID, version) log.info("fetching models", { providerID }) @@ -327,7 +331,8 @@ export const layer: Layer.Layer< ) return entry.recovery = yield* Effect.gen(function* () { - for (let delay = 30; ; delay = Math.min(delay * 2, 300)) { + for (let attempt = 0; attempt < 6; attempt++) { + const delay = Math.min(30 * 2 ** attempt, 300) yield* Effect.sleep(Duration.seconds(delay)) // A newer account or endpoint must not be replaced by this retry. if (selected.get(entry.providerID) !== entry || active.get(entry.providerID) !== entry) return @@ -342,6 +347,7 @@ export const layer: Layer.Layer< if (retryable(next)) continue return } + log.warn("catalog recovery attempts exhausted", { providerID: entry.providerID, attempts: 6 }) }).pipe(Effect.ensuring(Effect.sync(() => (entry.recovery = undefined))), Effect.forkIn(scope)) }) diff --git a/packages/opencode/test/kilocode/catalog-recovery.test.ts b/packages/opencode/test/kilocode/catalog-recovery.test.ts index 3b7e212955e..71c43c3f85b 100644 --- a/packages/opencode/test/kilocode/catalog-recovery.test.ts +++ b/packages/opencode/test/kilocode/catalog-recovery.test.ts @@ -77,6 +77,7 @@ it.effect("continues catalog recovery after its only caller times out", () => yield* TestClock.adjust("1 second") expect(Option.isNone(yield* Fiber.join(caller))).toBe(true) yield* Deferred.succeed(wait, undefined) + yield* Effect.yieldNow yield* TestClock.adjust("30 seconds") expect(yield* cache.get("kilo")).toEqual(catalog.models) expect(yield* Ref.get(calls)).toEqual([options, options]) @@ -232,6 +233,23 @@ it.effect("selects a new endpoint through fetch and stops the old recovery", () }), ) +it.effect("restarts recovery when a failed catalog is reselected within its TTL", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + yield* cache.fetch("kilo", { ...options, kilocodeOrganizationId: "org-b" }) + yield* TestClock.adjust("30 seconds") + expect(yield* cache.fetch("kilo", options)).toEqual({}) + yield* TestClock.adjust("30 seconds") + expect(yield* cache.get("kilo")).toEqual(catalog.models) + expect((yield* Ref.get(calls)).length).toBe(3) + }), + ).pipe(Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }, catalog, catalog]))) + }), +) + it.effect("keys implicit catalog requests by their resolved credentials", () => Effect.gen(function* () { const calls = yield* Ref.make([]) @@ -266,11 +284,31 @@ it.effect("caps retry delays at five minutes", () => yield* TestClock.adjust("1 second") expect((yield* Ref.get(calls)).length).toBe(count + 1) } + yield* TestClock.adjust("10 minutes") + expect((yield* Ref.get(calls)).length).toBe(7) }), ).pipe(Effect.provide(layer(calls, [{ models: {}, error: { kind: "network" } }]))) }), ) +it.effect("rearms an exhausted recovery burst on a later explicit fetch", () => + Effect.gen(function* () { + const calls = yield* Ref.make([]) + const failure: KiloModelsResult = { models: {}, error: { kind: "network" } } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo", options) + yield* TestClock.adjust("1050 seconds") + expect((yield* Ref.get(calls)).length).toBe(7) + expect(yield* cache.fetch("kilo", options)).toEqual({}) + yield* TestClock.adjust("30 seconds") + expect(yield* cache.get("kilo")).toEqual(catalog.models) + expect((yield* Ref.get(calls)).length).toBe(8) + }), + ).pipe(Effect.provide(layer(calls, [...Array.from({ length: 7 }, () => failure), catalog]))) + }), +) + it.effect("invalidates worktree state when a normal fetch recovers an expired catalog", () => Effect.gen(function* () { const calls = yield* Ref.make([]) From 1ab3cd262cafe59f6d17ff379cc916d68511915a Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 10:45:59 -0400 Subject: [PATCH 043/353] feat(jetbrains): add prompt navigator to chat transcript Long sessions had no way to jump between earlier prompts in the JetBrains chat transcript. The only navigation was manual scrolling or the jump-to-bottom button, so returning to an earlier prompt meant hunting through the whole conversation. The VS Code extension already ships a prompt rail, so this brings the same affordance to JetBrains. Draw the stripes on the existing session overlay layer so they stay above the transcript and its scrollbar. When the viewport has room to the right of the readable lane, the rail sits at the standard large inset; when the transcript is squeezed horizontally it covers the scrollbar column instead. Hit-testing is limited to the tick band so the scrollbar stays draggable above and below the ticks, and wheel events over the rail are forwarded to the scroll pane. The navigator balloon reuses SidePopupController with a new pointerless callout option and is aligned to the left of the rail. Prompt extraction, markdown preview cleanup, overflow capacity, and active-prompt selection live in a Swing-free object so they can be unit tested directly. --- .changeset/jetbrains-prompt-navigator.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 19 +- .../client/session/scroll/SessionScroll.kt | 65 ++++ .../client/session/ui/rail/PromptRail.kt | 211 ++++++++++++ .../session/ui/rail/PromptRailController.kt | 316 ++++++++++++++++++ .../client/session/ui/rail/PromptRailItems.kt | 157 +++++++++ .../client/session/ui/rail/PromptRailPopup.kt | 164 +++++++++ .../client/session/ui/style/SessionUiStyle.kt | 34 ++ .../ai/kilocode/client/ui/popup/SidePopup.kt | 2 + .../client/ui/popup/SidePopupController.kt | 9 + .../resources/messages/KiloBundle.properties | 5 + .../messages/KiloBundle_ar.properties | 6 + .../messages/KiloBundle_bs.properties | 6 + .../messages/KiloBundle_da.properties | 6 + .../messages/KiloBundle_de.properties | 6 + .../messages/KiloBundle_es.properties | 6 + .../messages/KiloBundle_fr.properties | 6 + .../messages/KiloBundle_ja.properties | 6 + .../messages/KiloBundle_ko.properties | 6 + .../messages/KiloBundle_nl.properties | 6 + .../messages/KiloBundle_no.properties | 6 + .../messages/KiloBundle_pl.properties | 6 + .../messages/KiloBundle_pt_BR.properties | 6 + .../messages/KiloBundle_ru.properties | 6 + .../messages/KiloBundle_th.properties | 6 + .../messages/KiloBundle_tr.properties | 6 + .../messages/KiloBundle_uk.properties | 6 + .../messages/KiloBundle_zh_CN.properties | 6 + .../messages/KiloBundle_zh_TW.properties | 6 + .../client/session/PromptRailLayoutTest.kt | 128 +++++++ .../session/ui/rail/PromptRailItemsTest.kt | 112 +++++++ .../ui/popup/SidePopupControllerTest.kt | 8 + 32 files changed, 1342 insertions(+), 1 deletion(-) create mode 100644 .changeset/jetbrains-prompt-navigator.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt diff --git a/.changeset/jetbrains-prompt-navigator.md b/.changeset/jetbrains-prompt-navigator.md new file mode 100644 index 00000000000..4457ba378e7 --- /dev/null +++ b/.changeset/jetbrains-prompt-navigator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add a prompt navigator to the chat transcript for jumping between prompts. 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 83625e6bccd..04bff877dfd 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 @@ -40,6 +40,7 @@ import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts +import ai.kilocode.client.session.ui.rail.PromptRailController import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.popup.HeaderPopupController @@ -197,6 +198,7 @@ class SessionUi( private lateinit var account: SessionAccountOverlay private lateinit var drop: SessionDropOverlay private lateinit var overlay: SessionHoverCopyOverlay + internal lateinit var promptRail: PromptRailController private val hide = timers.timer(HIDE_MS, repeats = false) { if (disposed || !this::drop.isInitialized) return@timer drop.setActive(false) @@ -655,6 +657,14 @@ class SessionUi( java.awt.Rectangle(0, 0, pane.width, pane.height) } root.overlay.setComponentZOrder(drop, 0) + promptRail = PromptRailController( + root = root, + model = controller.model, + messages = messageBody, + scroll = scroll, + parent = this, + timers = timers, + ) if (!readonly) { prompt.onFileDrag = ::syncDrop prompt.installFileDrop(root, "session-root") @@ -894,7 +904,12 @@ class SessionUi( private fun bindStyle() { addHierarchyListener { event -> if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener - if (isShowing) refreshBranch() else popup.hideAll() + if (isShowing) { + refreshBranch() + } else { + popup.hideAll() + promptRail.hideAll() + } } val bus = ApplicationManager.getApplication().messageBus.connect(this) @@ -1418,6 +1433,7 @@ class SessionUi( prompt.applyStyle(style) connection.applyStyle(style) scroll.applyStyle(style) + promptRail.applyStyle(style) empty?.applyStyle(style) refresh() } @@ -1458,6 +1474,7 @@ class SessionUi( branchJob?.cancel() hide.stop() popup.hideAll() + if (this::promptRail.isInitialized) promptRail.hideAll() modalFocus = null empty = null if (this::root.isInitialized) root.setModalContent(null) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 6279bdd6e00..6af29b25150 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -180,6 +180,32 @@ internal class SessionScroll( return true } + /** + * Scrolls so [id]'s turn sits at the top of the viewport, like [scrollMessageBottom] but anchored to + * the top of the turn rather than the bottom of a message — used by the prompt navigator, whose + * ticks jump to where a prompt starts rather than where the transcript ends. + */ + @RequiresEdt + fun scrollMessageTop(id: String): Boolean { + val target = messages.findTurn(id) ?: return false + if (!target.isVisible) return false + user = false + pause = false + stable = -1 + auto = true + show(messages) + auto = false + val gen = ++seq + if (SwingUtilities.isEventDispatchThread()) { + topPass(gen, id, FOLLOW_PASSES) + return true + } + ApplicationManager.getApplication().invokeLater { + topPass(gen, id, FOLLOW_PASSES) + } + return true + } + @RequiresEdt fun following(): Boolean { return component.viewport.view === messages && tail @@ -372,6 +398,39 @@ internal class SessionScroll( } } + @RequiresEdt + private fun topPass(id: Int, message: String, remaining: Int) { + if (id != seq) return + val target = messages.findTurn(message) + if (target == null || !target.isVisible) { + stable = -1 + updateJump() + return + } + auto = true + try { + layoutScroll() + val y = messageTop(target) + component.viewport.viewPosition = Point(0, y) + bar.value = y + tail = near() + updateJump() + } finally { + auto = false + } + syncValue() + if (remaining <= 0) { + stable = -1 + return + } + val next = messageTop(target) + val left = if (next == stable) remaining - 1 else FOLLOW_PASSES + stable = next + ApplicationManager.getApplication().invokeLater { + topPass(id, message, left) + } + } + @RequiresEdt private fun layoutScroll() { component.validate() @@ -394,6 +453,12 @@ internal class SessionScroll( return (point.y - extent).coerceIn(0, bottom()) } + @RequiresEdt + private fun messageTop(target: JComponent): Int { + val point = SwingUtilities.convertPoint(target, Point(0, 0), messages) + return point.y.coerceIn(0, bottom()) + } + @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt new file mode 100644 index 00000000000..f32a05c1a01 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt @@ -0,0 +1,211 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import java.awt.AlphaComposite +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import java.awt.event.MouseWheelEvent +import java.awt.event.MouseWheelListener +import javax.swing.JComponent +import javax.swing.SwingUtilities +import kotlin.math.max +import kotlin.math.min + +/** + * Paints the prompt navigator ticks and dispatches hover/click/wheel input for them. Lives on the + * session overlay layer (see [ai.kilocode.client.ui.LayeredOverlayPanel]); [PromptRailController] owns + * its bounds, model and popup wiring. + * + * [contains] only reports true within the vertical span the ticks actually occupy — above and below + * that band, in narrow layouts where the rail sits over the scrollbar column, events fall through to + * the scrollbar underneath. + */ +internal class PromptRail : JComponent() { + private var entries: List = emptyList() + private var items: List = emptyList() + private var active: Int? = null + private var hover = -1 + private var open = -1 + private var available = true + + var centered = false + + var onHover: ((PromptRailEntry?) -> Unit)? = null + var onSelect: ((PromptRailEntry) -> Unit)? = null + + var onWheel: (() -> Unit)? = null + + /** + * Where a mouse wheel event over the rail is redirected. The rail sits on the overlay layer, not + * inside the scroll pane, so a wheel event delivered to it would otherwise never reach the + * transcript — [PromptRailController] points this at the scroll pane once it is built. + */ + var wheelTarget: JComponent? = null + + init { + isOpaque = false + addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) = setHover(-1) + override fun mouseClicked(e: MouseEvent) { + val idx = indexAt(e.y) + if (idx < 0) return + onSelect?.invoke(entries[idx]) + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + if (e.modifiersEx and BUTTON_MASK != 0) return + setHover(indexAt(e.y)) + } + }) + addMouseWheelListener(MouseWheelListener { e -> forward(e) }) + } + + /** True while a popup for this rail's tick at [index] is showing, so that tick stays full size. */ + fun setOpen(index: Int) { + if (open == index) return + open = index + repaint() + } + + fun setAvailable(value: Boolean) { + if (available == value) return + available = value + syncVisible() + } + + /** Replaces the rail's data. Repaints only when something visible actually changed. */ + fun update(items: List, entries: List, active: Int?) { + val changed = this.items != items || this.entries != entries || this.active != active + this.items = items + this.entries = entries + this.active = active + syncVisible() + if (!changed) return + revalidate() + repaint() + } + + fun entries(): List = entries + + fun items(): List = items + + fun active(): Int? = active + + fun available(): Boolean = available + + /** Center y, in this component's own coordinates, of tick [index] — used to place the popup. */ + fun tickCenterY(index: Int): Int { + val geo = geometry() ?: return height / 2 + return geo.top + index * geo.step + geo.step / 2 + } + + override fun contains(x: Int, y: Int): Boolean { + val geo = geometry() ?: return false + if (y < geo.top || y >= geo.top + geo.step * entries.size) return false + return x in 0 until width + } + + override fun paintComponent(g: Graphics) { + val geo = geometry() ?: return + val g2 = g.create() as Graphics2D + try { + if (hover < 0 && open < 0) g2.composite = AlphaComposite.SrcOver.derive(0.5f) + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + for (i in entries.indices) { + paintEntry(g2, geo, i) + } + } finally { + g2.dispose() + } + } + + override fun getPreferredSize() = java.awt.Dimension(JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER), 0) + + private fun paintEntry(g2: Graphics2D, geo: Geometry, index: Int) { + val entry = entries[index] + val y = geo.top + index * geo.step + geo.step / 2 + val over = index == hover || index == open + val isActive = entry is PromptRailEntry.Prompt && entry.index == active + val queued = entry is PromptRailEntry.Prompt && items.getOrNull(entry.index)?.queued == true + val length = when { + over -> JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER) + isActive -> JBUI.scale(SessionUiStyle.PromptRail.TICK_ACTIVE) + else -> JBUI.scale(SessionUiStyle.PromptRail.TICK_REST) + } + val thickness = JBUI.scale(SessionUiStyle.PromptRail.TICK_THICKNESS) + val x = if (centered) (width - length) / 2 else width - length + g2.color = when { + queued -> SessionUiStyle.PromptRail.queuedColor() + over || isActive -> SessionUiStyle.PromptRail.activeColor() + else -> SessionUiStyle.PromptRail.restColor() + } + if (entry is PromptRailEntry.Overflow) { + paintDashed(g2, x, y - thickness / 2, length, thickness) + return + } + g2.fillRect(x, y - thickness / 2, length, thickness) + } + + private fun paintDashed(g2: Graphics2D, x: Int, y: Int, length: Int, thickness: Int) { + val dash = JBUI.scale(2) + var pos = x + val end = x + length + while (pos < end) { + val w = min(dash, end - pos) + g2.fillRect(pos, y, w, thickness) + pos += dash * 2 + } + } + + private fun setHover(index: Int) { + if (hover == index) return + hover = index + onHover?.invoke(entries.getOrNull(index)) + repaint() + } + + private fun indexAt(y: Int): Int { + val geo = geometry() ?: return -1 + if (entries.isEmpty()) return -1 + val idx = (y - geo.top) / geo.step + if (idx !in entries.indices) return -1 + return idx + } + + private fun forward(e: MouseWheelEvent) { + onWheel?.invoke() + val target = wheelTarget ?: return + target.dispatchEvent(SwingUtilities.convertMouseEvent(this, e, target)) + } + + private fun syncVisible() { + val show = available && entries.size >= 2 + if (isVisible != show) isVisible = show + } + + private fun geometry(): Geometry? { + if (entries.isEmpty() || height <= 0) return null + val pad = JBUI.scale(SessionUiStyle.PromptRail.RAIL_INSET) / 2 + val usable = max(0, height - 2 * pad) + val minStep = JBUI.scale(SessionUiStyle.PromptRail.STEP_MIN) + val maxStep = JBUI.scale(SessionUiStyle.PromptRail.STEP_MAX) + val ideal = if (entries.isEmpty()) minStep else usable / entries.size + val step = ideal.coerceIn(minStep, maxStep) + val total = step * entries.size + val top = (height - total) / 2 + return Geometry(top, step) + } + + private data class Geometry(val top: Int, val step: Int) + + private companion object { + const val BUTTON_MASK = MouseEvent.BUTTON1_DOWN_MASK or + MouseEvent.BUTTON2_DOWN_MASK or MouseEvent.BUTTON3_DOWN_MASK + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt new file mode 100644 index 00000000000..df5a789192a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt @@ -0,0 +1,316 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.scroll.SessionScroll +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.popup.SidePopupController +import ai.kilocode.client.ui.popup.SidePopupContent +import ai.kilocode.client.ui.popup.SidePopupRequest +import ai.kilocode.client.ui.popup.SidePopupSpot +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.AdjustmentListener +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.event.ChangeListener + +/** + * Owns the prompt navigator rail: its bounds on the session overlay, its data (from [SessionModel] + * events), the active tick (from scroll position), and the balloon that lists every prompt. + * + * Placement mirrors the jump-to-bottom button's readable-lane math (see [SessionScroll]): when the + * viewport has room right of the lane, the rail sits at the standard large inset from the panel edge; + * otherwise it covers the scrollbar column so it stays reachable in a narrow sidebar. + */ +internal class PromptRailController( + private val root: SessionRootPanel, + private val model: SessionModel, + private val messages: SessionMessageListPanel, + private val scroll: SessionScroll, + parent: Disposable, + timers: UiTimerSource = UiTimers, +) : Disposable { + val rail = PromptRail() + private val popup = SidePopupController(timers, SessionUiStyle.PromptRail.OPEN_MS) + private var style = SessionEditorStyle.current() + private var dirty = false + private var dead = false + private var target: Any? = null + private val adjustment = AdjustmentListener { recomputeActive() } + private val change = ChangeListener { recomputeActive() } + private val geometry = object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) = scheduleRebuild() + override fun componentShown(e: ComponentEvent) = scheduleRebuild() + override fun componentHidden(e: ComponentEvent) = scheduleRebuild() + } + + init { + rail.wheelTarget = scroll.component + rail.onSelect = { entry -> select(entry) } + rail.onHover = { entry -> hover(entry) } + rail.onWheel = { + popup.hideAll() + rail.setOpen(-1) + } + root.addOverlay(rail) { pane, _ -> bounds(pane) } + + scroll.bar.addAdjustmentListener(adjustment) + scroll.component.viewport.addChangeListener(change) + scroll.component.viewport.addComponentListener(geometry) + scroll.bar.addComponentListener(geometry) + + model.addListener(parent) { event -> + when (event) { + is SessionModelEvent.TurnAdded, + is SessionModelEvent.TurnUpdated, + is SessionModelEvent.TurnRemoved, + is SessionModelEvent.HistoryLoaded, + is SessionModelEvent.Cleared, + is SessionModelEvent.RevertChanged, + is SessionModelEvent.QueueChanged, + is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageRemoved, + -> scheduleRebuild() + + is SessionModelEvent.ContentAdded, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentRemoved, + is SessionModelEvent.ContentDelta, + -> scheduleRebuild() + + is SessionModelEvent.MessageUpdated, + is SessionModelEvent.StateChanged, + is SessionModelEvent.SessionUpdated, + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.BackgroundAgentsUpdated, + is SessionModelEvent.HeaderUpdated, + is SessionModelEvent.Compacted, + -> Unit + } + } + + Disposer.register(parent, this) + rebuild() + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + this.style = style + root.overlay.revalidate() + root.overlay.repaint() + } + + @RequiresEdt + fun refresh() { + rebuild() + } + + @RequiresEdt + fun hideAll() { + popup.hideAll() + rail.setOpen(-1) + target = null + } + + @RequiresEdt + override fun dispose() { + dead = true + dirty = false + scroll.bar.removeAdjustmentListener(adjustment) + scroll.component.viewport.removeChangeListener(change) + scroll.component.viewport.removeComponentListener(geometry) + scroll.bar.removeComponentListener(geometry) + hideAll() + } + + @RequiresEdt + private fun scheduleRebuild() { + if (dead || dirty) return + dirty = true + ApplicationManager.getApplication().invokeLater { + if (dead || !dirty) return@invokeLater + dirty = false + rebuild() + } + } + + @RequiresEdt + private fun rebuild() { + val items = PromptRailItems.items(model) + val capacity = PromptRailItems.capacity( + scroll.component.viewport.height, + JBUI.scale(SessionUiStyle.PromptRail.STEP_MIN), + JBUI.scale(SessionUiStyle.PromptRail.RAIL_INSET) / 2, + ) + val entries = PromptRailItems.entries(items, capacity) + rail.update(items, entries, activeIndex(items)) + rail.setAvailable(scroll.view === messages) + relayout() + } + + @RequiresEdt + private fun recomputeActive() { + rail.setAvailable(scroll.view === messages) + if (rail.items().isEmpty()) return + rail.update(rail.items(), rail.entries(), activeIndex(rail.items())) + } + + private fun activeIndex(items: List): Int? { + if (items.isEmpty()) return null + val tops = items.map { top(it.id) } + val vp = scroll.component.viewport + val bar = scroll.bar + val scrollable = bar.maximum > bar.visibleAmount + val atTop = vp.viewPosition.y <= 0 + return PromptRailItems.active(tops, vp.viewPosition.y, scrollable, atTop) + } + + private fun top(id: String): Int { + val turn = messages.findTurn(id) ?: return 0 + return SwingUtilities.convertPoint(turn, Point(0, 0), messages).y + } + + @RequiresEdt + private fun relayout() { + root.overlay.revalidate() + root.overlay.repaint() + } + + private fun select(entry: PromptRailEntry) { + when (entry) { + is PromptRailEntry.Prompt -> jump(rail.items().getOrNull(entry.index)?.id) + is PromptRailEntry.Overflow -> { + val index = rail.entries().indexOfFirst { it == entry } + rail.setOpen(index) + target = entry + popup.showNow(entry, this) { request(index) } + } + } + } + + private fun jump(id: String?) { + if (id == null) return + scroll.scrollMessageTop(id) + } + + private fun hover(entry: PromptRailEntry?) { + if (entry == null) { + target?.let(popup::notifyExit) + rail.setOpen(-1) + return + } + val index = rail.entries().indexOf(entry) + rail.setOpen(index) + val showing = popup.showing() + target = entry + if (showing) { + popup.hideAll() + target = entry + popup.showNow(entry, this) { request(index) } + return + } + popup.show(entry, this) { request(index) } + } + + private fun request(hoveredIndex: Int): SidePopupRequest { + val hoveredItemIndex = when (val e = rail.entries().getOrNull(hoveredIndex)) { + is PromptRailEntry.Prompt -> e.index + else -> -1 + } + return SidePopupRequest( + build = { + PromptRailPopup( + items = rail.items(), + hovered = hoveredItemIndex, + onSelect = { item -> jump(item.id) }, + onFirst = { rail.items().firstOrNull()?.let { jump(it.id) } }, + onLatest = { rail.items().lastOrNull()?.let { jump(it.id) } }, + ) + }, + place = { built -> place(hoveredIndex, built) }, + ) + } + + /** Balloon anchor: to the left of the rail, no callout, vertically centered on the hovered tick. */ + private fun place(index: Int, body: SidePopupContent): SidePopupSpot? { + val pane = SwingUtilities.getRootPane(rail)?.layeredPane ?: return null + if (!rail.isShowing) return null + val railBounds = SwingUtilities.convertRectangle(rail.parent, rail.bounds, pane) + val insets = UiStyle.Balloon.insets() + val shadow = UiStyle.Balloon.shadow() + val chromeWidth = insets.left + insets.right + shadow * 2 + val chromeHeight = insets.top + insets.bottom + shadow * 2 + val maxWidth = (railBounds.x - UiStyle.Gap.lg() - chromeWidth) + .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + val maxHeight = (railBounds.height - UiStyle.Gap.lg() - chromeHeight) + .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + if (maxWidth <= 0 || maxHeight <= 0) return null + body.fitWithin(maxWidth, maxHeight) + val y = rail.tickCenterY(index.coerceAtLeast(0)) + val point = SwingUtilities.convertPoint(rail, Point(0, y), pane) + val gap = UiStyle.Gap.lg() + return SidePopupSpot( + pane = pane, + point = Point(point.x - gap, point.y), + position = Balloon.Position.atLeft, + distance = 0, + callout = false, + ) + } + + /** + * Rail bounds, in overlay coordinates: standard inset right of the readable lane when there is + * room, or centered over the scrollbar column when the transcript is squeezed horizontally. + */ + private fun bounds(pane: JPanel): Rectangle { + val vp = scroll.component.viewport + if (vp.parent == null || vp.width <= 0 || vp.height <= 0) return Rectangle() + val vpBounds = SwingUtilities.convertRectangle(vp, Rectangle(vp.size), pane) + val lane = minOf(vp.width, SessionUiStyle.SessionLayout.readableWidth(messages, style.transcriptFont)) + val laneRight = vp.x + (vp.width + lane) / 2 + val railW = JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER) + val pad = UiStyle.Gap.pad() + val vpRightInPane = vpBounds.x + vpBounds.width + val bar = scroll.bar + val barBounds = if (bar.isVisible && bar.width > 0) { + SwingUtilities.convertRectangle(bar, Rectangle(bar.size), pane) + } else { + null + } + val contentRight = barBounds?.x ?: vpRightInPane + val laneRightInPane = SwingUtilities.convertPoint(vp, Point(laneRight - vp.x, 0), pane).x + val wide = contentRight - laneRightInPane >= railW + 2 * pad + val top = vpBounds.y + val height = vpBounds.height + if (wide) { + rail.centered = false + val right = contentRight - pad + return Rectangle(right - railW, top, railW, height) + } + rail.centered = true + if (barBounds != null) { + val width = barBounds.width.coerceAtMost(railW).coerceAtLeast(JBUI.scale(2)) + val x = barBounds.x + (barBounds.width - width) / 2 + return Rectangle(x, top, width, height) + } + val padding = JBUI.scale(SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING) + val width = padding.coerceAtMost(railW).coerceAtLeast(JBUI.scale(2)) + return Rectangle(vpRightInPane - padding + (padding - width) / 2, top, width, height) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt new file mode 100644 index 00000000000..ed383183f7c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt @@ -0,0 +1,157 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.Text +import kotlin.math.floor + +/** One navigable prompt: a user turn, its preview text, and the start of its response, if any. */ +data class PromptRailItem( + val id: String, + val queued: Boolean, + val prompt: String, + val answer: String, +) + +/** One rail tick: a navigable prompt, or a placeholder standing in for a run of hidden prompts. */ +sealed class PromptRailEntry { + data class Prompt(val index: Int) : PromptRailEntry() + data class Overflow(val hidden: IntRange) : PromptRailEntry() +} + +/** + * Pure logic for the prompt navigator: which turns are navigable, their preview text, how many ticks + * fit a given rail height, and which ticks are kept when there are more prompts than fit. + * + * Mirrors `packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts` for JetBrains, without + * lazy history paging — the JetBrains transcript always loads full history up front. + */ +object PromptRailItems { + const val PROMPT_LIMIT = 160 + const val ANSWER_LIMIT = 220 + + /** One item per navigable user turn, in transcript order. */ + fun items(model: SessionModel): List { + val out = mutableListOf() + for (turn in model.turns()) { + val anchor = model.message(turn.id) ?: continue + if (anchor.info.role != "user") continue + if (anchor.parts.values.any { it is Compaction }) continue + if (model.isRevertedMessage(turn.id)) continue + val prompt = truncate(preview(text(anchor.parts.values)), PROMPT_LIMIT) + val answer = truncate(preview(answerText(model, turn.messageIds)), ANSWER_LIMIT) + out.add( + if (prompt.isEmpty()) { + PromptRailItem(turn.id, model.isQueued(turn.id), answer, "") + } else { + PromptRailItem(turn.id, model.isQueued(turn.id), prompt, answer) + }, + ) + } + return out + } + + /** Joined, non-blank [Text] parts of the first assistant message in [messageIds] that has any. */ + private fun answerText(model: SessionModel, messageIds: List): String { + for (id in messageIds.drop(1)) { + val msg = model.message(id) ?: continue + if (msg.info.role != "assistant") continue + val joined = text(msg.parts.values) + if (joined.isNotBlank()) return joined + } + return "" + } + + private fun text(parts: Collection): String = parts + .filterIsInstance() + .map { it.content.toString() } + .filter { it.isNotBlank() } + .joinToString("\n") + + /** + * Plain-text preview of markdown [text]: fenced code blocks, inline code and images are dropped, + * links keep only their label, leading heading/list/quote markers are stripped, and whitespace is + * collapsed to single spaces. + */ + fun preview(text: String): String { + var out = text + out = out.replace(FENCE, " ") + out = out.replace(IMAGE, " ") + out = out.replace(LINK) { it.groupValues[1] } + out = out.replace(INLINE_CODE, " ") + out = out.lineSequence() + .map { it.replace(LEADING_MARKER, "") } + .joinToString("\n") + return out.replace(WHITESPACE, " ").trim() + } + + /** [text] cut to [limit] characters, with a trailing ellipsis when it was cut. */ + fun truncate(text: String, limit: Int): String { + if (text.length <= limit) return text + return text.take((limit - 1).coerceAtLeast(0)).trimEnd() + "…" + } + + /** Ticks that fit a rail of [height] px, spaced no closer than [stepMin] and no wider than [stepMax]. */ + fun capacity(height: Int, stepMin: Int, pad: Int): Int { + val usable = height - 2 * pad + if (usable <= 0) return 0 + return floor(usable.toDouble() / stepMin.toDouble()).toInt() + } + + /** + * Which [items] to show as ticks when only [capacity] fit: the first prompt, one overflow tick for + * everything hidden in between, and the most recent prompts. Below capacity 2 there is no overflow + * tick — see the branches below. + */ + fun entries(items: List, capacity: Int): List { + if (items.isEmpty() || capacity <= 0) return emptyList() + if (capacity == 1 || items.size <= capacity) { + return if (capacity == 1) { + listOf(PromptRailEntry.Prompt(items.lastIndex)) + } else { + items.indices.map { PromptRailEntry.Prompt(it) } + } + } + if (capacity == 2) { + return listOf(PromptRailEntry.Prompt(0), PromptRailEntry.Prompt(items.lastIndex)) + } + val tailCount = capacity - 2 + val tailStart = items.size - tailCount + return buildList { + add(PromptRailEntry.Prompt(0)) + add(PromptRailEntry.Overflow(1 until tailStart)) + for (i in tailStart until items.size) add(PromptRailEntry.Prompt(i)) + } + } + + /** + * Index into [items] that should read as active: the last prompt when the transcript cannot scroll, + * the first prompt when the viewport sits at the very top, otherwise the last prompt whose top is at + * or above [viewTop]. + */ + fun active(tops: List, viewTop: Int, scrollable: Boolean, atTop: Boolean): Int? { + if (tops.isEmpty()) return null + if (!scrollable) return tops.lastIndex + if (atTop) return 0 + var lo = 0 + var hi = tops.lastIndex + var found = -1 + while (lo <= hi) { + val mid = (lo + hi) / 2 + if (tops[mid] <= viewTop) { + found = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return if (found < 0) 0 else found + } + + private val FENCE = Regex("```[\\s\\S]*?```") + private val IMAGE = Regex("!\\[[^\\]]*]\\([^)]*\\)") + private val LINK = Regex("\\[([^\\]]*)]\\([^)]*\\)") + private val INLINE_CODE = Regex("`[^`]*`") + private val LEADING_MARKER = Regex("^\\s*(#{1,6}\\s+|[-*+>]\\s+)") + private val WHITESPACE = Regex("\\s+") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt new file mode 100644 index 00000000000..c0d88157b07 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt @@ -0,0 +1,164 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.PlainLabel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.popup.SidePopupContent +import com.intellij.icons.AllIcons +import com.intellij.openapi.util.Disposer +import com.intellij.ui.CollectionListModel +import com.intellij.ui.ListUtil +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.SwingTextTrimmer +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.ScrollPaneConstants + +/** + * Balloon body for the prompt navigator: a header with first/latest jump buttons, and a list of every + * navigable prompt with its answer preview. Wraps [HeaderPopupBody] for the shared clamp/scroll + * behavior every session balloon uses. + */ +internal class PromptRailPopup( + items: List, + hovered: Int, + private val onSelect: (PromptRailItem) -> Unit, + private val onFirst: () -> Unit, + private val onLatest: () -> Unit, +) : SidePopupContent { + private val disposer = Disposer.newDisposable("PromptRailPopup") + private val model = CollectionListModel(items) + private val list = JBList(model).apply { + isOpaque = false + visibleRowCount = items.size.coerceAtMost(8) + fixedCellHeight = JBUI.scale(ROW_HEIGHT) + cellRenderer = Renderer() + ListUtil.installAutoSelectOnMouseMove(this) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val idx = locationToIndex(e.point) + if (idx < 0) return + model.getElementAt(idx)?.let(onSelect) + } + }) + } + + private val first = HoverIcon().apply { + icon = AllIcons.Actions.MoveUp + toolTipText = KiloBundle.message("session.prompts.first") + addActionListener { onFirst() } + } + + private val latest = HoverIcon().apply { + icon = AllIcons.Actions.MoveDown + toolTipText = KiloBundle.message("session.prompts.latest") + addActionListener { onLatest() } + } + + private val body = HeaderPopupBody( + component = content(), + disposable = disposer, + background = UiStyle.Balloon.bg(), + ) + + override val component get() = body.component + override val disposable get() = body.disposable + override val background get() = body.background + + override fun fitWithin(width: Int, height: Int) = body.fitWithin(width, height) + + init { + if (hovered in 0 until model.size) { + list.selectedIndex = hovered + list.scrollRectToVisible(list.getCellBounds(hovered, hovered)) + } + } + + private fun content(): BorderLayoutPanel { + val header = BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.empty(4, 5, 4, 11) + add( + JBLabel(KiloBundle.message("session.prompts.navLabel")).apply { + foreground = UIUtil.getContextHelpForeground() + }, + BorderLayout.WEST, + ) + add( + Stack.horizontal(UiStyle.Gap.xs()).next(first).next(latest), + BorderLayout.EAST, + ) + } + val scroll = JBScrollPane( + list, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, + ).apply { + isOpaque = false + viewport.isOpaque = false + border = JBUI.Borders.empty() + } + return BorderLayoutPanel().apply { + isOpaque = false + add(header, BorderLayout.NORTH) + add(scroll, BorderLayout.CENTER) + } + } + + private class Renderer : ListCellRenderer { + private val title = PlainLabel().apply { + putClientProperty(SwingTextTrimmer.KEY, SwingTextTrimmer.ELLIPSIS_AT_RIGHT) + } + private val answer = JBTextArea().apply { + isEditable = false + isFocusable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + foreground = UIUtil.getContextHelpForeground() + } + private val panel = BorderLayoutPanel().apply { + border = JBUI.Borders.empty(9, 11) + add(Stack.vertical(UiStyle.Gap.xs()).next(title).next(answer)) + } + + override fun getListCellRendererComponent( + list: JList, + value: PromptRailItem?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + panel.isOpaque = selected + panel.background = list.selectionBackground + title.foreground = if (selected) list.selectionForeground else UIUtil.getLabelForeground() + title.text = value?.queuedTitle().orEmpty() + val response = value?.answer.orEmpty() + answer.isVisible = response.isNotBlank() || value?.prompt.isNullOrBlank() + answer.text = response.ifBlank { KiloBundle.message("session.prompts.noAnswer") } + return panel + } + + private fun PromptRailItem.queuedTitle(): String { + if (!queued) return prompt + return "${KiloBundle.message("session.queued")} · $prompt" + } + } + + private companion object { + const val ROW_HEIGHT = 76 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 0960dc8dcce..ddf832b25a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.style import ai.kilocode.client.ui.UiStyle import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI +import com.intellij.util.ui.NamedColorUtil import com.intellij.util.ui.UIUtil import java.awt.Color import java.awt.Component @@ -101,6 +102,39 @@ object SessionUiStyle { } } + /** Geometry and colors for the prompt navigator rail and its ticks. */ + object PromptRail { + /** Tick length across the rail, at rest. */ + const val TICK_REST = 9 + + /** Tick length across the rail when active. */ + const val TICK_ACTIVE = 13 + + /** Tick length across the rail at full size (hover or open). */ + const val TICK_HOVER = 16 + + /** Tick thickness on the scroll axis. */ + const val TICK_THICKNESS = 2 + + /** Closest allowed spacing between tick centers. */ + const val STEP_MIN = 7 + + /** Widest allowed spacing between tick centers. */ + const val STEP_MAX = 14 + + /** Total vertical padding reserved across the top and bottom of the rail. */ + const val RAIL_INSET = 24 + + /** Hover dwell, in ms, before the navigator balloon opens. */ + const val OPEN_MS = 350 + + fun restColor(): Color = UIUtil.getContextHelpForeground() + + fun activeColor(): Color = UIUtil.getLabelForeground() + + fun queuedColor(): Color = NamedColorUtil.getInactiveTextColor() + } + /** Shared tokens for individual transcript views and session views. */ object View { /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt index 9691e0207f5..5424599e118 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt @@ -28,6 +28,8 @@ internal class SidePopupSpot( val point: Point, val position: Balloon.Position, val distance: Int, + /** Whether the balloon points at [point] with the usual triangular callout. */ + val callout: Boolean = true, ) /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt index 5ed1003bcd7..9e7c77bfc97 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt @@ -71,6 +71,14 @@ internal class SidePopupController( showTimer.restart() } + /** Shows [request] immediately, without the normal hover dwell. */ + @RequiresEdt + fun showNow(key: Any, owner: Disposable, request: () -> SidePopupRequest?) { + show(key, owner, request) + showTimer.stop() + display() + } + @RequiresEdt fun notifyExit(key: Any) { if (target != key) return @@ -156,6 +164,7 @@ internal class SidePopupController( .setPointerSize(UiStyle.Balloon.pointer()) .setCornerToPointerDistance(spot.distance) .setCornerRadius(UiStyle.Balloon.arc()) + .setShowCallout(spot.callout) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) .setHideOnFrameResize(true) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 6f8be283769..78290a9f3d0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -80,6 +80,11 @@ feedback.dialog.discord=Join our Discord community feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom session.scroll.question=Scroll to question +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts session.copy.hover=Copy session.copy.prompt=Copy prompt session.copy.response=Copy response diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 0ab58655956..c39b4f64c7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=تعذّر فتح ملف ا settings.rules.info=القواعد هي ملفات تعليمات يضيفها Kilo إلى موجّه النظام في كل طلب، لذا تنطبق على جميع الجلسات. settings.rules.info.more=تُلتقط ملفات AGENTS.md تلقائيًا؛ هذه الصفحة مخصصة للمسارات الإضافية وأنماط المطابقة والروابط التي تريد إدراجها إلى جانبها. استخدم القواعد للقيود الدائمة مثل أسلوب الكتابة البرمجية أو متطلبات المراجعة: الأمر هو موجّه تشغّله، والمهارة تُحمَّل فقط عند ملاءمتها، أما القاعدة فهي فعّالة دائمًا. يؤدي توافق Claude Code أيضًا إلى تحميل تعليمات ومهارات CLAUDE.md من دليل إعدادات Claude Code الخاص بك ويتطلب إعادة التشغيل. settings.agentBehavior.description=تهيئة العملاء وخوادم MCP والقواعد والأوامر والمهارات. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index e3b6f80decc..bb63ff1f5e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Fajl komande nije bilo mogu settings.rules.info=Pravila su fajlovi s uputstvima koje Kilo dodaje u sistemski prompt svakog zahtjeva, tako da se primjenjuju na sve sesije. settings.rules.info.more=AGENTS.md fajlovi se automatski prepoznaju; ova stranica je namijenjena dodatnim putanjama, globovima i URL-ovima koje želite uključiti uz njih. Koristite pravila za trajna ograničenja poput stila kodiranja ili zahtjeva za pregled: komanda je prompt koji pokrećete, vještina se učitava samo kada je relevantna, a pravilo je uvijek aktivno. Claude Code kompatibilnost dodatno učitava CLAUDE.md uputstva i vještine iz vašeg direktorija Claude Code konfiguracije i zahtijeva ponovno pokretanje. settings.agentBehavior.description=Konfigurišite agente, MCP servere, pravila, komande i vještine. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 611975680a3..afaab019d84 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Kommandofilen kunne ikke åb settings.rules.info=Regler er instruktionsfiler, som Kilo tilføjer til systempromptet for hver anmodning, så de gælder for alle sessioner. settings.rules.info.more=AGENTS.md-filer hentes automatisk; denne side er til de ekstra stier, globs og URL'er, du ønsker inkluderet ud over dem. Brug regler til faste krav, såsom kodestil eller review-krav: en kommando er en prompt, du kører, en skill indlæses kun, når den er relevant, og en regel er altid aktiv. Claude Code-kompatibilitet indlæser derudover CLAUDE.md-instruktioner og skills fra din Claude Code-konfigurationsmappe og kræver en genstart. settings.agentBehavior.description=Konfigurer agenter, MCP-servere, regler, kommandoer og skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 8e1f29454c3..b7b2c713d66 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Die Befehlsdatei konnte nich settings.rules.info=Regeln sind Anleitungsdateien, die Kilo dem Systemprompt jeder Anfrage hinzufügt, sodass sie für alle Sitzungen gelten. settings.rules.info.more=AGENTS.md-Dateien werden automatisch erkannt; diese Seite dient den zusätzlichen Pfaden, Globs und URLs, die Sie zusätzlich einbeziehen möchten. Verwenden Sie Regeln für dauerhafte Vorgaben wie Coding-Style oder Review-Anforderungen: Ein Befehl ist ein Prompt, den Sie ausführen, ein Skill wird nur geladen, wenn er relevant ist, und eine Regel ist immer aktiv. Die Claude-Code-Kompatibilität lädt zusätzlich CLAUDE.md-Anleitungen und Skills aus Ihrem Claude-Code-Konfigurationsverzeichnis und erfordert einen Neustart. settings.agentBehavior.description=Konfigurieren Sie Agenten, MCP-Server, Regeln, Befehle und Skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 16e13e5c956..33ddd0a84cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=No se pudo abrir el archivo settings.rules.info=Las reglas son archivos de instrucciones que Kilo añade al prompt del sistema de cada solicitud, por lo que se aplican a todas las sesiones. settings.rules.info.more=Los archivos AGENTS.md se detectan automáticamente; esta página es para las rutas, patrones glob y URL adicionales que quieras incluir además de ellos. Usa reglas para restricciones permanentes, como el estilo de codificación o los requisitos de revisión: un comando es un prompt que ejecutas, una habilidad se carga solo cuando es relevante, y una regla siempre está activa. La compatibilidad con Claude Code además carga instrucciones CLAUDE.md y habilidades desde tu directorio de configuración de Claude Code, y requiere un reinicio. settings.agentBehavior.description=Configura agentes, servidores MCP, reglas, comandos y habilidades. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 1c895d3ef16..e1676a13077 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Impossible d'ouvrir le fichi settings.rules.info=Les règles sont des fichiers d'instructions que Kilo ajoute au prompt système de chaque requête, elles s'appliquent donc à toutes les sessions. settings.rules.info.more=Les fichiers AGENTS.md sont pris en compte automatiquement ; cette page concerne les chemins, motifs glob et URL supplémentaires que vous souhaitez inclure en plus. Utilisez les règles pour des contraintes permanentes telles que le style de code ou les exigences de revue : une commande est un prompt que vous exécutez, une compétence n'est chargée que lorsqu'elle est pertinente, et une règle est toujours active. La compatibilité Claude Code charge en plus les instructions CLAUDE.md et les compétences depuis le répertoire de configuration Claude Code, et nécessite un redémarrage. settings.agentBehavior.description=Configurez les agents, serveurs MCP, règles, commandes et compétences. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 1679ac90615..dd7b5f14ae2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=コマンドファイルを settings.rules.info=ルールとは、Kilo がすべてのリクエストのシステムプロンプトに追加する指示ファイルであり、すべてのセッションに適用されます。 settings.rules.info.more=AGENTS.md ファイルは自動的に取得されます。このページは、それらに加えて含めたい追加のパス、グロブ、URL のためのものです。コーディングスタイルやレビュー要件といった常時適用される制約にはルールを使用してください。コマンドは実行するプロンプト、スキルは関連性がある場合のみ読み込まれるもの、そしてルールは常に有効です。Claude Code 互換機能では、さらに Claude Code の設定ディレクトリから CLAUDE.md の指示とスキルを読み込み、再起動が必要です。 settings.agentBehavior.description=エージェント、MCP サーバー、ルール、コマンド、スキルを設定します。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 0c56257777e..73ef70c89f2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=편집기에서 명령 파 settings.rules.info=규칙은 Kilo가 모든 요청의 시스템 프롬프트에 추가하는 지침 파일로, 모든 세션에 적용됩니다. settings.rules.info.more=AGENTS.md 파일은 자동으로 인식됩니다. 이 페이지는 그 외에 포함하고 싶은 추가 경로, 글롭 패턴, URL을 위한 곳입니다. 코딩 스타일이나 리뷰 요구 사항과 같은 상시 제약에는 규칙을 사용하세요. 명령은 실행하는 프롬프트이고, 스킬은 관련이 있을 때만 로드되며, 규칙은 항상 적용됩니다. Claude Code 호환성을 사용하면 Claude Code 설정 디렉터리에서 CLAUDE.md 지침과 스킬도 추가로 로드되며 재시작이 필요합니다. settings.agentBehavior.description=에이전트, MCP 서버, 규칙, 명령, 스킬을 구성합니다. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 487be1983f8..07fc607dc56 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Het opdrachtbestand kon niet settings.rules.info=Regels zijn instructiebestanden die Kilo toevoegt aan de systeemprompt van elk verzoek, zodat ze voor alle sessies gelden. settings.rules.info.more=AGENTS.md-bestanden worden automatisch opgepikt; deze pagina is voor de extra paden, globs en URL's die je daar bovenop wilt toevoegen. Gebruik regels voor permanente beperkingen zoals codeerstijl of reviewvereisten: een opdracht is een prompt die je uitvoert, een skill wordt alleen geladen wanneer deze relevant is, en een regel is altijd actief. Claude Code-compatibiliteit laadt daarnaast CLAUDE.md-instructies en skills uit je Claude Code-configuratiemap en vereist een herstart. settings.agentBehavior.description=Configureer agents, MCP-servers, regels, opdrachten en skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 448524bafd8..b97cc52c936 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Kunne ikke åpne kommandofil settings.rules.info=Regler er instruksjonsfiler Kilo legger til i systempromptet for hver forespørsel, så de gjelder for alle økter. settings.rules.info.more=AGENTS.md-filer plukkes opp automatisk; denne siden er for de ekstra banene, mønstrene og URL-ene du vil inkludere i tillegg til dem. Bruk regler for faste begrensninger som kodestil eller krav til gjennomgang: en kommando er en prompt du kjører, en ferdighet lastes kun når den er relevant, og en regel er alltid aktiv. Claude Code-kompatibilitet laster i tillegg CLAUDE.md-instruksjoner og ferdigheter fra Claude Code-konfigurasjonsmappen din, og krever en omstart. settings.agentBehavior.description=Konfigurer agenter, MCP-servere, regler, kommandoer og ferdigheter. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 6ab9e339149..0d5792e703e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Nie udało się otworzyć pl settings.rules.info=Reguły to pliki instrukcji, które Kilo dodaje do systemowego promptu każdego żądania, więc obowiązują we wszystkich sesjach. settings.rules.info.more=Pliki AGENTS.md są wykrywane automatycznie; ta strona służy do dodania dodatkowych ścieżek, wzorców glob i adresów URL, które mają być uwzględnione oprócz nich. Reguł używaj do stałych ograniczeń, takich jak styl kodowania czy wymagania dotyczące przeglądu: polecenie to prompt, który uruchamiasz, umiejętność jest wczytywana tylko wtedy, gdy jest istotna, a reguła obowiązuje zawsze. Zgodność z Claude Code dodatkowo wczytuje instrukcje z plików CLAUDE.md oraz umiejętności z katalogu konfiguracyjnego Claude Code i wymaga ponownego uruchomienia. settings.agentBehavior.description=Skonfiguruj agentów, serwery MCP, reguły, polecenia i umiejętności. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index cdff5c1f8f1..0946e15beac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Não foi possível abrir o a settings.rules.info=Rules são arquivos de instrução que o Kilo adiciona ao prompt do sistema de cada requisição, então se aplicam a todas as sessões. settings.rules.info.more=Arquivos AGENTS.md são reconhecidos automaticamente; esta página é para os caminhos, globs e URLs extras que você deseja incluir além deles. Use rules para restrições permanentes, como estilo de código ou requisitos de revisão: um comando é um prompt que você executa, uma skill é carregada apenas quando é relevante, e uma rule está sempre ativa. A compatibilidade com Claude Code também carrega instruções do CLAUDE.md e skills do diretório de configuração do Claude Code e requer uma reinicialização. settings.agentBehavior.description=Configure agentes, servidores MCP, rules, comandos e skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index a76af6133e0..85b768f39f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Не удалось откр settings.rules.info=Правила — это файлы инструкций, которые Kilo добавляет в системный промпт каждого запроса, поэтому они применяются ко всем сессиям. settings.rules.info.more=Файлы AGENTS.md подхватываются автоматически; эта страница предназначена для дополнительных путей, шаблонов и URL-адресов, которые вы хотите включить в дополнение к ним. Используйте правила для постоянных ограничений, таких как стиль кода или требования к проверке: команда — это промпт, который вы запускаете, навык загружается только когда он актуален, а правило действует всегда. Совместимость с Claude Code дополнительно загружает инструкции CLAUDE.md и навыки из каталога конфигурации Claude Code и требует перезапуска. settings.agentBehavior.description=Настройка агентов, серверов MCP, правил, команд и навыков. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 73fc997e6f8..79a814e91ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=ไม่สามารถ settings.rules.info=กฎคือไฟล์คำสั่งที่ Kilo เพิ่มลงในพรอมต์ระบบของทุกคำขอ จึงมีผลกับทุกเซสชัน settings.rules.info.more=ไฟล์ AGENTS.md จะถูกอ่านโดยอัตโนมัติ หน้านี้ใช้สำหรับพาธ, glob และ URL เพิ่มเติมที่คุณต้องการรวมเข้าไปนอกเหนือจากนั้น ใช้กฎสำหรับข้อกำหนดที่ใช้ตลอด เช่น รูปแบบการเขียนโค้ดหรือข้อกำหนดในการตรวจสอบ โดยคำสั่งคือพรอมต์ที่คุณเรียกใช้ สกิลจะถูกโหลดก็ต่อเมื่อเกี่ยวข้อง ส่วนกฎจะทำงานอยู่เสมอ ความเข้ากันได้กับ Claude Code ยังโหลดคำสั่ง CLAUDE.md และสกิลจากไดเรกทอรีการตั้งค่า Claude Code ของคุณเพิ่มเติม และต้องรีสตาร์ท settings.agentBehavior.description=กำหนดค่าเอเจนต์ เซิร์ฟเวอร์ MCP กฎ คำสั่ง และสกิล + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index e92774183fa..8f6f849a40d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Komut dosyası düzenleyicid settings.rules.info=Kurallar, Kilo'nun her isteğin sistem promptuna eklediği talimat dosyalarıdır, bu nedenle tüm oturumlara uygulanır. settings.rules.info.more=AGENTS.md dosyaları otomatik olarak algılanır; bu sayfa, bunlara ek olarak dahil etmek istediğiniz ekstra yollar, joker karakter desenleri ve URL'ler içindir. Kodlama stili veya inceleme gereksinimleri gibi kalıcı kısıtlamalar için kuralları kullanın: bir komut çalıştırdığınız bir promptdur, bir yetenek yalnızca ilgili olduğunda yüklenir ve bir kural her zaman etkindir. Claude Code uyumluluğu, ayrıca Claude Code yapılandırma dizininizden CLAUDE.md talimatlarını ve yeteneklerini yükler ve yeniden başlatma gerektirir. settings.agentBehavior.description=Ajanları, MCP sunucularını, kuralları, komutları ve yetenekleri yapılandırın. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index e9c65c6ad12..7f568a7cf08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=Не вдалося відк settings.rules.info=Правила — це файли інструкцій, які Kilo додає до системного промпту кожного запиту, тож вони застосовуються до всіх сесій. settings.rules.info.more=Файли AGENTS.md підхоплюються автоматично; ця сторінка призначена для додаткових шляхів, шаблонів (glob) та URL-адрес, які ви хочете включити на додачу до них. Використовуйте правила для постійних обмежень, таких як стиль коду або вимоги до перевірки: команда — це промпт, який ви запускаєте, навичка завантажується лише тоді, коли вона релевантна, а правило діє завжди. Сумісність із Claude Code додатково завантажує інструкції CLAUDE.md та навички з каталогу конфігурації Claude Code і потребує перезапуску. settings.agentBehavior.description=Налаштування агентів, серверів MCP, правил, команд і навичок. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index bd75134cf3f..9e09aafbeb4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=无法在编辑器中打开 settings.rules.info=规则是 Kilo 添加到每个请求系统提示词中的指令文件,因此它们会应用于所有会话。 settings.rules.info.more=AGENTS.md 文件会被自动读取;此页面用于配置你希望在此基础上额外包含的路径、通配符和网址。规则适用于诸如编码风格或审查要求等长期性约束:命令是你运行的提示词,技能只在相关时才会加载,而规则则始终生效。Claude Code 兼容模式还会从你的 Claude Code 配置目录中加载 CLAUDE.md 指令和技能,并需要重启才能生效。 settings.agentBehavior.description=配置智能体、MCP 服务器、规则、命令和技能。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 35e96edf8e2..cc97bebd12a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -713,3 +713,9 @@ settings.agentBehavior.commands.openInEditor.failed=無法在編輯器中開啟 settings.rules.info=規則是 Kilo 加入每個請求系統提示詞中的指令檔案,因此適用於所有工作階段。 settings.rules.info.more=AGENTS.md 檔案會自動被讀取;此頁面用於設定您想額外加入的路徑、萬用字元和網址。規則適用於程式碼風格或審查要求等長期限制:命令是您執行的提示詞,技能僅在相關時才會載入,而規則則一律生效。Claude Code 相容模式還會從您的 Claude Code 設定目錄載入 CLAUDE.md 指令與技能,且需要重新啟動。 settings.agentBehavior.description=設定代理、MCP 伺服器、規則、命令與技能。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet +session.prompts.overflow={0} earlier prompts diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt new file mode 100644 index 00000000000..1acc059d246 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt @@ -0,0 +1,128 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.ui.SessionDropOverlay +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay +import ai.kilocode.client.session.ui.rail.PromptRail +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBScrollPane +import java.awt.Point +import java.awt.Rectangle +import java.awt.Component +import java.awt.Container +import javax.swing.JLayeredPane +import javax.swing.SwingUtilities + +class PromptRailLayoutTest : SessionUiTestBase() { + fun `test rail is hidden until two prompts exist`() { + showMessages() + val rail = find(ui) + assertFalse(rail.isVisible) + + fillTranscript(1) + layoutAll(ui) + drainScroll() + assertFalse(rail.isVisible) + + fillTranscript(1, start = 1) + layoutAll(ui) + drainScroll() + assertTrue(rail.isVisible) + } + + fun `test wide rail uses standard right inset outside readable lane`() { + showMessages() + fillTranscript(30) + ui.setSize(1600, 600) + layoutAll(ui) + ui.promptRail.refresh() + find(ui).overlay.doLayout() + drainScroll() + val rail = find(ui) + val root = find(ui) + val pane = scrollComponent() as JBScrollPane + val vp = SwingUtilities.convertRectangle(pane.viewport, Rectangle(pane.viewport.size), root.overlay) + val bar = SwingUtilities.convertRectangle(pane.verticalScrollBar, Rectangle(pane.verticalScrollBar.size), root.overlay) + + assertEquals(bar.x - UiStyle.Gap.pad(), rail.x + rail.width) + assertFalse("rail=${rail.bounds} bar=$bar vp=$vp", rail.bounds.intersects(bar)) + } + + fun `test narrow rail covers scrollbar and remains above content`() { + showMessages() + fillTranscript(12) + ui.setSize(420, 600) + layoutAll(ui) + ui.promptRail.refresh() + find(ui).overlay.doLayout() + drainScroll() + val rail = find(ui) + val root = find(ui) + val pane = scrollComponent() as JBScrollPane + val bar = SwingUtilities.convertRectangle(pane.verticalScrollBar, Rectangle(pane.verticalScrollBar.size), root.overlay) + + assertSame(root.overlay, rail.parent) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertTrue("available=${rail.available()} entries=${rail.entries().size} bounds=${rail.bounds}", rail.isVisible) + assertTrue("rail=${rail.bounds} bar=$bar", rail.bounds.intersects(bar)) + val point = SwingUtilities.convertPoint(rail, Point(rail.width / 2, rail.tickCenterY(0)), root) + assertSame(rail, SwingUtilities.getDeepestComponentAt(root, point.x, point.y)) + val free = SwingUtilities.convertPoint(rail, Point(rail.width / 2, 1), root) + val below = SwingUtilities.getDeepestComponentAt(root, free.x, free.y) + assertTrue(below === pane.verticalScrollBar || SwingUtilities.isDescendingFrom(below, pane.verticalScrollBar)) + assertFalse(root.isOptimizedDrawingEnabled) + } + + fun `test rail stays below the other session overlays`() { + showMessages() + fillTranscript(3) + val rail = find(ui) + val root = find(ui) + val drop = find(ui) + val account = find(ui) + + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(drop)) + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(account)) + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(jumpButton())) + } + + fun `test scrolling changes active prompt and jump scrolls to prompt top`() { + showMessages() + fillTranscript(10) + val rail = find(ui) + val bar = scrollBar() + setValue(bar, 0) + assertEquals(0, rail.active()) + + setValue(bar, bottom(bar) / 2) + assertTrue((rail.active() ?: 0) > 0) + + assertTrue(ui.scroll.scrollMessageTop("msg_2")) + drainScroll() + val turn = find(ui).findTurn("msg_2")!! + val expected = SwingUtilities.convertPoint(turn, Point(0, 0), scrollView()).y + .coerceIn(0, bottom(bar)) + assertEquals(expected, bar.value) + assertFalse(ui.scroll.following()) + } + + fun `test repeated updates retain the rail and overlay tree`() { + showMessages() + fillTranscript(10) + val root = find(ui) + val rail = find(ui) + val count = root.overlay.componentCount + val items = rail.items() + val entries = rail.entries() + + repeat(300) { rail.update(items, entries, it % items.size) } + + assertSame(rail, find(ui)) + assertEquals(count, root.overlay.componentCount) + } + + private fun layoutAll(comp: Component) { + comp.doLayout() + if (comp is Container) comp.components.forEach(::layoutAll) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt new file mode 100644 index 00000000000..6d9d555fdfc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt @@ -0,0 +1,112 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionRevertDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class PromptRailItemsTest : BasePlatformTestCase() { + fun `test preview strips markdown and collapses whitespace`() { + val text = """ + # Heading + - Keep [the label](https://example.com) + > Drop `inline` and ![image](image.png) + ```kotlin + hidden() + ``` + """.trimIndent() + + assertEquals("Heading Keep the label Drop and", PromptRailItems.preview(text)) + } + + fun `test items include user turns with first assistant text`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("up1", "u1", "text", " Build this\nnow ")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("tool", "a1", "tool")) + model.upsertMessage(message("a2", "assistant")) + model.updateContent("a2", part("ap1", "a2", "text", "Here is **the** answer")) + model.setQueued(setOf("u1")) + + assertEquals( + listOf(PromptRailItem("u1", true, "Build this now", "Here is **the** answer")), + PromptRailItems.items(model), + ) + } + + fun `test answer becomes prompt for attachment only turn`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("file", "u1", "file")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "I can inspect that image")) + + assertEquals( + listOf(PromptRailItem("u1", false, "I can inspect that image", "")), + PromptRailItems.items(model), + ) + } + + fun `test compaction and reverted turns are excluded`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "compaction")) + model.upsertMessage(message("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", "visible")) + model.upsertMessage(message("u3", "user")) + model.updateContent("u3", part("p3", "u3", "text", "reverted")) + model.setRevert(SessionRevertDto("u3")) + + assertEquals(listOf("u2"), PromptRailItems.items(model).map { it.id }) + } + + fun `test truncate and rail entries`() { + assertEquals("abc…", PromptRailItems.truncate("abcdef", 4)) + val items = List(8) { PromptRailItem("u$it", false, "prompt $it", "") } + + assertEmpty(PromptRailItems.entries(items, 0)) + assertEquals(listOf(PromptRailEntry.Prompt(7)), PromptRailItems.entries(items, 1)) + assertEquals( + listOf(PromptRailEntry.Prompt(0), PromptRailEntry.Prompt(7)), + PromptRailItems.entries(items, 2), + ) + assertEquals( + listOf( + PromptRailEntry.Prompt(0), + PromptRailEntry.Overflow(1..5), + PromptRailEntry.Prompt(6), + PromptRailEntry.Prompt(7), + ), + PromptRailItems.entries(items, 4), + ) + } + + fun `test capacity and active prompt`() { + assertEquals(0, PromptRailItems.capacity(10, 7, 7)) + assertEquals(4, PromptRailItems.capacity(42, 7, 7)) + val tops = listOf(0, 100, 220) + + assertEquals(2, PromptRailItems.active(tops, 0, scrollable = false, atTop = true)) + assertEquals(0, PromptRailItems.active(tops, 0, scrollable = true, atTop = true)) + assertEquals(1, PromptRailItems.active(tops, 160, scrollable = true, atTop = false)) + assertEquals(2, PromptRailItems.active(tops, 500, scrollable = true, atTop = false)) + } + + private fun message(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun part(id: String, message: String, type: String, text: String? = null) = PartDto( + id = id, + sessionID = "ses", + messageID = message, + type = type, + text = text, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt index 7ff767bfb7b..712828d0f75 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt @@ -2,10 +2,12 @@ package ai.kilocode.client.ui.popup import ai.kilocode.client.testing.TestUiTimers import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Point import javax.swing.JComponent import javax.swing.JPanel @@ -176,6 +178,12 @@ class SidePopupControllerTest : BasePlatformTestCase() { assertNull(field(controller, "target")) } + fun `test popup spot callout defaults on and can be disabled`() { + val pane = JPanel() + assertTrue(SidePopupSpot(pane, Point(), Balloon.Position.atLeft, 0).callout) + assertFalse(SidePopupSpot(pane, Point(), Balloon.Position.atLeft, 0, callout = false).callout) + } + private fun controller(dwell: Int = SidePopupController.SHOW_MS): SidePopupController { val item = SidePopupController(timers, dwell) controllers.add(item) From de1bcd1f5436cc232df394b59f5c7c3a339e44be Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 10:50:41 -0400 Subject: [PATCH 044/353] fix(jetbrains): dispose the MCP settings panel Delegate disposeReadyComponent to DraftReadyConfigurableBase so closing Settings releases the retained McpSettingsUi and cancels its in-flight reload. --- .../client/settings/agents/McpConfigurable.kt | 3 +++ .../settings/agents/McpSettingsUiTest.kt | 26 +++++++++++++++++++ .../client/testing/FakeAgentBehaviorRpcApi.kt | 4 +++ 3 files changed, 33 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index 5a425d5a82e..63fb8ca4bf3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -61,6 +61,9 @@ class McpConfigurable : DirectoryReadyConfigurable() { override fun disposeReadyComponent(component: JComponent) { if (ui === component) ui = null query = null + // DraftReadyConfigurableBase clears its retained panel and disposes McpSettingsUi, which is a + // Disposable SettingsListPanel. Skipping super would leak the page and its in-flight reload. + super.disposeReadyComponent(component) } companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.mcp" } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index 2ddb6d6791e..91535165548 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -28,6 +28,7 @@ import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SearchTextField import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.util.ui.UIUtil @@ -40,6 +41,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JTextField import javax.swing.SwingUtilities +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -453,6 +455,27 @@ class McpSettingsUiTest : BasePlatformTestCase() { edt { cfg.disposeUIResources(); true } } + fun `test configurable disposal disposes the mcp settings panel`() { + install() + // Hold the status call so the panel is provably still busy when the dialog closes. Only + // SettingsListPanel.dispose() clears that busy state; cancelling the configurable scope + // leaves the search field disabled, so this fails unless the override delegates to + // DraftReadyConfigurableBase. + val gate = CompletableDeferred() + agentRpc.mcpStatusGate = gate + val cfg = McpConfigurable() + val shell = edt { cfg.createComponent() } + flushUntil { components(shell).filterIsInstance().singleOrNull() != null } + val panel = components(shell).filterIsInstance().single() + ui = panel + flushUntil { agentRpc.mcpCalls.isNotEmpty() && !searchField(panel).isEnabled } + + edt { cfg.disposeUIResources(); true } + gate.complete(Unit) + + assertTrue("dispose() must clear the busy state", edt { searchField(panel).isEnabled }) + } + private fun panel(create: (String, McpConfigDto) -> McpEditDialogHandle = ::McpEditDialog): McpSettingsUi { install() val panel = edt { McpSettingsUi(scope!!, DIR, create) } @@ -528,6 +551,9 @@ class McpSettingsUiTest : BasePlatformTestCase() { return pane.text.replace(Regex("<[^>]+>"), "").replace(Regex("\\s+"), " ").trim() } + private fun searchField(panel: McpSettingsUi): SearchTextField = + components(panel).filterIsInstance().single() + private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index e35323ab422..32ac7e49a84 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -52,6 +52,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var removeSkillError: Exception? = null var saveSkillError: Exception? = null var mcpStatusError: Exception? = null + + /** Holds `mcpStatus` so a test can observe the list panel while it is still busy. */ + var mcpStatusGate: CompletableDeferred? = null var mcpConnectError: Exception? = null var removeResult = true var removeSkillResult = true @@ -178,6 +181,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { assertNotEdt("agentBehavior.mcpStatus") mcpStatusError?.let { throw it } mcpCalls.add(directory) + mcpStatusGate?.await() return mcps } From bbc1281a6e08e6cb656e644e8acaf98c0c66d38d Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 11:56:19 -0400 Subject: [PATCH 045/353] fix(jetbrains): surface why an MCP server failed Carry the runtime failure reason on the MCP status badge tooltip, since the row hides its description line, and log it for every unhealthy server so the cause is diagnosable from the IDE log. --- .../client/settings/agents/McpConfigurable.kt | 26 ++++++++++- .../settings/agents/McpSettingsUiTest.kt | 45 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index 63fb8ca4bf3..fcd117b00e2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -103,6 +103,13 @@ internal class McpSettingsUi( if (names.isEmpty()) { LOG.warn("mcp settings fetch returned no servers dir=$dir") } + // A row only has room for the status pill, so the reason a server is unhealthy would otherwise + // live solely in its tooltip. Log it too, so a failure is diagnosable from the IDE log alone. + for (name in names) { + val status = statuses[name] ?: continue + if (status.status == CONNECTED || status.status == DISABLED) continue + LOG.warn("mcp server unhealthy dir=$dir name=$name status=${status.status} reason=${status.error ?: "unreported"}") + } return names.map { name -> item(name, cfg[name]?.config, statuses[name]) } } @@ -149,7 +156,14 @@ internal class McpSettingsUi( } private fun badges(cfg: McpConfigDto?, status: McpStatusDto?): List = listOfNotNull( - ActiveListBadge(statusLabel(status), statusStyle(status)).takeIf { status != null }, + // The row hides its description line, which suppresses the row-level tooltip, so the status + // pill carries the reason itself. An id is what opts a badge into list hit-testing. + ActiveListBadge( + statusLabel(status), + statusStyle(status), + id = STATUS_BADGE, + tooltip = statusTooltip(status), + ).takeIf { status != null }, ActiveListBadge(cfg?.type ?: KiloBundle.message("settings.agentBehavior.mcp.configured")).takeIf { cfg != null }, ) @@ -263,6 +277,7 @@ internal class McpSettingsUi( const val NEEDS_AUTH = "needs_auth" const val NEEDS_REGISTRATION = "needs_client_registration" const val DISABLED = "disabled" + const val STATUS_BADGE = "status" const val CONNECT_CELL = "connect" const val DISCONNECT_CELL = "disconnect" const val AUTH_CELL = "auth" @@ -283,6 +298,15 @@ internal class McpSettingsUi( } } + /** + * Why the server is in [status], wrapped for a multi-line tooltip. Null when the pill text + * already says everything, so a healthy row does not gain a tooltip that repeats it. + */ + fun statusTooltip(status: McpStatusDto?): String? { + val reason = status?.error?.takeIf { it.isNotBlank() } ?: return null + return UiStyle.Text.tipLines(listOf(statusLabel(status)) + reason.lines()) + } + fun statusStyle(status: McpStatusDto?): UiStyle.Badge.Style { return when (status?.status) { CONNECTED -> UiStyle.Badge.Highlight diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index 91535165548..b5b49d36e57 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.testing.fire import ai.kilocode.client.testing.rowLines import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListCellBounds +import ai.kilocode.client.ui.list.activeListHits import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -122,6 +123,50 @@ class McpSettingsUiTest : BasePlatformTestCase() { } } + fun `test failed status badge carries the failure reason as its tooltip`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val badge = rows(panel).single { it.key == "runtime" }.badges.first() + assertEquals("failed", badge.text) + val tooltip = badge.tooltip + assertNotNull("the failed badge must explain itself", tooltip) + assertTrue("tooltip should carry the reason, got $tooltip", tooltip!!.contains("crashed")) + assertTrue("tooltip should name the status, got $tooltip", tooltip.contains("failed")) + true + } + } + + fun `test healthy status badge has no tooltip`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + // The pill already reads "connected"; a tooltip repeating it would be noise. + assertNull(rows(panel).single { it.key == "filesystem" }.badges.first().tooltip) + true + } + } + + fun `test failure reason is reachable through list hit testing`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val list = list(panel) + list.size = Dimension(460, 260) + list.doLayout() + val idx = rows(panel).indexOfFirst { it.key == "runtime" } + list.selectedIndex = idx + // Proves the badge is hit-tested, so hovering it actually shows the reason rather than + // the text merely existing on the model. + val hit = activeListHits(list, idx, selected = true).single { it.id == "status" } + assertTrue("hovering the badge must reveal the reason", hit.tooltip.orEmpty().contains("crashed")) + true + } + } + fun `test mcp rows keep equal height`() { val panel = panel() flushUntil { rows(panel).size == 3 } From 3e8177437ce36a0c114f1232eeec7ad1b1697dad Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 12:22:52 -0400 Subject: [PATCH 046/353] fix(jetbrains): offer sign-in for recoverable MCP auth failures The CLI only reports needs_auth for UnauthorizedError or an OAuth message, so a rejected browser flow, a failed token exchange, or a bare 401 arrived as failed and silently removed sign-in from MCP Settings, the Marketplace install prompt, and the session prompt. Reclassify those OAuth-specific failures as needing authentication so all three surfaces offer sign-in again. --- .../kilocode/backend/cli/KiloCliDataParser.kt | 29 +++++++++++-- .../backend/cli/KiloCliDataParserTest.kt | 42 +++++++++++++++++++ .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 23 ++++++++++ .../settings/agents/McpSettingsUiTest.kt | 16 +++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 252275c22db..4009041d8df 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -129,6 +129,20 @@ object KiloCliDataParser { private val FIELD_RE = ConcurrentHashMap() private val APPROVAL_SOURCES = setOf("agent", "global", "project", "yolo", "session", "manual", "default") + /** Lowercase markers that identify a `failed` MCP status as recoverable by signing in again. */ + private val MCP_AUTH_FAILURE_MARKERS = listOf( + "unauthorized", + "authentication required", + "needs authentication", + "not authenticated", + "oauth", + "browser authorization", + "token exchange failed", + "invalid_token", + "invalid_grant", + "401", + ) + // ================================================================ // SSE event parsing // ================================================================ @@ -881,12 +895,19 @@ object KiloCliDataParser { ) } + /** + * Whether a `failed` MCP status is really "sign-in required". + * + * The CLI only reports `needs_auth` when the transport threw `UnauthorizedError` or the message + * mentions OAuth (`packages/opencode/src/mcp/index.ts`); a rejected browser flow, a failed token + * exchange, or a bare 401 body fall through to `failed`. Those are all recoverable by signing in + * again, so they must still offer sign-in in Settings, Marketplace, and the session prompt. + * + * Matched markers are OAuth-specific, so a non-OAuth server failure is not reclassified. + */ private fun String?.isMcpAuthFailure(): Boolean { val value = this?.lowercase() ?: return false - return value.contains("unauthorized") || - value.contains("authentication required") || - value.contains("needs authentication") || - value.contains("not authenticated") + return MCP_AUTH_FAILURE_MARKERS.any { value.contains(it) } } private fun removable(obj: JsonObject): Boolean { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 7ef5606f94b..0eca89ffbdf 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData +import kotlinx.serialization.json.JsonPrimitive import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.CompactionPatchDto @@ -2295,6 +2296,47 @@ class KiloCliDataParserTest { assertEquals("failed", result.status) } + /** + * The CLI only reports `needs_auth` for `UnauthorizedError` or an "OAuth" message, so these + * recoverable sign-in failures arrive as `failed` and must still offer sign-in. + */ + @Test + fun `parseMcpStatus - recoverable oauth failures need auth`() { + val reasons = listOf( + "Browser authorization failed: Authorization cancelled", + "Browser authorization was rejected: this request was replaced by another authorization attempt", + "Token exchange failed: invalid client", + "Error POSTing to endpoint (HTTP 401): missing bearer token", + "OAuth discovery failed", + "Server rejected the request: invalid_grant", + "Server rejected the request: invalid_token", + ) + + for (reason in reasons) { + val json = """{"anaconda":{"status":"failed","error":${JsonPrimitive(reason)}}}""" + val result = KiloCliDataParser.parseMcpStatus(json).single() + assertEquals("needs_auth", result.status, "expected sign-in for: $reason") + assertEquals(reason, result.error, "the reason must survive normalization") + } + } + + @Test + fun `parseMcpStatus - unrelated failures are not reclassified as auth`() { + val reasons = listOf( + "Connection closed", + "Failed to get tools", + "spawn npx ENOENT", + "Invalid MCP URL for \"broken\"", + "Error POSTing to endpoint (HTTP 500): server error", + ) + + for (reason in reasons) { + val json = """{"broken":{"status":"failed","error":${JsonPrimitive(reason)}}}""" + val result = KiloCliDataParser.parseMcpStatus(json).single() + assertEquals("failed", result.status, "must stay failed for: $reason") + } + } + // ---- parseMcpBrowserOpenFailed ---- @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 0574d6e6fb4..5a013bc7103 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -361,6 +361,29 @@ class KiloAgentBehaviorRpcApiImplTest { assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/auth/authenticate") } + /** + * A rejected browser flow reaches the plugin as `failed`, but signing in again fixes it, so the + * whole wire path must report `needs_auth` for Settings, Marketplace, and the session prompt. + */ + @Test + fun `mcp status reports recoverable auth failures as needing sign in`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"failed","error":"Browser authorization failed: Authorization cancelled"}}""" + val rpc = rpc() + + val status = rpc.mcpStatus("/test").single() + + assertEquals("needs_auth", status.status) + assertEquals("Browser authorization failed: Authorization cancelled", status.error) + } + + @Test + fun `mcp status keeps unrelated failures failed`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"failed","error":"Connection closed"}}""" + val rpc = rpc() + + assertEquals("failed", rpc.mcpStatus("/test").single().status) + } + @Test fun `mcp auth remove and browser open failed event`() = runBlocking { val rpc = rpc() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index b5b49d36e57..b4585120859 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -123,6 +123,22 @@ class McpSettingsUiTest : BasePlatformTestCase() { } } + fun `test a server needing auth offers sign in`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + // github is needs_auth in the fixture; the row must expose sign-in, not just Connect. + val cells = rows(panel).single { it.key == "github" }.cells.map { it.id } + assertTrue("needs_auth must offer sign-in, got $cells", cells.contains("auth")) + val label = rows(panel).single { it.key == "github" }.cells.single { it.id == "auth" }.label + assertEquals(KiloBundle.message("settings.agentBehavior.mcp.signIn"), label) + // A healthy server must not. + assertFalse(rows(panel).single { it.key == "filesystem" }.cells.any { it.id == "auth" }) + true + } + } + fun `test failed status badge carries the failure reason as its tooltip`() { val panel = panel() flushUntil { rows(panel).size == 3 } From cdae41a30b96cc485b572e9c201a7b0f8d6eb9e3 Mon Sep 17 00:00:00 2001 From: Bruno Agatao <104621862+brunoagatao@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:50:43 +0200 Subject: [PATCH 047/353] chore(security): reduce Dependabot noise --- .github/dependabot.yml | 90 ++++++++++++++++++++++++++++++++----- .github/workflows/README.md | 3 +- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 784a6035a34..e1ea389cffe 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,24 @@ # kilocode_change - new file version: 2 updates: - # Bun workspace root (packages/*). Known overlap: packages/kilo-docs also - # has its own pnpm block below, since this scan can't see its separate - # pnpm-lock.yaml. Both may target the same package.json line; whichever PR - # merges first makes the other stale (caught by stale-bot-pr-notify.yml). + # Bun workspace root (packages/*). Version updates are OFF (limit 0): this + # scan covers ~17 upstream-owned packages that share one bun.lock, and + # Dependabot cannot scope a workspace scan by path. Nearly every PR it opened + # touched upstream files and was closed by hand (dependabot-auto-merge.yml + # skips them by design). Those dependencies come in with upstream merges. + # A limit of 0 does not disable security updates: alerts still open PRs, and + # security-findings-notify.yml reports them. packages/kilo-docs has its own + # block below, since this scan can't see its separate pnpm-lock.yaml. - package-ecosystem: "bun" directory: "/" schedule: interval: "weekly" - cooldown: - default-days: 7 - open-pull-requests-limit: 10 + open-pull-requests-limit: 0 labels: - "dependencies" commit-message: prefix: "chore" include: "scope" - groups: - bun-minor-patch: - update-types: - - "minor" - - "patch" # packages/kilo-docs deploys separately via pnpm; see note above. - package-ecosystem: "npm" @@ -35,7 +32,33 @@ updates: - "dependencies" commit-message: prefix: "chore(kilo-docs)" + # Majors are never proposed. They change peer/provider specs and break + # typecheck (see the @ai-sdk/* 2.x -> 3.x and @types/node 24 -> 26 PRs). + # Bump them by hand, on purpose. + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + # Related packages move together. Group names must end in "-minor-patch" + # so dependabot-auto-merge.yml recognises them. A dependency joins the + # first group that matches it. groups: + react-minor-patch: + patterns: + - "react" + - "react-dom" + - "@types/react" + - "@types/react-dom" + update-types: + - "minor" + - "patch" + vitest-minor-patch: + patterns: + - "vitest" + - "@vitest/*" + update-types: + - "minor" + - "patch" kilo-docs-minor-patch: update-types: - "minor" @@ -52,7 +75,50 @@ updates: - "dependencies" commit-message: prefix: "chore(jetbrains)" + # Majors are never proposed. They change peer/provider specs and break + # typecheck (see the @ai-sdk/* 2.x -> 3.x and @types/node 24 -> 26 PRs). + # Bump them by hand, on purpose. + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + # One bad update must not block the rest, and coupled artifacts must move + # together (same "-minor-patch" suffix and first-match rules as above). groups: + intellij-minor-patch: + patterns: + - "org.jetbrains.intellij.platform*" + - "rpc" + update-types: + - "minor" + - "patch" + kotlin-minor-patch: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + update-types: + - "minor" + - "patch" + junit-minor-patch: + patterns: + - "junit*" + - "org.junit*" + update-types: + - "minor" + - "patch" + okhttp-minor-patch: + patterns: + - "com.squareup.okhttp3*" + update-types: + - "minor" + - "patch" + markdown-minor-patch: + patterns: + - "org.commonmark*" + - "org.nibor.autolink*" + update-types: + - "minor" + - "patch" jetbrains-minor-patch: update-types: - "minor" diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 99bf408a988..fdb220bda10 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -20,7 +20,7 @@ Workflows that automate dependency remediation and give the team Slack visibilit `dependabot.yml` is not a workflow — it's a config file that GitHub's Dependabot backend reads directly. None of the 3 workflow files in this directory ever open a PR; they only act on PRs that already exist. -Dependabot re-scans on the `schedule: interval` set per ecosystem block, and once immediately whenever `dependabot.yml` itself changes on the default branch. Per ecosystem (`bun`, `npm` for kilo-docs, `gradle` for kilo-jetbrains), it checks each dependency for updates, classifies each as `major`, `minor`, or `patch`, bundles everything matching a `groups: *-minor-patch` rule into one combined PR, and opens every major bump as its own individual PR. These are normal PRs from that point on — required CI and branch protection apply like any other PR. +Dependabot re-scans on the `schedule: interval` set per ecosystem block, and once immediately whenever `dependabot.yml` itself changes on the default branch. Per ecosystem (`npm` for kilo-docs, `gradle` for kilo-jetbrains), it checks each dependency for updates, bundles minor and patch bumps into `groups: *-minor-patch` PRs (related packages share a group, so they move together), and ignores every major bump. Majors are bumped by hand. The `bun` block has `open-pull-requests-limit: 0`, so it opens no version-update PRs, only security-alert PRs. These are normal PRs from that point on — required CI and branch protection apply like any other PR. ### The 3 workflows each poll independently on their own schedule @@ -41,4 +41,5 @@ They don't call each other or trigger off PR creation. Each one wakes up on its kilocode is a fork of opencode sharing one `bun.lock` with upstream-owned `@opencode-ai/*` packages. Two things exist specifically to avoid friction with upstream syncs: - `dependabot-auto-merge.yml` only auto-merges a PR if every changed file (besides the shared `bun.lock`) lives under a `kilo`-named path. Anything touching shared/upstream code is left for a human. +- The root `bun` block has version updates turned off. It scans ~17 upstream-owned packages and cannot be scoped by path, so its PRs almost always touched upstream files. Those dependencies come in with upstream merges. - `dependabot.yml` deliberately does **not** cover `packages/opencode/Dockerfile` or `.github/workflows/**`: both are shared/upstream paths that the auto-merge guard above would never approve anyway, and `.github/workflows/**` changes routinely fail this repo's `kilocode_change` annotation check unless they land inside an existing marker block. Those paths are covered by `security-findings-notify.yml` instead, which only reads GitHub's alerts, it never proposes a PR. From ad1568f45a32d9a5583051910e9141965c09b00e Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 14:00:25 -0400 Subject: [PATCH 048/353] fix(jetbrains): correct MCP auth badge/actions and unify bundle uninstall - MCP settings: the needs-auth status pill now carries the warning glyph used in the session issue indicator, and the row's action set matches the actual connection state. needs_auth shows only Sign In (no Connect, no Reset sign-in, since there is nothing to reset until a session exists). Reset sign-in only appears once a remote server is connected. - Marketplace MCP + companion skill bundles can now be uninstalled consistently from either MCP Settings or Skills Settings: deleting either half confirms and removes the whole bundle (server, owned skills, and the ownership marker), instead of leaving an orphaned half installed. Ownership is read from a marker written next to the installed skill so removal still works after the catalog changes. - Active list renderer: selected-row action cells (and disabled-cell hit tests) now stay visible/interactive while the list is selected but unfocused, instead of only when the list is the focus owner. This is a shared behavior change across every settings list built on ActiveList. Adds/updates focused tests in McpSettingsUiTest, SkillsSettingsUiTest, SettingsListViewTest, KiloMarketplaceRpcApiImplTest, and KiloBundleLocaleTest, plus localized delete-confirmation strings. --- .changeset/jetbrains-mcp-oauth-sign-in.md | 2 +- .../KiloBackendMarketplaceManager.kt | 53 ++++++++++++ .../backend/rpc/KiloMarketplaceRpcApiImpl.kt | 3 + .../rpc/KiloMarketplaceRpcApiImplTest.kt | 37 +++++++++ .../client/app/KiloMarketplaceService.kt | 5 ++ .../client/settings/agents/McpConfigurable.kt | 34 ++++++-- .../settings/agents/SkillsConfigurable.kt | 45 +++++++++- .../client/ui/list/ActiveListModel.kt | 9 +- .../client/ui/list/ActiveListRenderer.kt | 2 +- .../resources/messages/KiloBundle.properties | 2 + .../messages/KiloBundle_ar.properties | 2 + .../messages/KiloBundle_bs.properties | 2 + .../messages/KiloBundle_da.properties | 2 + .../messages/KiloBundle_de.properties | 2 + .../messages/KiloBundle_es.properties | 2 + .../messages/KiloBundle_fr.properties | 2 + .../messages/KiloBundle_ja.properties | 2 + .../messages/KiloBundle_ko.properties | 2 + .../messages/KiloBundle_nl.properties | 2 + .../messages/KiloBundle_no.properties | 2 + .../messages/KiloBundle_pl.properties | 2 + .../messages/KiloBundle_pt_BR.properties | 2 + .../messages/KiloBundle_ru.properties | 2 + .../messages/KiloBundle_th.properties | 2 + .../messages/KiloBundle_tr.properties | 2 + .../messages/KiloBundle_uk.properties | 2 + .../messages/KiloBundle_zh_CN.properties | 2 + .../messages/KiloBundle_zh_TW.properties | 2 + .../client/plugin/KiloBundleLocaleTest.kt | 2 + .../settings/agents/McpSettingsUiTest.kt | 83 +++++++++++++++++-- .../settings/agents/SkillsSettingsUiTest.kt | 51 ++++++++++++ .../settings/base/SettingsListViewTest.kt | 4 +- .../client/testing/FakeMarketplaceRpcApi.kt | 9 ++ .../ai/kilocode/rpc/KiloMarketplaceRpcApi.kt | 3 + .../ai/kilocode/rpc/dto/MarketplaceDto.kt | 7 ++ 35 files changed, 361 insertions(+), 26 deletions(-) diff --git a/.changeset/jetbrains-mcp-oauth-sign-in.md b/.changeset/jetbrains-mcp-oauth-sign-in.md index 5d78a8f797a..fa6cbd6c3b4 100644 --- a/.changeset/jetbrains-mcp-oauth-sign-in.md +++ b/.changeset/jetbrains-mcp-oauth-sign-in.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Sign in to remote MCP servers from JetBrains. MCP Settings now shows the real sign-in error, lets you cancel an in-progress sign-in, and offers a copyable authorization URL when a browser cannot be opened automatically. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt shows a session-issues menu with a sign-in action when a configured MCP server needs authentication. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. +Sign in to remote MCP servers from JetBrains. MCP Settings now highlights servers that need authentication, only shows actions valid for the current connection state, keeps selected-row actions visible when focus moves elsewhere, shows the real sign-in error, lets you cancel an in-progress sign-in, and offers a copyable authorization URL when a browser cannot be opened automatically. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt shows a session-issues menu with a sign-in action when a configured MCP server needs authentication. Removing either half of a Marketplace MCP and companion-skill bundle from MCP or Skills Settings now confirms and removes the whole bundle. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt index c4289c5b9d2..8eed69a7856 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt @@ -1,7 +1,9 @@ package ai.kilocode.backend.marketplace import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceMethodDto @@ -30,13 +32,16 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.URLEncoder +import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.FileSystems import java.nio.file.FileVisitResult import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import java.util.UUID /** * Talks to the CLI's marketplace routes (`/kilocode/marketplace*`), which fetch the remote @@ -65,6 +70,29 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? return MarketplaceListDto(items = dtos, errors = errors) } + suspend fun bundles(directory: String): List { + val skills = KiloCliDataParser.parseAgentBehaviorSkills(request(directory, PATH_SKILLS, null)) + return withContext(Dispatchers.IO) { + skills.mapNotNull { skill -> + val path = skillPath(skill.location) ?: return@mapNotNull null + val file = path.parent?.resolve(OWNER_FILE) ?: return@mapNotNull null + val owner = runCatching { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > MAX_OWNER_BYTES) { + return@runCatching null + } + JSON.decodeFromString(Files.readString(file)) + }.onFailure { LOG.warn("marketplace owner marker read failed: $file", it) }.getOrNull() + ?: return@mapNotNull null + if (owner.version != 1 || !safe(owner.id) || runCatching { UUID.fromString(owner.token) }.isFailure) { + return@mapNotNull null + } + Owned(owner.id, scope(directory, path), skill.location) + }.groupBy { it.id to it.scope } + .map { (key, owned) -> MarketplaceBundleDto(key.first, key.second, owned.map { it.location }.sorted()) } + .sortedWith(compareBy(MarketplaceBundleDto::scope, MarketplaceBundleDto::id)) + } + } + suspend fun install( directory: String, item: MarketplaceItemDto, @@ -135,6 +163,26 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? @Serializable private data class WireSkill(val id: String, val content: String) + @Serializable + private data class WireOwner(val version: Int, val id: String, val token: String) + + private data class Owned(val id: String, val scope: String, val location: String) + + private fun skillPath(location: String): Path? = runCatching { + if (location.startsWith("file:")) Path.of(URI(location)) else Path.of(location) + }.getOrNull()?.toAbsolutePath()?.normalize() + + private fun scope(directory: String, path: Path): String { + val root = runCatching { Path.of(directory).toAbsolutePath().normalize() }.getOrNull() + return if (root != null && path.startsWith(root)) "project" else "global" + } + + private fun safe(id: String): Boolean { + if (id.isBlank() || id == "." || ".." in id || "/" in id || "\\" in id || id.endsWith(".")) return false + if (RESERVED.matches(id)) return false + return OWNER_ID.matches(id) + } + private fun installedKeys(element: JsonElement?): Set = element?.jsonObject?.keys ?: emptySet() /** [item] paired with its raw `suggest_for.filename` glob patterns, used only to compute [relevantKeys]. */ @@ -301,7 +349,12 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? val EXCLUDED_DIRS = setOf("node_modules", ".git", "dist", "build", "out", ".kilo", ".opencode", ".kilocode") const val MAX_RELEVANCE_FILES = 20_000 const val PATH_LIST = "/kilocode/marketplace" + const val PATH_SKILLS = "/skill" const val PATH_INSTALL = "/kilocode/marketplace/install" const val PATH_REMOVE = "/kilocode/marketplace/remove" + const val OWNER_FILE = ".kilo-marketplace.json" + const val MAX_OWNER_BYTES = 4096L + val OWNER_ID = Regex("^[A-Za-z0-9_@.\\-]+$") + val RESERVED = Regex("^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\\.|$)", RegexOption.IGNORE_CASE) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt index c6f68be6ea2..28b516834f4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt @@ -5,6 +5,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.marketplace.KiloBackendMarketplaceManager import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -15,6 +16,8 @@ class KiloMarketplaceRpcApiImpl( ) : KiloMarketplaceRpcApi { override suspend fun list(directory: String): MarketplaceListDto = manager.list(directory) + override suspend fun bundles(directory: String): List = manager.bundles(directory) + override suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt index 61180432446..307e5f542ee 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceSkillDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -17,6 +18,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.nio.file.Files +import java.util.UUID import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -117,6 +119,41 @@ class KiloMarketplaceRpcApiImplTest { assertEquals("directory=%2Ftest+project", mock.lastMarketplaceListPath?.substringAfter("?")) } + @Test + fun `bundles reads catalog independent companion ownership markers`() = runBlocking { + val root = Files.createTempDirectory("kilo-marketplace-bundles") + val project = root.resolve("project") + val local = project.resolve(".kilo/skills/docs/SKILL.md") + val global = root.resolve("global/skills/review/SKILL.md") + try { + Files.createDirectories(local.parent) + Files.createDirectories(global.parent) + Files.writeString(local, "# Docs") + Files.writeString(global, "# Review") + val token = UUID.randomUUID().toString() + Files.writeString(local.parent.resolve(".kilo-marketplace.json"), """{"version":1,"id":"context7","token":"$token"}""") + Files.writeString(global.parent.resolve(".kilo-marketplace.json"), """{"version":1,"id":"context7","token":"$token"}""") + mock.skills = """ + [ + {"name":"docs","location":"$local"}, + {"name":"review","location":"$global"} + ] + """.trimIndent() + + val result = rpc().bundles(project.toString()) + + assertEquals( + listOf( + MarketplaceBundleDto("context7", "global", listOf(global.toString())), + MarketplaceBundleDto("context7", "project", listOf(local.toString())), + ), + result, + ) + } finally { + root.toFile().deleteRecursively() + } + } + @Test fun `list marks items relevant when a suggest_for filename pattern matches the workspace`() = runBlocking { val dir = Files.createTempDirectory("kilo-marketplace-relevance") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt index ce416d2cfd5..7251ed1e497 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -48,6 +49,10 @@ class KiloMarketplaceService internal constructor( suspend fun list(directory: String): MarketplaceListDto = call("list dir=$directory", LIST_TIMEOUT_MS) { list(directory) } + suspend fun bundles(directory: String): List = call("bundles dir=$directory", LIST_TIMEOUT_MS) { + bundles(directory) + } + suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index fcd117b00e2..8c481f68333 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloMcpAuthService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloDocs import ai.kilocode.client.settings.base.DirectoryReadyConfigurable @@ -19,6 +20,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.application.EDT @@ -77,6 +79,7 @@ internal class McpSettingsUi( private var dir = dir private var servers: Map = emptyMap() + private var bundles: List = emptyList() init { start() @@ -91,7 +94,11 @@ internal class McpSettingsUi( override suspend fun fetch(): List { val behavior = service() val cfg = behavior.mcpConfig(dir) - withContext(edt) { servers = cfg } + val owned = if (dir.isBlank()) emptyList() else service().bundles(dir) + withContext(edt) { + servers = cfg + bundles = owned + } val statuses = if (dir.isBlank()) { LOG.warn("mcp settings fetch skipped runtime status: missing project directory config=${cfg.size}") emptyMap() @@ -162,18 +169,19 @@ internal class McpSettingsUi( statusLabel(status), statusStyle(status), id = STATUS_BADGE, + icon = AllIcons.General.Warning.takeIf { status?.status == NEEDS_AUTH }, tooltip = statusTooltip(status), ).takeIf { status != null }, ActiveListBadge(cfg?.type ?: KiloBundle.message("settings.agentBehavior.mcp.configured")).takeIf { cfg != null }, ) private fun cells(cfg: McpConfigDto?, status: McpStatusDto?): List = listOfNotNull( - connect(status?.status == CONNECTED), + connect(status?.status == CONNECTED).takeUnless { status?.status == NEEDS_AUTH }, ActiveListCell(AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.signIn")).takeIf { status?.status == NEEDS_AUTH }, ActiveListCell(RESET_AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.resetAuth")).takeIf { - cfg?.type == "remote" + cfg?.type == "remote" && status?.status == CONNECTED }, ActiveListCell( EDIT_CELL, @@ -247,16 +255,32 @@ internal class McpSettingsUi( } private fun remove(name: String) { + val scope = servers[name]?.scope ?: return + val target = if (scope == "workspace") "project" else scope + val bundle = bundles.singleOrNull { it.id == name && it.scope == target } val result = Messages.showYesNoDialog( - KiloBundle.message("settings.agentBehavior.mcp.delete.message", name), + KiloBundle.message( + if (bundle == null) "settings.agentBehavior.mcp.delete.message" + else "settings.agentBehavior.mcp.delete.bundle.message", + name, + ), KiloBundle.message("settings.agentBehavior.mcp.delete.title"), KiloBundle.message("common.delete"), Messages.getCancelButton(), Messages.getQuestionIcon(), ) if (result != Messages.YES) return - val scope = servers[name]?.scope ?: return mutateAndReload(ActiveListSelection.Slide) { + if (bundle != null) { + val removed = service().remove(dir, name, "mcp", target) + if (!removed.success) { + throw SettingsMessageException( + removed.error ?: KiloBundle.message("settings.marketplace.remove.failed"), + ) + } + service().reloadSkills(dir) + return@mutateAndReload true + } if (!service().saveMcp(dir, name, scope, null)) { throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.save.failed")) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt index bd9a563a8f0..b471196e7ea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloDocs @@ -29,6 +30,7 @@ import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListView import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.SkillDto import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.SkillsPatchDto @@ -91,6 +93,7 @@ internal class SkillsSettingsUi( private val cs = scope private var dir = dir private var skills = emptyMap() + private var bundles = emptyList() private val app get() = service() private val state = SettingsDraftState(skillsDraft(app.state.value.config?.skills ?: SkillsConfigDto()), ::saved) private var draft: SkillsDraft @@ -116,12 +119,20 @@ internal class SkillsSettingsUi( val items = withTimeoutOrNull(SKILL_LOAD_TIMEOUT_MS) { service().loadSkills(dir) } ?: throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.skills.load.timeout")) + val owned = if (dir.isBlank()) emptyList() else service().bundles(dir) withContext(edt) { val dirty = state.modified() val edit = draft state.accept(skillsDraft(config())) - if (dirty) draft = state.draft.copy(edited = edit.edited, deleted = edit.deleted) + if (dirty) { + draft = state.draft.copy( + edited = edit.edited, + deleted = edit.deleted, + uninstalls = edit.uninstalls, + ) + } skills = items.associateBy { key(it) } + bundles = owned sources.refresh(draft.sources) } LOG.info("skills settings fetch dir=$dir total=${items.size}") @@ -178,6 +189,15 @@ internal class SkillsSettingsUi( if (target.edited.isNotEmpty() && !behavior.saveSkills(dir, target.edited)) { failed = KiloBundle.message("settings.agentBehavior.save.failed") } + if (failed == null) { + for (bundle in target.uninstalls) { + val result = service().remove(dir, bundle.id, "mcp", bundle.scope) + if (!result.success) { + failed = result.error ?: KiloBundle.message("settings.marketplace.remove.failed") + break + } + } + } if (failed == null) { for (location in target.deleted) { if (!behavior.removeSkill(dir, location)) { @@ -226,12 +246,12 @@ internal class SkillsSettingsUi( } private fun rows(items: List = skills.values.toList()): List = items.mapNotNull { skill -> - if (skill.location in draft.deleted) return@mapNotNull null + if (skill.location in draft.deleted || draft.uninstalls.any { skill.location in it.skills }) return@mapNotNull null item(skill) } private fun skillFallback(target: SkillsDraft): List = skills.values.mapNotNull { skill -> - if (skill.location in target.deleted) return@mapNotNull null + if (skill.location in target.deleted || target.uninstalls.any { skill.location in it.skills }) return@mapNotNull null target.edited[skill.location]?.let { skill.copy(content = it) } ?: skill } @@ -287,14 +307,30 @@ internal class SkillsSettingsUi( } private fun remove(skill: SkillDto) { + val bundle = bundles.singleOrNull { skill.location in it.skills } val result = Messages.showYesNoDialog( - KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name), + if (bundle == null) { + KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name) + } else { + KiloBundle.message("settings.agentBehavior.skills.delete.bundle.message", skill.name, bundle.id) + }, KiloBundle.message("settings.agentBehavior.skills.delete.title"), KiloBundle.message("common.delete"), Messages.getCancelButton(), Messages.getQuestionIcon(), ) if (result != Messages.YES) return + if (bundle != null) { + state.update { + copy( + deleted = deleted - bundle.skills.toSet(), + edited = edited - bundle.skills.toSet(), + uninstalls = uninstalls + bundle, + ) + } + view.update(rows(), ActiveListSelection.Slide) + return + } state.update { copy(deleted = deleted + skill.location, edited = edited - skill.location) } view.update(rows(), ActiveListSelection.Slide) } @@ -325,6 +361,7 @@ private data class SkillsDraft( val sources: SkillsConfigDto, val edited: Map = emptyMap(), val deleted: Set = emptySet(), + val uninstalls: Set = emptySet(), ) private fun skillsDraft(sources: SkillsConfigDto) = SkillsDraft(sources) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 4547cd16394..f33d5997309 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -149,8 +149,8 @@ internal interface ActiveListHitCell { * list, and the session history stack: a leading icon, a title whose weight follows * [ActiveListConfig.title] with an inline [note], a secondary [description] line, [leading] badges * before the title, inline [badges] after it, optional right-aligned [trailing] text, and action - * [cells]. Action cells are shown only for the active focused selection unless - * [ActiveListCell.alwaysVisible] is true. + * [cells]. Action cells are shown for the selected row, including while the list is unfocused. + * [ActiveListCell.alwaysVisible] also exposes an action on unselected rows. */ internal interface ActiveListItem { val key: String @@ -259,9 +259,8 @@ internal fun activeListHits( @Suppress("UNCHECKED_CAST") val renderer = list.cellRenderer as? ListCellRenderer ?: return emptyList() val cell = list.getCellBounds(index, index) ?: return emptyList() - // Render as focused so the region geometry is available for hit-testing even when the list is - // not the focus owner. Painting still hides the cells on an unfocused list; this only resolves - // hit targets and keeps them stable regardless of focus. + // Render as focused so the region geometry and focused/unfocused selection state never alter + // hit targets. val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, true) comp.setBounds(0, 0, cell.width, cell.height) activeListLayout(comp) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 316b62539c2..175ebde98c4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -358,7 +358,7 @@ internal class ActiveListRenderer( trail.foreground = weak val hovered = (list as? ActiveListActive)?.hoveredIndex() == index - val show = if (cfg.hoverActions) list.isEnabled && selected && hovered else active && list.isEnabled + val show = if (cfg.hoverActions) list.isEnabled && selected && hovered else selected && list.isEnabled syncCells(value, show) cellPane.isVisible = cells.isVisible pill.isVisible = cells.isVisible diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 59b93c2c0ca..1ba23c37805 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -885,6 +885,7 @@ settings.agentBehavior.mcp.edit.env.add=Add settings.agentBehavior.mcp.edit.env.remove=Remove {0} settings.agentBehavior.mcp.delete.title=Delete MCP Server settings.agentBehavior.mcp.delete.message=Delete MCP server {0}? This cannot be undone. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.mcp.status.connected=connected settings.agentBehavior.mcp.status.failed=failed settings.agentBehavior.mcp.status.needsAuth=needs auth @@ -969,6 +970,7 @@ settings.agentBehavior.skills.reload.blocked=Skills settings saved, but active s settings.agentBehavior.skills.saved.notification=Skills settings saved settings.agentBehavior.skills.delete.title=Delete Skill settings.agentBehavior.skills.delete.message=Delete skill {0}? This removes the skill file and cannot be undone. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. settings.agentBehavior.skills.delete.failed=Could not delete the skill. settings.agentBehavior.skills.openInEditor=Open in Editor settings.agentBehavior.skills.openInEditor.pending=The skill file will open after you close Settings. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 5612dd995bd..02074048a7a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=تم حفظ إعدادات المه settings.agentBehavior.skills.saved.notification=تم حفظ إعدادات المهارات settings.agentBehavior.skills.delete.title=حذف المهارة settings.agentBehavior.skills.delete.message=هل تريد حذف المهارة {0}؟ سيؤدي ذلك إلى إزالة ملف المهارة ولا يمكن التراجع عنه. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=تعذر حذف المهارة. settings.agentBehavior.skills.openInEditor=فتح في المحرر settings.agentBehavior.skills.openInEditor.pending=سيتم فتح ملف المهارة بعد إغلاق الإعدادات. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 29127081c89..970ad5a862f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Postavke vještina su sačuvane, al settings.agentBehavior.skills.saved.notification=Postavke vještina su sačuvane settings.agentBehavior.skills.delete.title=Izbriši vještinu settings.agentBehavior.skills.delete.message=Izbrisati vještinu {0}? Ovo uklanja datoteku vještine i ne može se poništiti. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Nije moguće izbrisati vještinu. settings.agentBehavior.skills.openInEditor=Otvori u editoru settings.agentBehavior.skills.openInEditor.pending=Datoteka vještine će se otvoriti nakon što zatvorite Postavke. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 25b6c539f65..b4a0416e7db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Færdighedsindstillingerne blev gem settings.agentBehavior.skills.saved.notification=Færdighedsindstillinger gemt settings.agentBehavior.skills.delete.title=Slet færdighed settings.agentBehavior.skills.delete.message=Slet færdigheden {0}? Dette fjerner færdighedsfilen og kan ikke fortrydes. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Kunne ikke slette færdigheden. settings.agentBehavior.skills.openInEditor=Åbn i editor settings.agentBehavior.skills.openInEditor.pending=Færdighedsfilen åbnes, når du lukker Indstillinger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 3098a6aae17..b3c5d3ebcf3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Skill-Einstellungen gespeichert, ab settings.agentBehavior.skills.saved.notification=Skill-Einstellungen gespeichert settings.agentBehavior.skills.delete.title=Skill löschen settings.agentBehavior.skills.delete.message=Skill {0} löschen? Dadurch wird die Skill-Datei entfernt und kann nicht rückgängig gemacht werden. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Der Skill konnte nicht gelöscht werden. settings.agentBehavior.skills.openInEditor=Im Editor öffnen settings.agentBehavior.skills.openInEditor.pending=Die Skill-Datei wird geöffnet, nachdem Sie die Einstellungen geschlossen haben. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 601eda07b7d..ff3aa9b67a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Configuración de habilidades guard settings.agentBehavior.skills.saved.notification=Configuración de habilidades guardada settings.agentBehavior.skills.delete.title=Eliminar habilidad settings.agentBehavior.skills.delete.message=¿Eliminar la habilidad {0}? Esto elimina el archivo de la habilidad y no se puede deshacer. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=No se pudo eliminar la habilidad. settings.agentBehavior.skills.openInEditor=Abrir en el editor settings.agentBehavior.skills.openInEditor.pending=El archivo de la habilidad se abrirá después de cerrar Configuración. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 204af8eb4a1..ee3e585d23a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Paramètres des compétences enregi settings.agentBehavior.skills.saved.notification=Paramètres des compétences enregistrés settings.agentBehavior.skills.delete.title=Supprimer la compétence settings.agentBehavior.skills.delete.message=Supprimer la compétence {0} ? Cela supprime le fichier de compétence et ne peut pas être annulé. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Impossible de supprimer la compétence. settings.agentBehavior.skills.openInEditor=Ouvrir dans l’éditeur settings.agentBehavior.skills.openInEditor.pending=Le fichier de compétence s’ouvrira après la fermeture des paramètres. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index f1bbb2a7ecf..750a0e4a966 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=スキル設定を保存しまし settings.agentBehavior.skills.saved.notification=スキル設定を保存しました settings.agentBehavior.skills.delete.title=スキルを削除 settings.agentBehavior.skills.delete.message=スキル {0} を削除しますか?スキルファイルが削除され、この操作は元に戻せません。 +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=スキルを削除できませんでした。 settings.agentBehavior.skills.openInEditor=エディターで開く settings.agentBehavior.skills.openInEditor.pending=設定を閉じるとスキルファイルが開きます。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index ec1f5f81efb..84004804946 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=스킬 설정이 저장되었지만 settings.agentBehavior.skills.saved.notification=스킬 설정이 저장되었습니다 settings.agentBehavior.skills.delete.title=스킬 삭제 settings.agentBehavior.skills.delete.message=스킬 {0}을 삭제할까요? 스킬 파일이 제거되며 되돌릴 수 없습니다. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=스킬을 삭제할 수 없습니다. settings.agentBehavior.skills.openInEditor=에디터에서 열기 settings.agentBehavior.skills.openInEditor.pending=설정을 닫으면 스킬 파일이 열립니다. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 5cbd6e4d07b..e6b82aaae31 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Vaardigheidsinstellingen opgeslagen settings.agentBehavior.skills.saved.notification=Vaardigheidsinstellingen opgeslagen settings.agentBehavior.skills.delete.title=Vaardigheid verwijderen settings.agentBehavior.skills.delete.message=Vaardigheid {0} verwijderen? Dit verwijdert het vaardigheidsbestand en kan niet ongedaan worden gemaakt. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Kon de vaardigheid niet verwijderen. settings.agentBehavior.skills.openInEditor=Openen in editor settings.agentBehavior.skills.openInEditor.pending=Het vaardigheidsbestand wordt geopend nadat u Instellingen sluit. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 9e31e606e05..7eae2dc5135 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Ferdighetsinnstillinger ble lagret, settings.agentBehavior.skills.saved.notification=Ferdighetsinnstillinger lagret settings.agentBehavior.skills.delete.title=Slett ferdighet settings.agentBehavior.skills.delete.message=Slette ferdigheten {0}? Dette fjerner ferdighetsfilen og kan ikke angres. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Kunne ikke slette ferdigheten. settings.agentBehavior.skills.openInEditor=Åpne i editor settings.agentBehavior.skills.openInEditor.pending=Ferdighetsfilen åpnes etter at du lukker Innstillinger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index b90301f10b0..0a17749e530 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Ustawienia umiejętności zapisane, settings.agentBehavior.skills.saved.notification=Ustawienia umiejętności zapisane settings.agentBehavior.skills.delete.title=Usuń umiejętność settings.agentBehavior.skills.delete.message=Usunąć umiejętność {0}? Spowoduje to usunięcie pliku umiejętności i nie można tego cofnąć. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Nie można usunąć umiejętności. settings.agentBehavior.skills.openInEditor=Otwórz w edytorze settings.agentBehavior.skills.openInEditor.pending=Plik umiejętności otworzy się po zamknięciu Ustawień. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 712d99b8f02..29369d249f9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Configurações de habilidades salv settings.agentBehavior.skills.saved.notification=Configurações de habilidades salvas settings.agentBehavior.skills.delete.title=Excluir habilidade settings.agentBehavior.skills.delete.message=Excluir a habilidade {0}? Isso remove o arquivo da habilidade e não pode ser desfeito. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Não foi possível excluir a habilidade. settings.agentBehavior.skills.openInEditor=Abrir no editor settings.agentBehavior.skills.openInEditor.pending=O arquivo da habilidade será aberto depois que você fechar as Configurações. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 59a1b7f90bb..01b02bcb0a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Настройки навыков settings.agentBehavior.skills.saved.notification=Настройки навыков сохранены settings.agentBehavior.skills.delete.title=Удалить навык settings.agentBehavior.skills.delete.message=Удалить навык {0}? Это удалит файл навыка, и действие нельзя будет отменить. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Не удалось удалить навык. settings.agentBehavior.skills.openInEditor=Открыть в редакторе settings.agentBehavior.skills.openInEditor.pending=Файл навыка откроется после закрытия настроек. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 577750bc5f4..036066ba138 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=บันทึกการตั settings.agentBehavior.skills.saved.notification=บันทึกการตั้งค่าทักษะแล้ว settings.agentBehavior.skills.delete.title=ลบทักษะ settings.agentBehavior.skills.delete.message=ลบทักษะ {0} หรือไม่ การดำเนินการนี้จะลบไฟล์ทักษะและไม่สามารถย้อนกลับได้ +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=ไม่สามารถลบทักษะได้ settings.agentBehavior.skills.openInEditor=เปิดในตัวแก้ไข settings.agentBehavior.skills.openInEditor.pending=ไฟล์ทักษะจะเปิดหลังจากคุณปิดการตั้งค่า diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index f98d8577f61..6f7b938c730 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Beceri ayarları kaydedildi, ancak settings.agentBehavior.skills.saved.notification=Beceri ayarları kaydedildi settings.agentBehavior.skills.delete.title=Beceriyi Sil settings.agentBehavior.skills.delete.message={0} becerisi silinsin mi? Bu işlem beceri dosyasını kaldırır ve geri alınamaz. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Beceri silinemedi. settings.agentBehavior.skills.openInEditor=Düzenleyicide Aç settings.agentBehavior.skills.openInEditor.pending=Beceri dosyası Ayarlar kapatıldıktan sonra açılacak. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 08c9a030ae5..610cc2b3b78 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=Налаштування навич settings.agentBehavior.skills.saved.notification=Налаштування навичок збережено settings.agentBehavior.skills.delete.title=Видалити навичку settings.agentBehavior.skills.delete.message=Видалити навичку {0}? Це видалить файл навички, і дію не можна буде скасувати. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=Не вдалося видалити навичку. settings.agentBehavior.skills.openInEditor=Відкрити в редакторі settings.agentBehavior.skills.openInEditor.pending=Файл навички відкриється після закриття Налаштувань. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 881ea175867..590e836bf2b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=技能设置已保存,但仍有 settings.agentBehavior.skills.saved.notification=技能设置已保存 settings.agentBehavior.skills.delete.title=删除技能 settings.agentBehavior.skills.delete.message=要删除技能 {0} 吗?这会移除技能文件,且无法撤销。 +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=无法删除该技能。 settings.agentBehavior.skills.openInEditor=在编辑器中打开 settings.agentBehavior.skills.openInEditor.pending=关闭设置后将打开技能文件。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index fb6e9b47970..27ff4462bb0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -518,6 +518,8 @@ settings.agentBehavior.skills.reload.blocked=技能設定已儲存,但仍有 settings.agentBehavior.skills.saved.notification=技能設定已儲存 settings.agentBehavior.skills.delete.title=刪除技能 settings.agentBehavior.skills.delete.message=要刪除技能 {0} 嗎?這會移除技能檔案,且無法復原。 +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.skills.delete.failed=無法刪除該技能。 settings.agentBehavior.skills.openInEditor=在編輯器中開啟 settings.agentBehavior.skills.openInEditor.pending=關閉設定後將開啟技能檔案。 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt index cde6667f442..7181ed3d2bb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -513,6 +513,8 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "settings.agentBehavior.mcp.signIn.unsupported" to listOf("SERVER_NAME"), "settings.agentBehavior.mcp.resetAuth.message" to listOf("SERVER_NAME"), "settings.agentBehavior.mcp.authUrl.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.delete.bundle.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.skills.delete.bundle.message" to listOf("SKILL_NAME", "SERVER_NAME"), "settings.marketplace.mcp.signIn.message" to listOf("SERVER_NAME"), "prompt.mcp.needsAuth.one" to listOf("SERVER_NAME"), "prompt.mcp.needsAuth.many" to listOf("7"), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index b4585120859..38d620a3ee7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -4,11 +4,13 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloMcpAuthService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.DirectoryReadyConfigurable import ai.kilocode.client.settings.base.SettingsInfo import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeMarketplaceRpcApi import ai.kilocode.client.testing.fire import ai.kilocode.client.testing.rowLines import ai.kilocode.client.ui.list.ActiveListItem @@ -21,6 +23,7 @@ import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import com.intellij.icons.AllIcons import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service @@ -56,6 +59,7 @@ class McpSettingsUiTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private lateinit var appRpc: FakeAppRpcApi private lateinit var agentRpc: FakeAgentBehaviorRpcApi + private lateinit var marketRpc: FakeMarketplaceRpcApi override fun tearDown() { try { @@ -91,13 +95,13 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("connected", "stdio"), rows.single { it.key == "filesystem" }.badges.map { it.text }) assertEquals("bun mcp-files", rows.single { it.key == "filesystem" }.description) assertEquals(listOf("needs auth", "remote"), rows.single { it.key == "github" }.badges.map { it.text }) + assertEquals(AllIcons.General.Warning, rows.single { it.key == "github" }.badges.first().icon) assertEquals("https://mcp.github.test", rows.single { it.key == "github" }.description) assertEquals(listOf("failed"), rows.single { it.key == "runtime" }.badges.map { it.text }) assertEquals("crashed", rows.single { it.key == "runtime" }.description) val github = rows.single { it.key == "github" } - assertEquals(listOf("connect", "auth", "resetAuth", "edit", "remove"), github.cells.map { it.id }) + assertEquals(listOf("auth", "edit", "remove"), github.cells.map { it.id }) assertTrue(github.cells.single { it.id == "edit" }.primary) - assertEquals(KiloBundle.message("settings.agentBehavior.mcp.connect"), github.cells.single { it.id == "connect" }.label) val remove = github.cells.single { it.id == "remove" } assertEquals(AllIcons.Actions.GC, remove.icon) assertTrue(remove.iconOnly) @@ -131,6 +135,8 @@ class McpSettingsUiTest : BasePlatformTestCase() { // github is needs_auth in the fixture; the row must expose sign-in, not just Connect. val cells = rows(panel).single { it.key == "github" }.cells.map { it.id } assertTrue("needs_auth must offer sign-in, got $cells", cells.contains("auth")) + assertFalse("needs_auth must not offer connect, got $cells", cells.contains("connect")) + assertFalse("needs_auth must not offer reset sign-in, got $cells", cells.contains("resetAuth")) val label = rows(panel).single { it.key == "github" }.cells.single { it.id == "auth" }.label assertEquals(KiloBundle.message("settings.agentBehavior.mcp.signIn"), label) // A healthy server must not. @@ -239,11 +245,11 @@ class McpSettingsUiTest : BasePlatformTestCase() { agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, "connected") } - click(panel, "github", "connect") + click(panel, "runtime", "connect") - flushUntil { rows(panel).single { it.key == "github" }.badges.first().text == "connected" } - assertEquals(listOf("github"), agentRpc.mcpConnects) - assertEquals("github", edt { list(panel).selectedValue?.key }) + flushUntil { rows(panel).single { it.key == "runtime" }.badges.first().text == "connected" } + assertEquals(listOf("runtime"), agentRpc.mcpConnects) + assertEquals("runtime", edt { list(panel).selectedValue?.key }) } fun `test remove action writes mcp config patch and reloads`() { @@ -261,6 +267,44 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertNull(save.third) } + fun `test removing a marketplace MCP confirms and removes its companion bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val server = agentRpc.mcpConfigs.getValue("github") + agentRpc.mcpConfigs = agentRpc.mcpConfigs + ("github" to server.copy(scope = "workspace")) + marketRpc.bundles = listOf(MarketplaceBundleDto("github", "project", listOf("$DIR/.kilo/skills/github/SKILL.md"))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + var message = "" + TestDialogManager.setTestDialog { + message = it + Messages.YES + } + + click(panel, "github", "remove") + + flushUntil { marketRpc.removeCalls.isNotEmpty() } + assertTrue(message.contains("companion skills")) + assertEquals(FakeMarketplaceRpcApi.RemoveCall(DIR, "github", "mcp", "project"), marketRpc.removeCalls.single()) + assertTrue(agentRpc.mcpSaves.isEmpty()) + assertEquals(listOf(DIR), agentRpc.skillReloads) + } + + fun `test declining marketplace MCP removal keeps both components`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("github", "global", listOf("/skills/github/SKILL.md"))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "github", "remove") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(marketRpc.removeCalls.isEmpty()) + assertTrue(agentRpc.mcpSaves.isEmpty()) + } + fun `test remove selects the server that took the deleted slot`() { val panel = panel() flushUntil { rows(panel).size == 3 } @@ -449,21 +493,30 @@ class McpSettingsUiTest : BasePlatformTestCase() { flushUntil { agentRpc.mcpAuthRemovals.contains("github") } } - fun `test reset sign in cell only appears for remote servers`() { + fun `test reset sign in cell only appears for connected remote servers`() { val panel = panel() flushUntil { rows(panel).size == 3 } edt { val rows = rows(panel) - assertTrue(rows.single { it.key == "github" }.cells.any { it.id == "resetAuth" }) + assertFalse(rows.single { it.key == "github" }.cells.any { it.id == "resetAuth" }) assertFalse(rows.single { it.key == "filesystem" }.cells.any { it.id == "resetAuth" }) true } + + status(panel, "github", "connected") + + edt { + val cells = rows(panel).single { it.key == "github" }.cells.map { it.id } + assertEquals(listOf("disconnect", "resetAuth", "edit", "remove"), cells) + true + } } fun `test reset sign in requires confirmation and calls mcpAuthRemove on accept`() { val panel = panel() flushUntil { rows(panel).size == 3 } + status(panel, "github", "connected") agentRpc.mcpAuthRemoveResult = true agentRpc.afterMcpConnect = { _, name -> agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, "needs_auth") @@ -481,6 +534,7 @@ class McpSettingsUiTest : BasePlatformTestCase() { fun `test reset sign in decline does not call mcpAuthRemove`() { val panel = panel() flushUntil { rows(panel).size == 3 } + status(panel, "github", "connected") TestDialogManager.setTestDialog { Messages.NO } click(panel, "github", "resetAuth") @@ -545,10 +599,18 @@ class McpSettingsUiTest : BasePlatformTestCase() { return panel } + private fun status(panel: McpSettingsUi, name: String, status: String) { + val calls = agentRpc.mcpCalls.size + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, status) + edt { panel.reload(); true } + flushUntil { agentRpc.mcpCalls.size > calls && searchField(panel).isEnabled } + } + private fun install() { val cs = CoroutineScope(SupervisorJob()) scope = cs appRpc = FakeAppRpcApi() + marketRpc = FakeMarketplaceRpcApi() agentRpc = FakeAgentBehaviorRpcApi().apply { mcps = listOf( McpStatusDto("filesystem", "connected"), @@ -571,6 +633,11 @@ class McpSettingsUiTest : BasePlatformTestCase() { val behavior = KiloAgentBehaviorService(cs, agentRpc) ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, behavior, testRootDisposable) ApplicationManager.getApplication().replaceService(KiloMcpAuthService::class.java, KiloMcpAuthService(cs, behavior), testRootDisposable) + ApplicationManager.getApplication().replaceService( + KiloMarketplaceService::class.java, + KiloMarketplaceService(cs, marketRpc), + testRootDisposable, + ) } private fun click(panel: McpSettingsUi, key: String, id: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt index f90b7ae853f..6eb822439a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt @@ -3,12 +3,14 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.SettingsInfo import ai.kilocode.client.settings.base.SettingsPathDialogHandle import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeMarketplaceRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.fire import ai.kilocode.client.testing.rowLines @@ -17,6 +19,7 @@ import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.SkillDto import ai.kilocode.rpc.dto.SkillsConfigDto import com.intellij.openapi.application.ApplicationManager @@ -29,6 +32,7 @@ import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SearchTextField import com.intellij.ui.TitledSeparator import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList @@ -57,6 +61,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { private lateinit var appRpc: FakeAppRpcApi private lateinit var agentRpc: FakeAgentBehaviorRpcApi private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var marketRpc: FakeMarketplaceRpcApi private var shown = 0 override fun tearDown() { @@ -319,6 +324,44 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) } + fun `test deleting a companion skill stages its whole marketplace bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("anaconda", "global", listOf(CUSTOM))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + var message = "" + TestDialogManager.setTestDialog { + message = it + Messages.YES + } + + click(skillsList(panel), panel, CUSTOM, "delete") + + assertTrue(message.contains("anaconda")) + assertTrue(edt { rows(panel).none { it.key == CUSTOM } }) + assertTrue(marketRpc.removeCalls.isEmpty()) + edt { panel.applyDraft(); true } + flushUntil { marketRpc.removeCalls.isNotEmpty() } + assertEquals(FakeMarketplaceRpcApi.RemoveCall(DIR, "anaconda", "mcp", "global"), marketRpc.removeCalls.single()) + assertTrue(agentRpc.skillRemovals.isEmpty()) + } + + fun `test declining companion skill removal keeps the whole bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("anaconda", "global", listOf(CUSTOM))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + TestDialogManager.setTestDialog { Messages.NO } + + click(skillsList(panel), panel, CUSTOM, "delete") + + assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) + assertFalse(edt { panel.modified() }) + assertTrue(marketRpc.removeCalls.isEmpty()) + } + fun `test add path and url write skills config patch on apply`() { var path = "/extra/skills" var url = "https://skills.test/index.json" @@ -510,6 +553,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { scope = cs appRpc = FakeAppRpcApi() workspaceRpc = FakeWorkspaceRpcApi() + marketRpc = FakeMarketplaceRpcApi() agentRpc = FakeAgentBehaviorRpcApi().apply { skills = listOf( SkillDto("plan", "Plan work", CUSTOM, "# Plan\nUse steps", editable = true), @@ -530,6 +574,11 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable) + ApplicationManager.getApplication().replaceService( + KiloMarketplaceService::class.java, + KiloMarketplaceService(cs, marketRpc), + testRootDisposable, + ) } private fun click(list: JBList, panel: SkillsSettingsUi, key: String, id: String) { @@ -568,6 +617,8 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { private fun skillsList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().first() + private fun searchField(panel: SkillsSettingsUi) = components(panel).filterIsInstance().single() + private fun bannerIntro(info: SettingsInfo): String { val pane = UIUtil.findComponentOfType(info, javax.swing.JEditorPane::class.java) ?: error("no banner text") return pane.text.replace(Regex("<[^>]+>"), "").replace(Regex("\\s+"), " ").trim() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 91b84697371..f32e4aaa8f2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -660,7 +660,7 @@ class SettingsListViewTest : BasePlatformTestCase() { } } - fun `test in-place action cells are hidden on unfocused selected row`() { + fun `test in-place action cells stay visible on unfocused selected row`() { edt { val row = item("with", "Alpha", "Description", ActiveListCell("edit", "Edit")) val model = CollectionListModel(listOf(row)) @@ -668,7 +668,7 @@ class SettingsListViewTest : BasePlatformTestCase() { val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) renderer.getListCellRendererComponent(list, row, 0, true, false) - assertTrue(actionCells(renderer).none { it.isVisible }) + assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId }) renderer.getListCellRendererComponent(list, row, 0, true, true) assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt index 9803b63ad40..21c5a3bc1a9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -11,10 +12,12 @@ class FakeMarketplaceRpcApi : KiloMarketplaceRpcApi { var listError: Exception? = null var installResult = MarketplaceResultDto(success = true, slug = "test") var removeResult = MarketplaceResultDto(success = true, slug = "test") + var bundles = emptyList() /** Holds [install] open until completed, so a test can observe the in-flight state. */ var installGate: CompletableDeferred? = null val listCalls = mutableListOf() + val bundleCalls = mutableListOf() val installCalls = mutableListOf() val removeCalls = mutableListOf() @@ -28,6 +31,12 @@ class FakeMarketplaceRpcApi : KiloMarketplaceRpcApi { return list } + override suspend fun bundles(directory: String): List { + assertNotEdt("marketplace.bundles") + bundleCalls.add(directory) + return bundles + } + override suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt index f2b3eeccbdd..32716f85f82 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.MarketplaceItemDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto import com.intellij.platform.rpc.RemoteApiProviderService @@ -18,6 +19,8 @@ interface KiloMarketplaceRpcApi : RemoteApi { suspend fun list(directory: String): MarketplaceListDto + suspend fun bundles(directory: String): List + suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MarketplaceDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MarketplaceDto.kt index 3718f04ca97..19a732027a3 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MarketplaceDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MarketplaceDto.kt @@ -23,6 +23,13 @@ data class MarketplaceSkillDto( val content: String, ) +@Serializable +data class MarketplaceBundleDto( + val id: String, + val scope: String, + val skills: List, +) + // `content` carries the marketplace catalog item's raw `content` field as JSON text // (a string for skills, an AgentContent object for agents, a string or an array of // installation methods for MCP servers) so it can be replayed verbatim on install From a73754ff88ed603cf2c88c13cff093d3684f2a95 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 15:43:01 -0400 Subject: [PATCH 049/353] fix(jetbrains): address PR review feedback on MCP auth and list hit-testing - Anchor the 401/oauth auth-failure markers so an unrelated failure that merely embeds a port number, ID, or hostname containing those substrings is not misclassified as needs_auth. 401 now requires an http/status-prefixed match; oauth requires a word boundary. - A tooltip-only badge (an id with no action, like the MCP status pill) no longer blocks single/double-click fall-through to the row's own click/open handler. Only regions that carry an actual action still swallow the click. Addresses PR #14667 review threads on KiloCliDataParser.kt and McpConfigurable.kt/ActiveListView.kt. --- .../kilocode/backend/cli/KiloCliDataParser.kt | 17 +++++++++---- .../backend/cli/KiloCliDataParserTest.kt | 20 +++++++++++++++ .../kilocode/client/ui/list/ActiveListView.kt | 10 +++++--- .../settings/base/SettingsListViewTest.kt | 25 +++++++++++++++++++ 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 4009041d8df..567e99efb18 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -135,14 +135,18 @@ object KiloCliDataParser { "authentication required", "needs authentication", "not authenticated", - "oauth", "browser authorization", "token exchange failed", "invalid_token", "invalid_grant", - "401", ) + /** Word-boundary markers, so a hostname or server name that merely contains the text does not match. */ + private val MCP_AUTH_FAILURE_WORD_MARKERS = listOf("oauth").map { Regex("\\b${Regex.escape(it)}\\b") } + + /** An HTTP 401 response, anchored to `http`/`status` so an unrelated port or ID is not mistaken for it. */ + private val MCP_AUTH_HTTP_401 = Regex("\\b(?:http|status)\\D{0,10}401\\b") + // ================================================================ // SSE event parsing // ================================================================ @@ -900,14 +904,17 @@ object KiloCliDataParser { * * The CLI only reports `needs_auth` when the transport threw `UnauthorizedError` or the message * mentions OAuth (`packages/opencode/src/mcp/index.ts`); a rejected browser flow, a failed token - * exchange, or a bare 401 body fall through to `failed`. Those are all recoverable by signing in + * exchange, or an HTTP 401 body fall through to `failed`. Those are all recoverable by signing in * again, so they must still offer sign-in in Settings, Marketplace, and the session prompt. * - * Matched markers are OAuth-specific, so a non-OAuth server failure is not reclassified. + * Matched markers are OAuth-specific and word/status-anchored, so an unrelated failure that merely + * embeds a port number or a server name containing "oauth" is not reclassified. */ private fun String?.isMcpAuthFailure(): Boolean { val value = this?.lowercase() ?: return false - return MCP_AUTH_FAILURE_MARKERS.any { value.contains(it) } + if (MCP_AUTH_FAILURE_MARKERS.any { value.contains(it) }) return true + if (MCP_AUTH_FAILURE_WORD_MARKERS.any { it.containsMatchIn(value) }) return true + return MCP_AUTH_HTTP_401.containsMatchIn(value) } private fun removable(obj: JsonObject): Boolean { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 0eca89ffbdf..6e19e43647e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2337,6 +2337,26 @@ class KiloCliDataParserTest { } } + /** + * The 401/oauth markers are anchored to avoid reclassifying failures that merely embed those + * digits/letters as a substring, e.g. a port number or a server hostname. + */ + @Test + fun `parseMcpStatus - loose 401 and oauth substrings are not reclassified as auth`() { + val reasons = listOf( + "connect ECONNREFUSED 127.0.0.1:40123", + "Invalid MCP URL for \"broken401\"", + "Invalid MCP URL for \"oauthserver\"", + "Failed to connect to myoauthapp.internal", + ) + + for (reason in reasons) { + val json = """{"broken":{"status":"failed","error":${JsonPrimitive(reason)}}}""" + val result = KiloCliDataParser.parseMcpStatus(json).single() + assertEquals("failed", result.status, "must stay failed for: $reason") + } + } + // ---- parseMcpBrowserOpenFailed ---- @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index b335718db91..1b6e2fa55e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -881,10 +881,12 @@ internal class ActiveListView( val id = if (enabled) { activeListCellAt(list, idx, e.point, selected, menu?.takeIf { it.available(item) } != null) } else { - activeListCellBounds(list, idx, selected) - .entries - .firstOrNull { it.value.contains(e.point) } - ?.key + // A hit-tested region without an action — a status pill with only an id for tooltip + // hit-testing — must not swallow a click/double-click that falls through to it; only an + // actual action cell (enabled or not) blocks fall-through to row activation. + activeListHits(list, idx, selected) + .firstOrNull { it.action != null && it.bounds.contains(e.point) } + ?.id } return Hit(item, id) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index f32e4aaa8f2..65a3504fff8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -588,6 +588,31 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test double click on a tooltip-only badge still activates the row`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { key, id -> calls += "$key:$id" } + // A status pill with an id (for tooltip hit-testing) but no action, exactly like the MCP + // Settings status badge — it must not swallow the double-click that falls through to the + // row's own open/activate handler. + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val badges = listOf(ActiveListBadge("needs auth", id = "status", tooltip = "reason")) + } + view.update(listOf(row)) + layout(view) + val bounds = view.list.getCellBounds(0, 0) + val badgeArea = activeListCellBounds(view.list, 0, selected = true).getValue("status") + + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, Point(badgeArea.centerX.toInt(), bounds.y + bounds.height / 2), count = 2)) + + assertEquals(listOf("with"), opened) + assertTrue("no cell action should fire for a non-actionable badge", calls.isEmpty()) + } + } + fun `test disabled action click does not invoke`() { edt { val calls = mutableListOf() From 3b3f20a55346a7999c2dd8844108cec8bc5ce2ee Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 15:43:35 -0400 Subject: [PATCH 050/353] fix(jetbrains): keep prompt navigator clear of the stripes The navigator had two overflow problems. JBList's expandable-item handler painted a truncated row's full text as a strip outside the list bounds, so it escaped the balloon and ran over the ticks and past the window edge. Disable it: rows are meant to read as clamped previews. Placement also rolled its own math instead of reusing SidePopupGeometry, so the body could overflow its side. BalloonImpl silently re-points a balloon ABOVE or BELOW when the requested rectangle does not fit, which dropped it back over the ticks. Anchor on the rail's left edge, cap the body to the room between that edge and the window, and budget height against the visible session via SidePopupGeometry.aim so the requested side is kept. --- .../session/ui/rail/PromptRailController.kt | 43 +++++++++---- .../client/session/ui/rail/PromptRailPopup.kt | 3 + .../session/ui/rail/PromptRailPopupTest.kt | 61 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt index df5a789192a..8a9190e8ac3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.popup.SidePopupController import ai.kilocode.client.ui.popup.SidePopupContent +import ai.kilocode.client.ui.popup.SidePopupGeometry import ai.kilocode.client.ui.popup.SidePopupRequest import ai.kilocode.client.ui.popup.SidePopupSpot import ai.kilocode.client.util.UiTimerSource @@ -247,29 +248,49 @@ internal class PromptRailController( ) } - /** Balloon anchor: to the left of the rail, no callout, vertically centered on the hovered tick. */ - private fun place(index: Int, body: SidePopupContent): SidePopupSpot? { + /** + * Anchors the balloon on the left edge of the rail, so the body opens into the transcript and never + * covers the ticks it describes. The body is capped to the room between that edge and the window, and + * its height is budgeted against the visible session: `BalloonImpl.show` silently re-points a balloon + * `ABOVE`/`BELOW` when the requested rectangle does not fit, which would drop it back over the ticks. + */ + private fun place(index: Int, built: SidePopupContent): SidePopupSpot? { val pane = SwingUtilities.getRootPane(rail)?.layeredPane ?: return null if (!rail.isShowing) return null - val railBounds = SwingUtilities.convertRectangle(rail.parent, rail.bounds, pane) + val area = SwingUtilities.convertRectangle(root, root.visibleRect, pane) + if (area.isEmpty) return null + val rect = SwingUtilities.convertRectangle(rail.parent, rail.bounds, pane) + val gap = UiStyle.Gap.pad() val insets = UiStyle.Balloon.insets() + // The shadow is reserved on every side, so it counts twice on each axis. There is no callout + // here, so unlike the header popups the pointer adds nothing. val shadow = UiStyle.Balloon.shadow() val chromeWidth = insets.left + insets.right + shadow * 2 val chromeHeight = insets.top + insets.bottom + shadow * 2 - val maxWidth = (railBounds.x - UiStyle.Gap.lg() - chromeWidth) + // Room is measured to the window edge rather than the chat panel: the rail hugs the right side of + // a narrow sidebar, where the panel alone would leave almost nothing to open into. + val maxWidth = (rect.x - chromeWidth - gap) .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) - val maxHeight = (railBounds.height - UiStyle.Gap.lg() - chromeHeight) + val maxHeight = (area.height - gap * 2 - chromeHeight) .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) if (maxWidth <= 0 || maxHeight <= 0) return null - body.fitWithin(maxWidth, maxHeight) - val y = rail.tickCenterY(index.coerceAtLeast(0)) - val point = SwingUtilities.convertPoint(rail, Point(0, y), pane) - val gap = UiStyle.Gap.lg() + built.fitWithin(maxWidth, maxHeight) + val view = Rectangle(area.x, area.y + shadow, area.width, (area.height - shadow * 2).coerceAtLeast(0)) + val height = built.component.preferredSize.height + insets.top + insets.bottom + val tick = SwingUtilities.convertPoint(rail, Point(0, rail.tickCenterY(index.coerceAtLeast(0))), pane) + val aim = SidePopupGeometry.aim( + view = view, + subject = rect, + y = tick.y, + height = height, + gap = gap, + indent = UiStyle.Balloon.arc(), + ) return SidePopupSpot( pane = pane, - point = Point(point.x - gap, point.y), + point = Point(rect.x, aim.y), position = Balloon.Position.atLeft, - distance = 0, + distance = aim.distance, callout = false, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt index c0d88157b07..56a561bb5dd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt @@ -43,6 +43,9 @@ internal class PromptRailPopup( private val model = CollectionListModel(items) private val list = JBList(model).apply { isOpaque = false + // The expandable-item hint paints a truncated row's full text as a strip outside the list, which + // escapes the balloon and lands on the ticks. Rows here are meant to read as clamped previews. + setExpandableItemsEnabled(false) visibleRowCount = items.size.coerceAtMost(8) fixedCellHeight = JBUI.scale(ROW_HEIGHT) cellRenderer = Renderer() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt new file mode 100644 index 00000000000..7d34f096ba9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt @@ -0,0 +1,61 @@ +package ai.kilocode.client.session.ui.rail + +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBList +import java.awt.Component +import java.awt.Container + +class PromptRailPopupTest : BasePlatformTestCase() { + /** + * The expandable-item hint renders a truncated row's full text as a strip outside the list bounds, so + * it escapes the balloon and paints over the ticks the navigator is anchored to. + */ + fun `test navigator list does not use expandable item hints`() { + val popup = popup(items(6)) + + val list = find(popup.component) ?: error("expected a JBList in the navigator body") + assertFalse(list.expandableItemsHandler.isEnabled) + + Disposer.dispose(popup.disposable) + } + + /** The cap is what keeps the balloon inside the room left of the rail instead of being re-pointed. */ + fun `test body honors the width cap`() { + val popup = popup(items(12)) + val cap = 240 + + popup.fitWithin(cap, 400) + + assertTrue( + "preferred width ${popup.component.preferredSize.width} must not exceed the $cap cap", + popup.component.preferredSize.width <= cap, + ) + assertTrue(popup.component.preferredSize.height <= 400) + + Disposer.dispose(popup.disposable) + } + + private fun popup(items: List) = PromptRailPopup( + items = items, + hovered = 0, + onSelect = {}, + onFirst = {}, + onLatest = {}, + ) + + private fun items(count: Int) = List(count) { + PromptRailItem( + id = "msg_$it", + queued = false, + prompt = "Prompt $it that is long enough to be clamped by the navigator row width", + answer = "Answer $it that is also long enough to wrap across the two lines the row allows", + ) + } + + private fun find(root: Component): JBList<*>? { + if (root is JBList<*>) return root + if (root !is Container) return null + return root.components.firstNotNullOfOrNull(::find) + } +} From f01d37ceed8c1a682170f7d371b93c71293e5327 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 16:31:25 -0400 Subject: [PATCH 051/353] fix(jetbrains): restore action-cell click blocking without swallowing tooltip badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix (a73754ff) keyed click fall-through on ActiveListHit.action, but that field is only the per-cell handler and is null for every cell routed through the panel-level onCell callback (Sign In, Connect, Delete, rename, ...) and for the row menu glyph. That regressed double-clicking any real onCell-routed action into also firing row activation. hit(e, enabled = false) now blocks fall-through when the hit id belongs to a real visible cell (activeListVisibleCells, including the menu glyph when available) or a registered badge region (activeListRegions) — independent of whether that cell/region carries its own action lambda. A tooltip-only badge (id with neither an action nor an onCell binding) still correctly falls through to row activation. Added a regression test that double-clicks a non-primary onCell action cell and asserts it fires the cell action without also invoking onOpen/ activate, per PR review feedback. Addresses PR #14667 review thread on ActiveListView.kt. --- .../kilocode/client/ui/list/ActiveListView.kt | 11 +++++-- .../settings/base/SettingsListViewTest.kt | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 1b6e2fa55e1..8f0c699ef01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -882,10 +882,15 @@ internal class ActiveListView( activeListCellAt(list, idx, e.point, selected, menu?.takeIf { it.available(item) } != null) } else { // A hit-tested region without an action — a status pill with only an id for tooltip - // hit-testing — must not swallow a click/double-click that falls through to it; only an - // actual action cell (enabled or not) blocks fall-through to row activation. + // hit-testing — must not swallow a click/double-click that falls through to it. `action` + // is only the per-cell handler and is null for every cell routed through the panel-level + // `onCell` callback (Sign In, Connect, Delete, rename, the row menu glyph, ...), so + // blocking must key off whether the hit id is a real cell/region, not that field. + val ids = activeListVisibleCells(item, selected, menu?.takeIf { it.available(item) } != null) + .map { it.id } + .toSet() + activeListRegions(item).keys activeListHits(list, idx, selected) - .firstOrNull { it.action != null && it.bounds.contains(e.point) } + .firstOrNull { it.id in ids && it.bounds.contains(e.point) } ?.id } return Hit(item, id) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 65a3504fff8..98d05ec6d72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -613,6 +613,39 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test double click on a non-primary onCell action does not also activate the row`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + // "Connect" has no per-cell `action`, exactly like every real onCell-routed action + // (Sign In, Connect, Delete, rename, ...) — the click must fire it once through onCell + // and must not also fall through to the row's own open/activate handler. + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { key, id -> calls += "$key:$id" } + val row = item( + "with", + "Alpha", + null, + ActiveListCell("connect", "Connect"), + ActiveListCell("edit", "Edit", primary = true), + ) + view.update(listOf(row)) + layout(view) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("connect") + val point = center(area) + + click(view, point) + fire(view.list, mouse(view, MouseEvent.MOUSE_PRESSED, point, count = 2)) + fire(view.list, mouse(view, MouseEvent.MOUSE_RELEASED, point, count = 2)) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, point, count = 2)) + + // Each press/release of the two clicks fires the cell action once, same as two + // independent single clicks; the only thing under test is that the trailing + // MOUSE_CLICKED(count=2) does not also treat this as row activation. + assertEquals(listOf("with:connect", "with:connect"), calls) + assertTrue("double-clicking a real action cell must not also open/activate the row", opened.isEmpty()) + } + } + fun `test disabled action click does not invoke`() { edt { val calls = mutableListOf() From d627b3185589fe671748b0b651d7a1c61bbb7795 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Wed, 30 Sep 2026 17:21:32 -0400 Subject: [PATCH 052/353] feat(cli): interrupt a subagent from its view with double Esc Double Esc in a subagent view aborts only that subagent (scope=session) while the parent keeps working. The parent's task tool reports the stop as a deliberate user interruption and is told not to relaunch the subagent on its own, keeping the task_id for a user-requested resume. Exit keys need a second press in the subagent view. --- .changeset/tui-subagent-esc-interrupt.md | 5 + packages/opencode/src/kilocode/tool/task.ts | 26 +++ packages/opencode/src/tool/task.ts | 5 + .../opencode/test/kilocode/tool/task.test.ts | 23 ++ packages/opencode/test/session/prompt.test.ts | 57 +++++ packages/tui/src/config/keybind.ts | 2 + packages/tui/src/kilocode/double-press.ts | 34 +++ packages/tui/src/routes/session/index.tsx | 56 ++++- .../src/routes/session/subagent-footer.tsx | 17 +- .../tui/test/kilocode/double-press.test.ts | 40 ++++ .../tui/test/kilocode/subagent-view.test.tsx | 221 ++++++++++++++++++ 11 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 .changeset/tui-subagent-esc-interrupt.md create mode 100644 packages/opencode/test/kilocode/tool/task.test.ts create mode 100644 packages/tui/src/kilocode/double-press.ts create mode 100644 packages/tui/test/kilocode/double-press.test.ts create mode 100644 packages/tui/test/kilocode/subagent-view.test.tsx diff --git a/.changeset/tui-subagent-esc-interrupt.md b/.changeset/tui-subagent-esc-interrupt.md new file mode 100644 index 00000000000..fe0d4d99828 --- /dev/null +++ b/.changeset/tui-subagent-esc-interrupt.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Press Esc twice in a subagent view to stop just that subagent while the parent session keeps working, and press the exit key twice to leave the TUI from a subagent view. Fix a blank screen that could appear when starting the TUI. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 3580c71e40f..2d6e7584647 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -35,6 +35,32 @@ const ModelState = z .passthrough() export namespace KiloTask { + /** + * Raised by the task tool when the user aborts a foreground subagent's own turn + * (a session-scoped abort from the subagent view). The stop was deliberate, so + * the message tells the parent not to resume on its own: the generic resume hint + * reads as an instruction and models immediately relaunched the subagent. The + * task_id stays available for when the user asks to continue. The task prompt is + * omitted: the parent already has it in its own tool call. + */ + export class Interrupted extends Error { + readonly sessionID: SessionID + readonly activity: string + + constructor(input: { sessionID: SessionID; activity: string }) { + super( + [ + `Interrupted by user: the user deliberately stopped subagent ${input.sessionID} during "${input.activity}".`, + "Do not resume, retry, or replace this subagent on your own. Tell the user it was stopped and wait for their direction.", + `If the user asks to continue it, call the task tool with task_id="${input.sessionID}"; its prior context is preserved.`, + ].join("\n"), + ) + this.name = "TaskInterrupted" + this.sessionID = input.sessionID + this.activity = input.activity + } + } + export const ModelFields = { model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e8e6186f0bb..8b2d5696b01 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -282,6 +282,11 @@ export const TaskTool = Tool.define( // kilocode_change start - expose terminal child assistant errors through the task tool boundary, // including the resumable task_id so the parent agent can continue the subagent (#11620) if (result.info.role === "assistant" && result.info.error) { + // a session-scoped abort (Esc in the subagent view) ends the child's turn with AbortedError + if (MessageV2.AbortedError.isInstance(result.info.error)) + return yield* Effect.fail( + new KiloTask.Interrupted({ sessionID: nextSession.id, activity: params.description }), + ) return yield* Effect.fail(new Error(`${errorMessage(result.info.error)}\n${resumeHint(nextSession.id)}`)) } const failed = result.parts.findLast((item) => item.type === "tool" && item.state.status === "error") diff --git a/packages/opencode/test/kilocode/tool/task.test.ts b/packages/opencode/test/kilocode/tool/task.test.ts new file mode 100644 index 00000000000..12338736985 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/task.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { KiloTask } from "../../../src/kilocode/tool/task" +import { SessionID } from "../../../src/session/schema" + +describe("KiloTask.Interrupted", () => { + const sessionID = SessionID.make("ses_interrupted") + const error = new KiloTask.Interrupted({ sessionID, activity: "inspect bug" }) + + test("names the user interruption, subagent, and activity", () => { + expect(error.name).toBe("TaskInterrupted") + expect(error.sessionID).toBe(sessionID) + expect(error.message).toContain("Interrupted by user") + expect(error.message).toContain(`"inspect bug"`) + }) + + test("tells the parent not to relaunch, but keeps the task_id for a user-requested resume", () => { + expect(error.message).toContain("Do not resume, retry, or replace this subagent on your own") + expect(error.message).toContain("If the user asks to continue it") + expect(error.message).toContain(`task_id="${sessionID}"`) + // the generic resume hint reads as an instruction and made models relaunch immediately + expect(error.message).not.toContain("can be resumed: call the task tool again") + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f7ee635ebd8..11773d5a644 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1365,6 +1365,63 @@ it.instance( 10_000, ) +// kilocode_change start - Esc in the subagent view aborts only the child (scope=session) +it.instance( + "session-scoped child abort reports a resumable user interruption to the parent", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* llm.text("parent recovered") + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + const child = yield* pollWithTimeout( + Effect.gen(function* () { + const id = (yield* sessions.children(chat.id))[0]?.id + if (!id) return undefined + if ((yield* status.get(id)).type !== "busy") return undefined + return (yield* llm.calls) >= 2 ? id : undefined + }), + "child task never started", + "10 seconds", + ) + + yield* prompt.cancel(child, "session") + const result = yield* awaitWithTimeout( + Fiber.join(fiber), + "parent did not continue after child abort", + "15 seconds", + ) + + expect(result.parts.some((part) => part.type === "text" && part.text === "parent recovered")).toBe(true) + const part = (yield* MessageV2.filterCompactedEffect(chat.id)) + .flatMap((msg) => msg.parts) + .find( + (part): part is ErrorToolPart => + part.type === "tool" && part.tool === "task" && part.state.status === "error", + ) + expect(part?.state.error).toContain("Interrupted by user") + expect(part?.state.error).toContain(`task_id="${child}"`) + expect(part?.state.error).not.toContain("look into the cache key path") + expect(JSON.stringify((yield* llm.hits).at(-1)?.body)).toContain("Interrupted by user") + }), + 30_000, +) +// kilocode_change end + it.instance( "loop sets status to busy then idle", () => diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 431c02e061c..e88cb4fb72c 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -95,6 +95,7 @@ export const Definitions = { session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), session_interrupt: keybind("escape", "Interrupt current session"), + subagent_interrupt: keybind("escape", "Interrupt current subagent"), // kilocode_change session_background: keybind("ctrl+b", "Background synchronous subagents"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), @@ -308,6 +309,7 @@ export const CommandMap = { session_share: "session.share", session_unshare: "session.unshare", session_interrupt: "session.interrupt", + subagent_interrupt: "subagent.interrupt", // kilocode_change session_background: "session.background", session_compact: "session.compact", session_toggle_timestamps: "session.toggle.timestamps", diff --git a/packages/tui/src/kilocode/double-press.ts b/packages/tui/src/kilocode/double-press.ts new file mode 100644 index 00000000000..56fff8eed11 --- /dev/null +++ b/packages/tui/src/kilocode/double-press.ts @@ -0,0 +1,34 @@ +import { createSignal, onCleanup } from "solid-js" + +/** + * Tracks a "press again to confirm" gesture. `press()` returns true on the + * second press inside `window` ms; each press restarts the window so a stale + * timer can never reset a newer count early. + */ +export function createDoublePress(window: number) { + const [count, setCount] = createSignal(0) + let timer: ReturnType | undefined + + const reset = () => { + clearTimeout(timer) + timer = undefined + setCount(0) + } + + onCleanup(() => clearTimeout(timer)) + + return { + count, + reset, + press() { + clearTimeout(timer) + if (count() >= 1) { + reset() + return true + } + setCount(1) + timer = setTimeout(reset, window) + return false + }, + } +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9154e337057..38c4da37151 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -20,11 +20,13 @@ import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" +import { useExit } from "../../context/exit" // kilocode_change import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" +import { createDoublePress } from "../../kilocode/double-press" // kilocode_change import { Prompt, type PromptRef } from "../../component/prompt" import type { AssistantMessage, @@ -342,6 +344,25 @@ export function Session() { const toast = useToast() const sdk = useSDK() const editor = useEditorContext() + const exit = useExit() // kilocode_change + // kilocode_change start - subagent-view interrupt and exit double-press state + const interrupt = createDoublePress(5000) + const quit = createDoublePress(1000) + // Pending permission/question/network prompts own escape and the exit keys. + const subagentKeys = createMemo(() => !!session()?.parentID && !disabled()) + const subagentRunning = createMemo(() => { + if (!subagentKeys()) return false + const status = sync.data.session_status?.[route.sessionID] + return status ? running(status.type) : false + }) + function interruptSubagent() { + if (!interrupt.press()) return + const fail = () => toast.show({ message: "Failed to interrupt subagent", variant: "error" }) + void sdk.client.session.abort({ sessionID: route.sessionID, scope: "session" }).then((res) => { + if (res.error) fail() + }, fail) + } + // kilocode_change end onCleanup(MemorySessionTui.attach({ event, toast, sessionID: route.sessionID })) // kilocode_change // kilocode_change start - background processes are scoped to the visible session @@ -1251,6 +1272,37 @@ export function Session() { bindings: tuiConfig.keybinds.get("session.background"), })) + // kilocode_change start - subagent view: double Esc interrupts only this subagent (scope=session), + // and the configured exit keys need a second press. `get` (not `gather`) because gather caches by name. + useBindings(() => ({ + mode: KILO_BASE_MODE, + enabled: subagentRunning(), + priority: 1, + commands: [ + { + name: "subagent.interrupt", + title: "Interrupt subagent", + category: "Session", + hidden: true, + run: interruptSubagent, + }, + ], + bindings: tuiConfig.keybinds.get("subagent.interrupt"), + })) + + useBindings(() => ({ + mode: KILO_BASE_MODE, + enabled: subagentKeys(), + priority: 1, + bindings: tuiConfig.keybinds.get("app.exit").map((binding) => ({ + ...binding, + cmd: () => { + if (quit.press()) exit() + }, + })), + })) + // kilocode_change end + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) const revertMessageIndex = createMemo(() => { @@ -1457,7 +1509,9 @@ export function Session() { - + {/* kilocode_change start */} + + {/* kilocode_change end */} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 069225359e9..2e3479eb5ee 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, Show } from "solid-js" +import type { Accessor } from "solid-js" // kilocode_change import { useRouteData } from "../../context/route" import { useSync } from "../../context/sync" import { useTheme } from "../../context/theme" @@ -10,7 +11,11 @@ import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" -export function SubagentFooter() { +// kilocode_change start - double-press counters owned by the session route +type Props = { interrupt: Accessor; exitPress: Accessor } + +export function SubagentFooter(props: Props) { + // kilocode_change end const route = useRouteData("session") const sync = useSync() const local = useLocal() // kilocode_change @@ -76,6 +81,8 @@ export function SubagentFooter() { const parentShortcut = useCommandShortcut("session.parent") const previousShortcut = useCommandShortcut("session.child.previous") const nextShortcut = useCommandShortcut("session.child.next") + const interruptShortcut = useCommandShortcut("subagent.interrupt") // kilocode_change + const exitShortcut = useCommandShortcut("app.exit") // kilocode_change const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -114,6 +121,14 @@ export function SubagentFooter() { )} + {/* kilocode_change start - interrupt/exit double-press hints */} + 0}> + {interruptShortcut() || "esc"} again to interrupt + + 0}> + {exitShortcut() || "ctrl+c"} again to exit + + {/* kilocode_change end */} ({ press: createDoublePress(window), dispose })) +} + +describe("createDoublePress", () => { + test("confirms on the second press and resets", () => { + const item = setup(1_000) + expect(item.press.press()).toBe(false) + expect(item.press.count()).toBe(1) + expect(item.press.press()).toBe(true) + expect(item.press.count()).toBe(0) + expect(item.press.press()).toBe(false) + item.dispose() + }) + + test("expires the first press after the window", async () => { + const item = setup(20) + expect(item.press.press()).toBe(false) + await Bun.sleep(40) + expect(item.press.count()).toBe(0) + expect(item.press.press()).toBe(false) + item.dispose() + }) + + test("a timer from an earlier gesture does not cut a later window short", async () => { + const item = setup(60) + item.press.press() + expect(item.press.press()).toBe(true) + await Bun.sleep(30) + item.press.press() + // the first gesture's timer would have fired here if it had not been cleared + await Bun.sleep(40) + expect(item.press.count()).toBe(1) + item.dispose() + }) +}) diff --git a/packages/tui/test/kilocode/subagent-view.test.tsx b/packages/tui/test/kilocode/subagent-view.test.tsx new file mode 100644 index 00000000000..264383fc99a --- /dev/null +++ b/packages/tui/test/kilocode/subagent-view.test.tsx @@ -0,0 +1,221 @@ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { createSlot, createSolidSlotRegistry, testRender, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onCleanup, Show } from "solid-js" +import { NudgeProvider } from "../../../opencode/src/kilocode/cli/cmd/tui/context/nudge" +import { TuiConfigProvider } from "../../src/config" +import { ArgsProvider } from "../../src/context/args" +import { ClipboardProvider } from "../../src/context/clipboard" +import { DataProvider } from "../../src/context/data" +import { EditorContextProvider } from "../../src/context/editor" +import { EpilogueProvider } from "../../src/context/epilogue" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { LocalProvider } from "../../src/context/local" +import { LocationProvider } from "../../src/context/location" +import { PermissionProvider } from "../../src/context/permission" +import { ProjectProvider } from "../../src/context/project" +import { PromptRefProvider } from "../../src/context/prompt" +import { RouteProvider } from "../../src/context/route" +import { TuiTerminalEnvironmentProvider } from "../../src/context/runtime" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider, useSync } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../src/keymap" +import { createPluginRuntime, PluginRuntimeProvider } from "../../src/plugin/runtime" +import { FrecencyProvider } from "../../src/prompt/frecency" +import { PromptHistoryProvider } from "../../src/prompt/history" +import { PromptStashProvider } from "../../src/prompt/stash" +import { Session } from "../../src/routes/session" +import { DialogProvider } from "../../src/ui/dialog" +import { ToastProvider } from "../../src/ui/toast" +import { wait } from "../cli/cmd/tui/sync-fixture" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createFetch, directory, eventSource, json } from "../fixture/tui-sdk" + +const parent = { + id: "ses_parent", + slug: "parent", + projectID: "proj_test", + directory, + title: "Parent session", + version: "test", + time: { created: 1, updated: 1 }, +} +const child = { + ...parent, + id: "ses_child", + slug: "child", + parentID: parent.id, + title: "inspect bug (@general subagent)", +} + +async function mount(root: string) { + await Bun.write(`${root}/kv.json`, JSON.stringify({ animations_enabled: false, sidebar: "hide", vim_enabled: false })) + const aborts: URL[] = [] + const exits: unknown[] = [] + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([parent, child]) + if (url.pathname === `/session/${child.id}`) return json(child) + if (url.pathname === `/session/${parent.id}`) return json(parent) + if (url.pathname === "/session/status") return json({ [child.id]: { type: "busy" } }) + if (url.pathname === `/session/${child.id}/abort`) { + aborts.push(url) + return json(true) + } + if (url.pathname.startsWith("/session/") && url.pathname.endsWith("/children")) return json([child]) + if (["/message", "/todo", "/diff"].some((suffix) => url.pathname.endsWith(suffix))) return json([]) + if (url.pathname.startsWith("/background-process/")) return json(true) + return undefined + }) + const config = createTuiResolvedConfig() + const refs: { sync?: ReturnType } = {} + + function Ready() { + const sync = useSync() + refs.sync = sync + return ( + + ({}) }}> + + + + + + + + + + + + + + + + + + + + + + ) + } + + function Content() { + const dimensions = useTerminalDimensions() + return ( + + + + + + ) + } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const runtime = { + ...createPluginRuntime(), + Slot: createSlot(createSolidSlotRegistry>(renderer, {})), + } + onCleanup(registerOpencodeKeymap(keymap, renderer, config)) + return ( + + + + + + + + + + + + + + exits.push(reason)}> + {}}> + + + + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 100, height: 30 }) + const frame = () => app.captureCharFrame() + try { + await wait(() => refs.sync?.data.session_status?.[child.id]?.type === "busy" && frame().includes("General")) + await app.flush() + return { + aborts, + exits, + frame, + async press(sequence: string) { + app.renderer.stdin.emit("data", Buffer.from(sequence)) + // a lone ESC is disambiguated from escape sequences after a short delay + await Bun.sleep(80) + await app.flush() + }, + [Symbol.dispose]() { + app.renderer.destroy() + }, + } + } catch (err) { + app.renderer.destroy() + throw err + } +} + +test("subagent view renders its footer", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + expect(scene.frame()).toContain("General") + expect(scene.frame()).toContain("Parent") +}) + +test("double Esc interrupts only the running subagent", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + await scene.press("\x1b") + expect(scene.frame()).toContain("again to interrupt") + expect(scene.aborts).toHaveLength(0) + await scene.press("\x1b") + await wait(() => scene.aborts.length === 1) + expect(scene.aborts[0]?.searchParams.get("scope")).toBe("session") + expect(scene.frame()).not.toContain("again to interrupt") +}) + +test("exit keys need a second press in the subagent view", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + await scene.press("\x03") + expect(scene.frame()).toContain("again to exit") + expect(scene.exits).toHaveLength(0) + await scene.press("\x03") + expect(scene.exits).toHaveLength(1) +}) From 3b88c2e418a8ba7d089b52e98608659b9f707b63 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Wed, 30 Sep 2026 17:21:33 -0400 Subject: [PATCH 053/353] fix(cli): wait for the TUI worker before sending RPC calls Bun drops worker messages that arrive before the worker installs its handler, which could leave the TUI on a blank screen. The worker now announces it is listening and the thread waits for that signal, failing with an error after 30s. --- packages/opencode/src/cli/cmd/tui.ts | 9 +++++ packages/opencode/src/cli/tui/worker.ts | 2 ++ .../src/kilocode/cli/cmd/tui/worker-ready.ts | 17 ++++++++++ .../kilocode/cli/tui/fixture/slow-worker.ts | 10 ++++++ .../kilocode/cli/tui/worker-ready.test.ts | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+) create mode 100644 packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts create mode 100644 packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts create mode 100644 packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 7ac90e9e10e..a9e08500432 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -25,6 +25,7 @@ import { // kilocode_change end import type { RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change - runtime import deferred import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change +import { WorkerReady } from "@/kilocode/cli/cmd/tui/worker-ready" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -334,6 +335,7 @@ export const TuiThreadCommand = cmd({ console.error("TUI worker error", e.error ?? e.message) } const client = Rpc.client(worker) + const ready = WorkerReady.wait(client) // kilocode_change - subscribe before the worker can signal const reload = () => { client.call("reload", undefined).catch((err) => console.error("TUI worker reload failed", err)) } @@ -417,6 +419,13 @@ export const TuiThreadCommand = cmd({ orphanWatch.unref() // kilocode_change end + // kilocode_change start - calls sent before the worker listens are dropped, leaving a blank TUI + if (!(await ready)) { + UI.error(`TUI worker did not start within ${WorkerReady.Timeout / 1000}s`) + shutdownAndExit({ reason: "worker-timeout", code: 1 }) + return + } + // kilocode_change end const prompt = await input(args.prompt) const { TuiConfig } = await import("@/config/tui") // kilocode_change const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 0e2fcaaef6f..45258df3805 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -15,6 +15,7 @@ import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change +import { WorkerReady } from "@/kilocode/cli/cmd/tui/worker-ready" // kilocode_change ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal @@ -114,3 +115,4 @@ export const rpc = { } Rpc.listen(rpc) +Rpc.emit(WorkerReady.Event, undefined) // kilocode_change - tell the parent it is safe to send RPC calls diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts b/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts new file mode 100644 index 00000000000..e6b3606280a --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts @@ -0,0 +1,17 @@ +// Bun drops worker messages that arrive before the worker installs its handler, so the +// parent must not send RPC calls until the worker announces it is listening. +export namespace WorkerReady { + export const Event = "tui.worker.ready" + export const Timeout = 30_000 + + // Subscribe synchronously right after creating the RPC client so the signal cannot be missed. + export function wait(client: { on(event: string, handler: () => void): () => void }, timeout = Timeout) { + const ready = Promise.withResolvers() + const off = client.on(Event, () => ready.resolve(true)) + const timer = setTimeout(() => ready.resolve(false), timeout) + return ready.promise.finally(() => { + clearTimeout(timer) + off() + }) + } +} diff --git a/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts b/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts new file mode 100644 index 00000000000..31456d0c030 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts @@ -0,0 +1,10 @@ +import { Rpc } from "../../../../../src/util/rpc" +import { WorkerReady } from "../../../../../src/kilocode/cli/cmd/tui/worker-ready" + +// Mimics the TUI worker: slow module startup before the RPC handler is installed. +await Bun.sleep(Number(process.env.KILO_TEST_WORKER_DELAY ?? 200)) + +Rpc.listen({ + ping: () => "pong", +}) +if (!process.env.KILO_TEST_WORKER_SILENT) Rpc.emit(WorkerReady.Event, undefined) diff --git a/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts b/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts new file mode 100644 index 00000000000..efb9978a7fd --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { WorkerReady } from "../../../../src/kilocode/cli/cmd/tui/worker-ready" +import { Rpc } from "../../../../src/util/rpc" +import { withTimeout } from "../../../../src/util/timeout" + +const file = new URL("./fixture/slow-worker.ts", import.meta.url) +const workers: Worker[] = [] + +function spawn(env?: Record) { + const worker = new Worker(file, { env }) + workers.push(worker) + return Rpc.client<{ ping: () => string }>(worker) +} + +afterEach(() => { + for (const worker of workers.splice(0)) worker.terminate() +}) + +describe("TUI worker ready handshake", () => { + test("calls made after the ready signal reach a slow-starting worker", async () => { + const client = spawn() + const ready = WorkerReady.wait(client) + + expect(await ready).toBe(true) + expect(await withTimeout(client.call("ping", undefined), 2_000)).toBe("pong") + }) + + test("resolves false when the worker never signals ready", async () => { + const client = spawn({ KILO_TEST_WORKER_SILENT: "1", KILO_TEST_WORKER_DELAY: "0" }) + + expect(await WorkerReady.wait(client, 300)).toBe(false) + }) +}) From f1e766d339830af0649f3dbe0da67fe73d495ca6 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Wed, 30 Sep 2026 17:22:56 -0400 Subject: [PATCH 054/353] feat(tui): steer running subagents from their view --- .changeset/tui-subagent-steering.md | 5 + .../opencode/src/kilocode/session/steering.ts | 76 +++++ packages/opencode/src/session/prompt.ts | 4 + .../test/kilocode/session-steering.test.ts | 297 ++++++++++++++++++ packages/tui/src/component/prompt/index.tsx | 11 +- packages/tui/src/kilocode/steer.ts | 86 +++++ packages/tui/src/routes/session/index.tsx | 19 +- packages/tui/test/kilocode/steer.test.ts | 58 ++++ 8 files changed, 551 insertions(+), 5 deletions(-) create mode 100644 .changeset/tui-subagent-steering.md create mode 100644 packages/opencode/src/kilocode/session/steering.ts create mode 100644 packages/opencode/test/kilocode/session-steering.test.ts create mode 100644 packages/tui/src/kilocode/steer.ts create mode 100644 packages/tui/test/kilocode/steer.test.ts diff --git a/.changeset/tui-subagent-steering.md b/.changeset/tui-subagent-steering.md new file mode 100644 index 00000000000..5e3e2f17a3a --- /dev/null +++ b/.changeset/tui-subagent-steering.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Steer a running subagent directly from its TUI view. The steer runs as the subagent's own agent and model, and the parent agent is notified over the shared agent board. diff --git a/packages/opencode/src/kilocode/session/steering.ts b/packages/opencode/src/kilocode/session/steering.ts new file mode 100644 index 00000000000..9177665784c --- /dev/null +++ b/packages/opencode/src/kilocode/session/steering.ts @@ -0,0 +1,76 @@ +import { Effect } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import type { Config } from "@/config/config" +import type { RuntimeFlags } from "@/effect/runtime-flags" +import type { SessionID } from "@/session/schema" +import { BoardEnabled } from "@/kilocode/board/enabled" +import { BoardStore } from "@/kilocode/board/store" + +/** + * Metadata kind a client puts on the text part a human typed into a subagent + * view. Only marked parts count as steering: the task tool's own prompts to a + * child session are also non-synthetic text, so `parentID` alone is not proof + * that a human steered the child. + */ +export const KIND = "subagent_steer" + +const PREFIX = "The user steered this subagent directly:\n\n" +// Budget for the JSON-escaped body. The formatted board message adds id, +// timestamp, route, and type (~200 bytes) and must stay under 4 KiB. +const BUDGET = 3584 + +type Part = { type: string; text?: string; synthetic?: boolean; metadata?: Record } + +export function text(parts: ReadonlyArray) { + return parts + .flatMap((part) => + part.type === "text" && !part.synthetic && part.metadata?.kind === KIND && part.text ? [part.text] : [], + ) + .join("\n") + .trim() +} + +/** Excerpt the body so its JSON-escaped form fits the board message budget. */ +export function body(value: string) { + const fit = (bytes: number): string => { + const out = BoardStore.excerpt(PREFIX + value, bytes) + const size = Buffer.byteLength(JSON.stringify(out)) + if (size <= BUDGET || bytes <= 1) return out + return fit(Math.max(1, Math.min(bytes - 1, Math.floor((bytes * BUDGET) / size)))) + } + return fit(BUDGET) +} + +/** + * Post a shared-board message from a steered subagent to its parent so the + * parent learns that a human redirected its child. Failures are logged and + * never fail prompt admission. + */ +export const notify = Effect.fn("KiloSessionSteering.notify")(function* (input: { + session: { id: SessionID; parentID?: SessionID } + parts: ReadonlyArray + messageID: string + config: Config.Interface + flags: RuntimeFlags.Info + database: Database.Interface +}) { + const parent = input.session.parentID + if (!parent) return + const steer = text(input.parts) + if (!steer) return + if (!BoardEnabled.on(yield* input.config.get(), input.flags)) return + yield* BoardStore.post({ + sessionID: input.session.id, + messageID: input.messageID, + to: parent, + type: "INFO", + body: body(steer), + }).pipe( + Effect.provideService(Database.Service, input.database), + Effect.catch((err) => + Effect.logWarning("subagent steering notification failed", { "session.id": input.session.id, err }), + ), + ) +}) + +export * as KiloSessionSteering from "./steering" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index df0ed17ba50..d6de1113cd9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -94,6 +94,7 @@ import { SessionResume } from "@/kilocode/session-resume" // kilocode_change import { SessionResumeImport } from "@/kilocode/session-resume/import" // kilocode_change import { KiloSessionContinuation } from "@/kilocode/session/continuation" // kilocode_change import { KiloSessionControl } from "@/kilocode/session/control" // kilocode_change +import { KiloSessionSteering } from "@/kilocode/session/steering" // kilocode_change import { Goal } from "@/kilocode/session/goal/runner" // kilocode_change import { GoalPolicy } from "@/kilocode/session/goal/policy" // kilocode_change import { GoalState } from "@/kilocode/session/goal/state" // kilocode_change @@ -1487,6 +1488,9 @@ export const layer = Layer.effect( yield* dismiss return message } + // Tell the parent when a human steers this subagent; only a turn that will run counts. + const steer = { session, parts: input.parts, messageID: message.info.id } + yield* KiloSessionSteering.notify({ ...steer, config, flags, database }) // Queue tails and runner fibers can resume outside the HTTP request's // ambient instance context; bridge both Effect refs and legacy ALS. const bridge = yield* EffectBridge.make() diff --git a/packages/opencode/test/kilocode/session-steering.test.ts b/packages/opencode/test/kilocode/session-steering.test.ts new file mode 100644 index 00000000000..2f256e6a173 --- /dev/null +++ b/packages/opencode/test/kilocode/session-steering.test.ts @@ -0,0 +1,297 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { MemoryService } from "@kilocode/kilo-memory/effect/service" +import { BackgroundJob } from "../../src/background/job" +import { LSP } from "../../src/lsp/lsp" +import { MCP } from "../../src/mcp" +import { Plugin } from "../../src/plugin" +import { Session } from "../../src/session/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionSummary } from "../../src/session/summary" +import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" +import { BoardStore } from "../../src/kilocode/board/store" +import { KiloSessionSteering } from "../../src/kilocode/session/steering" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { reply, TestLLMServer } from "../lib/llm-server" + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const plugin = Layer.mock(Plugin.Service)({ + trigger: (_name: string, _input: unknown, output: Output) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, +}) + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + resourceTemplates: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in steering test"), + authenticate: () => Effect.die("unexpected MCP auth in steering test"), + finishAuth: () => Effect.die("unexpected MCP auth in steering test"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const server = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + BackgroundJob.node, + Database.node, + CrossSpawnSpawner.node, + memory, + server, +]) + +const it = testEffect( + LayerNode.compile(root, [ + [SessionSummary.node, summary], + [Plugin.node, plugin], + [LSP.node, lsp], + [MCP.node, mcp], + [KiloSessions.node, KiloSessions.testLayer], + ]), +) + +const cfg = { + model: "test/test-model", + enabled_providers: ["test"], + snapshot: false, + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, + shared_agent_board: true, +} + +function config(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { ...cfg.provider.test.options, baseURL: url }, + }, + }, + } +} + +const steer = (text: string) => ({ type: "text" as const, text, metadata: { kind: KiloSessionSteering.KIND } }) + +test("fits escape-heavy steering text inside the board message budget", () => { + for (const text of ["\u0001".repeat(3000), '"\\'.repeat(2000), "x".repeat(9000)]) { + const body = KiloSessionSteering.body(text) + expect(body.startsWith("The user steered this subagent directly:")).toBe(true) + expect(Buffer.byteLength(JSON.stringify(body))).toBeLessThanOrEqual(3584) + } + expect(KiloSessionSteering.body("short")).toBe("The user steered this subagent directly:\n\nshort") +}) + +test("only counts marked human text as steering", () => { + expect( + KiloSessionSteering.text([ + { type: "text", text: "task prompt from the parent" }, + { type: "text", text: "editor context", synthetic: true, metadata: { kind: KiloSessionSteering.KIND } }, + { type: "file" }, + steer("inspect the parser"), + ]), + ).toBe("inspect the parser") +}) + +it.live("notifies the parent when a human steers a subagent", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Steering main" }) + const child = yield* sessions.create({ parentID: chat.id, title: "Worker" }) + + yield* llm.push(reply().text("ack").stop()) + yield* prompt.prompt({ + sessionID: child.id, + agent: "code", + parts: [steer("steer the worker to inspect the parser")], + }) + + const board = yield* BoardStore.read({ sessionID: chat.id }) + expect(board.messages).toEqual([ + expect.objectContaining({ + from: child.id, + to: "main", + type: "INFO", + body: expect.stringContaining("steer the worker to inspect the parser"), + }), + ]) + }), + { git: true, config }, + ), +) + +it.live("posts escape-heavy steering that exceeds the raw excerpt size", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Escaped steering" }) + const child = yield* sessions.create({ parentID: chat.id, title: "Worker" }) + + yield* llm.push(reply().text("ack").stop()) + yield* prompt.prompt({ sessionID: child.id, agent: "code", parts: [steer('"\\'.repeat(2000))] }) + + expect((yield* BoardStore.read({ sessionID: chat.id })).messages).toHaveLength(1) + }), + { git: true, config }, + ), +) + +it.live("does not notify for unmarked child prompts or noReply steering", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Unmarked" }) + const child = yield* sessions.create({ parentID: chat.id, title: "Worker" }) + + yield* llm.push(reply().text("ack").stop()) + yield* prompt.prompt({ + sessionID: child.id, + agent: "code", + parts: [{ type: "text", text: "task-tool style prompt without a steering mark" }], + }) + yield* prompt.prompt({ sessionID: child.id, agent: "code", noReply: true, parts: [steer("note only")] }) + + expect((yield* BoardStore.read({ sessionID: chat.id })).messages).toEqual([]) + }), + { git: true, config }, + ), +) + +it.live("does not notify the parent when the task tool launches a subagent", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Task launch", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.push( + reply().tool("task", { + description: "inspect parser", + prompt: "worker assignment: inspect the parser edge", + subagent_type: "general", + }), + ) + yield* llm.push(reply().text("worker result").stop()) + yield* llm.push(reply().text("main done").stop()) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "code", + parts: [{ type: "text", text: "delegate the parser inspection" }], + }) + + const children = yield* sessions.children(chat.id) + expect(children).toHaveLength(1) + const child = children.at(0) + if (!child) throw new Error("task tool did not create a child session") + const users = (yield* sessions.messages({ sessionID: child.id })).filter((item) => item.info.role === "user") + expect(JSON.stringify(users)).toContain("worker assignment: inspect the parser edge") + expect((yield* BoardStore.read({ sessionID: chat.id })).messages).toEqual([]) + }), + { git: true, config }, + ), +) + +it.live("does not notify the parent when the main session is prompted", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Main only" }) + + yield* llm.push(reply().text("ack").stop()) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "code", + parts: [steer("work on the main task")], + }) + + const board = yield* BoardStore.read({ sessionID: chat.id }) + expect(board.messages).toEqual([]) + }), + { git: true, config }, + ), +) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 32d9a42e6f3..8615700c27d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -59,6 +59,7 @@ import { slashMatches } from "@/kilocode/cli/cmd/command-display" import { createCostAlertController } from "@/kilocode/cli/cmd/tui/cost-alert" import { MemoryPrompt } from "@/kilocode/cli/cmd/tui/component/memory-prompt" import { GoalPrompt } from "@/kilocode/cli/cmd/tui/component/goal" +import { KiloSteer } from "../../kilocode/steer" // kilocode_change end import { KILO_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../config" @@ -235,8 +236,11 @@ export function Prompt(props: PromptProps) { bumpCursor: () => setCursorVersion((value) => value + 1), cursorVersion: () => cursorVersion(), }) + // Subagent views never interrupt: aborting the child would fail the parent's pending task call. const interruptible = createMemo( - () => running(status().type) || (goal()?.active === true && (!vim.vimEnabled() || vim.vimMode() === "normal")), + () => + !KiloSteer.steering(sync.session.get(props.sessionID ?? "")) && + (running(status().type) || (goal()?.active === true && (!vim.vimEnabled() || vim.vimMode() === "normal"))), ) // kilocode_change end const currentProviderLabel = createMemo(() => local.model.parsed().provider) @@ -1181,6 +1185,7 @@ export function Prompt(props: PromptProps) { ] : [] + const target = sync.session.get(sessionID) // kilocode_change - subagent steering target if (store.mode === "shell") { move.startSubmit() void sdk.client.session.shell({ @@ -1191,6 +1196,7 @@ export function Prompt(props: PromptProps) { modelID: selectedModel.modelID, }, command: inputText, + ...KiloSteer.shell(target), // kilocode_change - run subagent shell as the subagent }) setStore("mode", "normal") } else if ( @@ -1212,6 +1218,7 @@ export function Prompt(props: PromptProps) { agent: local.agent.current()?.name ?? "", // kilocode_change model: `${selectedModel.providerID}/${selectedModel.modelID}`, variant, + ...KiloSteer.command(target), // kilocode_change - run subagent commands as the subagent parts: nonTextParts.filter((x) => x.type === "file"), }).then((result) => GoalPrompt.feedback(command.slice(1), args, result, toast)) // kilocode_change } else { @@ -1224,11 +1231,13 @@ export function Prompt(props: PromptProps) { agent: agent.name, model: selectedModel, variant, + ...KiloSteer.prompt(target), // kilocode_change - steer a subagent with its own agent/model parts: [ ...editorParts, { type: "text", text: inputText, + ...KiloSteer.mark(target), // kilocode_change - mark human steering for the parent notice }, ...nonTextParts, ], diff --git a/packages/tui/src/kilocode/steer.ts b/packages/tui/src/kilocode/steer.ts new file mode 100644 index 00000000000..07e119d42d6 --- /dev/null +++ b/packages/tui/src/kilocode/steer.ts @@ -0,0 +1,86 @@ +// Subagent steering from the TUI. +// +// A subagent view mounts the normal prompt so the user can redirect a running +// child. Every path that sends input to the child (prompt, slash command, shell) +// must use the child's own agent and model: sending the primary selection would +// run the child as the primary agent, and the server persists that choice onto +// the child session. Plain prompts also mark the typed text so the server can +// tell a human steer apart from the parent's own task-tool prompt and notify the +// parent over the shared agent board. +import type { Session } from "@kilocode/sdk/v2" +import { running } from "../util/session" + +// Must match `KIND` in packages/opencode/src/kilocode/session/steering.ts. +export const KIND = "subagent_steer" + +type Target = Pick | undefined +type Ref = { focused: boolean; current: { input: string } } | undefined + +function child(session: Target) { + return session?.parentID ? session : undefined +} + +function pick(session: Target) { + const target = child(session) + if (!target) return undefined + const variant = target.model?.variant + return { + agent: target.agent, + model: target.model, + variant: variant && variant !== "default" ? variant : undefined, + } +} + +/** Whether `session` is a subagent that the TUI prompt would steer. */ +export function steering(session: Target) { + return !!child(session) +} + +/** Subagent views accept input only while the child runs; a finished child's new turn never reaches the parent. */ +export function open(session: Target, status: string | undefined) { + return !child(session) || running(status ?? "idle") +} + +/** Subagent navigation keys yield to the prompt once the user starts typing a steer. */ +export function nav(ref: Ref) { + return !ref?.focused || ref.current.input === "" +} + +/** Metadata that marks the typed text part as a human steer. */ +export function mark(session: Target) { + return child(session) ? { metadata: { kind: KIND } } : {} +} + +/** `session.prompt` overrides that keep the child's agent, model, and variant. */ +export function prompt(session: Target) { + const target = pick(session) + if (!target) return {} + return { + ...(target.agent ? { agent: target.agent } : {}), + ...(target.model ? { model: { providerID: target.model.providerID, modelID: target.model.id } } : {}), + variant: target.variant, + } +} + +/** `session.command` overrides that keep the child's agent, model, and variant. */ +export function command(session: Target) { + const target = pick(session) + if (!target) return {} + return { + ...(target.agent ? { agent: target.agent } : {}), + ...(target.model ? { model: `${target.model.providerID}/${target.model.id}` } : {}), + variant: target.variant, + } +} + +/** `session.shell` overrides that keep the child's agent and model. */ +export function shell(session: Target) { + const target = pick(session) + if (!target) return {} + return { + ...(target.agent ? { agent: target.agent } : {}), + ...(target.model ? { model: { providerID: target.model.providerID, modelID: target.model.id } } : {}), + } +} + +export * as KiloSteer from "./steer" diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9154e337057..d94c432ec73 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -65,6 +65,7 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { ApprovalBadge, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change import { BoardTool } from "../../kilocode/board-tool" // kilocode_change +import { KiloSteer } from "../../kilocode/steer" // kilocode_change import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -159,11 +160,12 @@ const sessionBindingCommands = [ "session.copy", "session.export", "session.child.first", - "session.parent", - "session.child.next", - "session.child.previous", ] as const +// kilocode_change start - subagent navigation keys (bare arrows) yield to a steering prompt with text +const sessionNavBindingCommands = ["session.parent", "session.child.next", "session.child.previous"] as const +// kilocode_change end + const sessionGlobalBindingCommands = [ "session.page.up", "session.page.down", @@ -278,7 +280,7 @@ export function Session() { const blockingSuggestion = createMemo(() => blockingSuggestions()[0]) const visible = createMemo( () => - !session()?.parentID && + KiloSteer.open(session(), sync.data.session_status?.[route.sessionID]?.type) && permissions().length === 0 && blockingQuestions().length === 0 && blockingSuggestions().length === 0 && @@ -1244,6 +1246,15 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) + // kilocode_change start - outrank prompt history on an empty prompt; step aside once a steer is typed + useBindings(() => ({ + mode: KILO_BASE_MODE, + priority: 1, + enabled: () => KiloSteer.nav(prompt), + bindings: tuiConfig.keybinds.gather("session.nav", sessionNavBindingCommands), + })) + // kilocode_change end + useBindings(() => ({ mode: KILO_BASE_MODE, enabled: foregroundTasks().length > 0, diff --git a/packages/tui/test/kilocode/steer.test.ts b/packages/tui/test/kilocode/steer.test.ts new file mode 100644 index 00000000000..023aeb3ac3b --- /dev/null +++ b/packages/tui/test/kilocode/steer.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import { KiloSteer } from "../../src/kilocode/steer" + +const child = { + parentID: "ses_parent", + agent: "explore", + model: { id: "sub-model", providerID: "sub", variant: "high" }, +} +const root = { agent: "code", model: { id: "main-model", providerID: "main" } } + +test("subagent input keeps the child's agent, model, and variant on every path", () => { + expect(KiloSteer.prompt(child)).toEqual({ + agent: "explore", + model: { providerID: "sub", modelID: "sub-model" }, + variant: "high", + }) + expect(KiloSteer.command(child)).toEqual({ agent: "explore", model: "sub/sub-model", variant: "high" }) + expect(KiloSteer.shell(child)).toEqual({ agent: "explore", model: { providerID: "sub", modelID: "sub-model" } }) +}) + +test("primary sessions keep the current selection", () => { + for (const session of [root, undefined]) { + expect(KiloSteer.prompt(session)).toEqual({}) + expect(KiloSteer.command(session)).toEqual({}) + expect(KiloSteer.shell(session)).toEqual({}) + expect(KiloSteer.mark(session)).toEqual({}) + expect(KiloSteer.steering(session)).toBe(false) + } +}) + +test("a default child variant clears the primary variant instead of inheriting it", () => { + const plain = { ...child, model: { ...child.model, variant: "default" } } + expect(KiloSteer.prompt(plain)).toEqual({ + agent: "explore", + model: { providerID: "sub", modelID: "sub-model" }, + variant: undefined, + }) +}) + +test("only subagent text is marked as a human steer", () => { + expect(KiloSteer.mark(child)).toEqual({ metadata: { kind: "subagent_steer" } }) + expect(KiloSteer.steering(child)).toBe(true) +}) + +test("subagent views accept input only while the child is running", () => { + expect(KiloSteer.open(child, "busy")).toBe(true) + expect(KiloSteer.open(child, "retry")).toBe(true) + expect(KiloSteer.open(child, "idle")).toBe(false) + expect(KiloSteer.open(child, undefined)).toBe(false) + expect(KiloSteer.open(root, "idle")).toBe(true) +}) + +test("navigation keys yield only once the focused prompt has text", () => { + expect(KiloSteer.nav(undefined)).toBe(true) + expect(KiloSteer.nav({ focused: false, current: { input: "draft" } })).toBe(true) + expect(KiloSteer.nav({ focused: true, current: { input: "" } })).toBe(true) + expect(KiloSteer.nav({ focused: true, current: { input: "draft" } })).toBe(false) +}) From faa860e2de20f3b70ef1020af6c31c0dae6f542d Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Wed, 30 Sep 2026 17:32:32 -0400 Subject: [PATCH 055/353] feat(cli): show the subagent interrupt shortcut beside the footer navigation The subagent footer shows 'Interrupt esc' next to Parent/Prev/Next while the subagent can be interrupted, turning into 'Interrupt esc again' once armed; it is clickable like the other shortcuts. Narrow footers hide usage while a key hint shows, and the brief exit confirmation takes the interrupt item's place, so the row never wraps. --- packages/tui/src/routes/session/index.tsx | 7 ++- .../src/routes/session/subagent-footer.tsx | 52 ++++++++++++---- .../tui/test/kilocode/subagent-view.test.tsx | 60 ++++++++++++++++--- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 38c4da37151..c33a13ee927 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1510,7 +1510,12 @@ export function Session() { {/* kilocode_change start */} - + contentWidth() < 96} + /> {/* kilocode_change end */} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 2e3479eb5ee..c15d4416d74 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -12,7 +12,12 @@ import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" // kilocode_change start - double-press counters owned by the session route -type Props = { interrupt: Accessor; exitPress: Accessor } +type Props = { + interruptible: Accessor + interrupt: Accessor + exitPress: Accessor + narrow: Accessor +} export function SubagentFooter(props: Props) { // kilocode_change end @@ -81,9 +86,18 @@ export function SubagentFooter(props: Props) { const parentShortcut = useCommandShortcut("session.parent") const previousShortcut = useCommandShortcut("session.child.previous") const nextShortcut = useCommandShortcut("session.child.next") - const interruptShortcut = useCommandShortcut("subagent.interrupt") // kilocode_change - const exitShortcut = useCommandShortcut("app.exit") // kilocode_change - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) + // kilocode_change start - key hints + const interruptShortcut = useCommandShortcut("subagent.interrupt") + const exitShortcut = useCommandShortcut("app.exit") + const interruptKey = createMemo(() => { + const key = interruptShortcut() + return !key || key === "escape" ? "esc" : key + }) + const armed = createMemo(() => props.interrupt() > 0) + // narrow footers drop usage while a key hint is shown so the row does not wrap + const crowded = createMemo(() => props.narrow() && (props.interruptible() || props.exitPress() > 0)) + // kilocode_change end + const [hover, setHover] = createSignal<"interrupt" | "parent" | "prev" | "next" | null>(null) // kilocode_change useTerminalDimensions() return ( @@ -114,23 +128,41 @@ export function SubagentFooter(props: Props) { {/* kilocode_change end */} - + {/* kilocode_change - hide usage while a key hint crowds a narrow footer */} + {(item) => ( {[item().context, item().cost].filter(Boolean).join(" · ")} )} - {/* kilocode_change start - interrupt/exit double-press hints */} - 0}> - {interruptShortcut() || "esc"} again to interrupt - + {/* kilocode_change start - transient exit confirmation */} 0}> - {exitShortcut() || "ctrl+c"} again to exit + + {exitShortcut() || "ctrl+c"} again to exit + {/* kilocode_change end */} + {/* kilocode_change start - interrupt this subagent, alongside the navigation shortcuts; + the brief exit confirmation takes its space so a narrow row never wraps */} + + setHover("interrupt")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("subagent.interrupt")} + backgroundColor={hover() === "interrupt" ? theme.backgroundElement : theme.backgroundPanel} + > + + Interrupt{" "} + + {armed() ? `${interruptKey()} again` : interruptKey()} + + + + + {/* kilocode_change end */} setHover("parent")} onMouseOut={() => setHover(null)} diff --git a/packages/tui/test/kilocode/subagent-view.test.tsx b/packages/tui/test/kilocode/subagent-view.test.tsx index 264383fc99a..6e309093cf9 100644 --- a/packages/tui/test/kilocode/subagent-view.test.tsx +++ b/packages/tui/test/kilocode/subagent-view.test.tsx @@ -52,7 +52,25 @@ const child = { title: "inspect bug (@general subagent)", } -async function mount(root: string) { +const reply = { + info: { + id: "msg_child_reply", + sessionID: child.id, + role: "assistant", + parentID: "msg_child_user", + agent: "general", + mode: "subagent", + providerID: "test", + modelID: "test", + path: { cwd: directory, root: directory }, + cost: 0.0123, + tokens: { input: 12000, output: 3456, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2 }, + }, + parts: [], +} + +async function mount(root: string, width = 100) { await Bun.write(`${root}/kv.json`, JSON.stringify({ animations_enabled: false, sidebar: "hide", vim_enabled: false })) const aborts: URL[] = [] const exits: unknown[] = [] @@ -66,6 +84,7 @@ async function mount(root: string) { return json(true) } if (url.pathname.startsWith("/session/") && url.pathname.endsWith("/children")) return json([child]) + if (url.pathname === `/session/${child.id}/message`) return json([reply]) if (["/message", "/todo", "/diff"].some((suffix) => url.pathname.endsWith(suffix))) return json([]) if (url.pathname.startsWith("/background-process/")) return json(true) return undefined @@ -166,7 +185,7 @@ async function mount(root: string) { ) } - const app = await testRender(() => , { width: 100, height: 30 }) + const app = await testRender(() => , { width, height: 30 }) const frame = () => app.captureCharFrame() try { await wait(() => refs.sync?.data.session_status?.[child.id]?.type === "busy" && frame().includes("General")) @@ -191,23 +210,27 @@ async function mount(root: string) { } } -test("subagent view renders its footer", async () => { +test("running subagent view shows the interrupt shortcut beside the navigation shortcuts", async () => { await using tmp = await tmpdir() using scene = await mount(tmp.path) - expect(scene.frame()).toContain("General") - expect(scene.frame()).toContain("Parent") + const row = + scene + .frame() + .split("\n") + .find((line) => line.includes("General")) ?? "" + expect(row).toMatch(/Interrupt esc\s+Parent up\s+Prev left\s+Next right/) }) test("double Esc interrupts only the running subagent", async () => { await using tmp = await tmpdir() using scene = await mount(tmp.path) await scene.press("\x1b") - expect(scene.frame()).toContain("again to interrupt") + expect(scene.frame()).toContain("Interrupt esc again") expect(scene.aborts).toHaveLength(0) await scene.press("\x1b") await wait(() => scene.aborts.length === 1) expect(scene.aborts[0]?.searchParams.get("scope")).toBe("session") - expect(scene.frame()).not.toContain("again to interrupt") + expect(scene.frame()).not.toContain("esc again") }) test("exit keys need a second press in the subagent view", async () => { @@ -219,3 +242,26 @@ test("exit keys need a second press in the subagent view", async () => { await scene.press("\x03") expect(scene.exits).toHaveLength(1) }) + +for (const width of [80, 120]) { + test(`footer stays on one row at ${width} columns in every key-hint state`, async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path, width) + const rows = () => + scene + .frame() + .split("\n") + .filter((row) => row.includes("┃") && row.trim() !== "┃") + const states = [] as string[][] + states.push(rows()) + await scene.press("\x1b") + states.push(rows()) + await scene.press("\x03") + states.push(rows()) + expect(states.map((item) => item.length)).toEqual([1, 1, 1]) + expect(states[0]?.[0]).toContain("Interrupt esc") + expect(states[1]?.[0]).toContain("Interrupt esc again") + expect(states[2]?.[0]).toContain("again to exit") + for (const item of states) expect(item[0]).toContain("Next right") + }) +} From 2b95bcd3d9a76ba66c7b31ead93300090879ced4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 18:06:39 -0400 Subject: [PATCH 056/353] fix(jetbrains): recognize HTTP 403 as a recoverable MCP auth failure Some remote MCP servers (e.g. anaconda) reject an unauthenticated SSE connection with 403 rather than 401, reaching the plugin as a plain "SSE error: Non-200 status code (403)" failure. The CLI only reports needs_auth for UnauthorizedError or an OAuth-mentioning message, so this fell through to failed and broke sign-in end to end: no post-install sign-in prompt, and MCP Settings showed "failed" with no Sign In action instead of the needs-auth badge. Extend the anchored http/status marker regex from 401-only to 401 or 403 (\b(?:http|status)\D{0,10}40[13]\b), so both transports' unauthenticated rejections normalize to needs_auth while still rejecting unrelated failures that merely embed those digits (ports, durations, server names, 404/500 statuses). Adds parser-level coverage for the exact SSE 403 message plus the existing 401/oauth false-positive guards, and a full-HTTP-path test through KiloAgentBehaviorRpcApiImplTest/MockCliServer proving the CLI response reaches the frontend DTO as needs_auth. --- .../ai/kilocode/backend/cli/KiloCliDataParser.kt | 16 +++++++++++----- .../backend/cli/KiloCliDataParserTest.kt | 15 ++++++++++++--- .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 567e99efb18..77eeb48548e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -144,8 +144,12 @@ object KiloCliDataParser { /** Word-boundary markers, so a hostname or server name that merely contains the text does not match. */ private val MCP_AUTH_FAILURE_WORD_MARKERS = listOf("oauth").map { Regex("\\b${Regex.escape(it)}\\b") } - /** An HTTP 401 response, anchored to `http`/`status` so an unrelated port or ID is not mistaken for it. */ - private val MCP_AUTH_HTTP_401 = Regex("\\b(?:http|status)\\D{0,10}401\\b") + /** + * An HTTP 401/403 response, anchored to `http`/`status` so an unrelated port or ID is not mistaken + * for it. Covers both transports' unauthenticated rejections, e.g. `Error POSTing to endpoint + * (HTTP 401): missing bearer token` and `SSE error: Non-200 status code (403)`. + */ + private val MCP_AUTH_HTTP_STATUS = Regex("\\b(?:http|status)\\D{0,10}40[13]\\b") // ================================================================ // SSE event parsing @@ -904,8 +908,10 @@ object KiloCliDataParser { * * The CLI only reports `needs_auth` when the transport threw `UnauthorizedError` or the message * mentions OAuth (`packages/opencode/src/mcp/index.ts`); a rejected browser flow, a failed token - * exchange, or an HTTP 401 body fall through to `failed`. Those are all recoverable by signing in - * again, so they must still offer sign-in in Settings, Marketplace, and the session prompt. + * exchange, or an HTTP 401/403 rejection (`SSE error: Non-200 status code (403)`) fall through to + * `failed`. Those are all recoverable by signing in again, so they must still offer sign-in in + * Settings, Marketplace, and the session prompt — including the post-install prompt, which only + * fires when this normalization reports `needs_auth`. * * Matched markers are OAuth-specific and word/status-anchored, so an unrelated failure that merely * embeds a port number or a server name containing "oauth" is not reclassified. @@ -914,7 +920,7 @@ object KiloCliDataParser { val value = this?.lowercase() ?: return false if (MCP_AUTH_FAILURE_MARKERS.any { value.contains(it) }) return true if (MCP_AUTH_FAILURE_WORD_MARKERS.any { it.containsMatchIn(value) }) return true - return MCP_AUTH_HTTP_401.containsMatchIn(value) + return MCP_AUTH_HTTP_STATUS.containsMatchIn(value) } private fun removable(obj: JsonObject): Boolean { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 6e19e43647e..e3a06a8acdf 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2307,6 +2307,10 @@ class KiloCliDataParserTest { "Browser authorization was rejected: this request was replaced by another authorization attempt", "Token exchange failed: invalid client", "Error POSTing to endpoint (HTTP 401): missing bearer token", + "Error POSTing to endpoint (HTTP 403): Forbidden", + // The SSE transport's unauthenticated rejection, as reported for anaconda's MCP. + "SSE error: Non-200 status code (403)", + "SSE error: Non-200 status code (401)", "OAuth discovery failed", "Server rejected the request: invalid_grant", "Server rejected the request: invalid_token", @@ -2328,6 +2332,8 @@ class KiloCliDataParserTest { "spawn npx ENOENT", "Invalid MCP URL for \"broken\"", "Error POSTing to endpoint (HTTP 500): server error", + "SSE error: Non-200 status code (404)", + "SSE error: Non-200 status code (500)", ) for (reason in reasons) { @@ -2338,16 +2344,19 @@ class KiloCliDataParserTest { } /** - * The 401/oauth markers are anchored to avoid reclassifying failures that merely embed those - * digits/letters as a substring, e.g. a port number or a server hostname. + * The 401/403/oauth markers are anchored to avoid reclassifying failures that merely embed + * those digits/letters as a substring, e.g. a port number or a server hostname. */ @Test - fun `parseMcpStatus - loose 401 and oauth substrings are not reclassified as auth`() { + fun `parseMcpStatus - loose status-code and oauth substrings are not reclassified as auth`() { val reasons = listOf( "connect ECONNREFUSED 127.0.0.1:40123", + "connect ECONNREFUSED 127.0.0.1:40323", "Invalid MCP URL for \"broken401\"", + "Invalid MCP URL for \"broken403\"", "Invalid MCP URL for \"oauthserver\"", "Failed to connect to myoauthapp.internal", + "Request failed after 403 ms", ) for (reason in reasons) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 5a013bc7103..2c51a8847b3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -376,6 +376,22 @@ class KiloAgentBehaviorRpcApiImplTest { assertEquals("Browser authorization failed: Authorization cancelled", status.error) } + /** + * An unauthenticated SSE rejection (anaconda's MCP responds 403 before OAuth) also reaches the + * plugin as `failed`. It must normalize to `needs_auth` over the full wire path, or the + * post-install sign-in prompt never fires and MCP Settings shows `failed` without Sign In. + */ + @Test + fun `mcp status reports an SSE 403 rejection as needing sign in`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"failed","error":"SSE error: Non-200 status code (403)"}}""" + val rpc = rpc() + + val status = rpc.mcpStatus("/test").single() + + assertEquals("needs_auth", status.status) + assertEquals("SSE error: Non-200 status code (403)", status.error) + } + @Test fun `mcp status keeps unrelated failures failed`() = runBlocking { mock.mcp = """{"anaconda":{"status":"failed","error":"Connection closed"}}""" From 0500ce58a29256dd2eee53104fb6a05fc738666c Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 18:08:56 -0400 Subject: [PATCH 057/353] fix(jetbrains): center navigator balloon left of the prompt ticks The balloon still covered the ticks. The cause is that a pointerless balloon is positioned differently from a pointed one: in BalloonImpl.getUpdatedBounds the requested Balloon.Position and cornerToPointerDistance are consulted only when the pointer is shown, and with the callout off the content box is centered on the target point instead. Anchoring the point to the rail's left edge therefore left half the balloon on top of the rail. Hand the platform the intended center rather than an edge, derived from the built body plus the balloon's own border insets, so the content box ends a gap and a shadow short of the rail. Vertical centering follows the hovered tick, clamped to the visible session, which also replaces the SidePopupGeometry.aim call: its result drives cornerToPointerDistance and so does nothing for a balloon with no pointer. The math lives in PromptRailPlacement so the invariant that matters -- the box never reaches the ticks or the window edge -- is unit tested instead of only observable by eye. --- .../session/ui/rail/PromptRailController.kt | 54 ++++++++----- .../session/ui/rail/PromptRailPlacement.kt | 54 +++++++++++++ .../ui/rail/PromptRailPlacementTest.kt | 80 +++++++++++++++++++ 3 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt index 8a9190e8ac3..d9378a701f5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt @@ -10,7 +10,6 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.popup.SidePopupController import ai.kilocode.client.ui.popup.SidePopupContent -import ai.kilocode.client.ui.popup.SidePopupGeometry import ai.kilocode.client.ui.popup.SidePopupRequest import ai.kilocode.client.ui.popup.SidePopupSpot import ai.kilocode.client.util.UiTimerSource @@ -21,6 +20,7 @@ import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import java.awt.Dimension import java.awt.Point import java.awt.Rectangle import java.awt.event.AdjustmentListener @@ -249,10 +249,14 @@ internal class PromptRailController( } /** - * Anchors the balloon on the left edge of the rail, so the body opens into the transcript and never - * covers the ticks it describes. The body is capped to the room between that edge and the window, and - * its height is budgeted against the visible session: `BalloonImpl.show` silently re-points a balloon - * `ABOVE`/`BELOW` when the requested rectangle does not fit, which would drop it back over the ticks. + * Puts the balloon entirely left of the ticks, so the body opens into the transcript instead of + * covering the rail it was opened from. The body is capped to the room between the rail and the + * window edge, and its height to the visible session. + * + * The point handed back is the balloon's intended center, not an edge: with the callout off the + * platform ignores the position and the pointer distance and centers the box on the target (see + * [PromptRailPlacement]). [SidePopupGeometry] is deliberately not used here — its `aim` result drives + * `cornerToPointerDistance`, which only applies to balloons that draw a pointer. */ private fun place(index: Int, built: SidePopupContent): SidePopupSpot? { val pane = SwingUtilities.getRootPane(rail)?.layeredPane ?: return null @@ -269,28 +273,40 @@ internal class PromptRailController( val chromeHeight = insets.top + insets.bottom + shadow * 2 // Room is measured to the window edge rather than the chat panel: the rail hugs the right side of // a narrow sidebar, where the panel alone would leave almost nothing to open into. - val maxWidth = (rect.x - chromeWidth - gap) - .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) - val maxHeight = (area.height - gap * 2 - chromeHeight) - .coerceIn(0, JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + val maxWidth = PromptRailPlacement.maxWidth( + railX = rect.x, + gap = gap, + chrome = chromeWidth, + cap = JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH), + ) + val maxHeight = PromptRailPlacement.maxHeight( + height = area.height, + gap = gap, + chrome = chromeHeight, + cap = JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), + ) if (maxWidth <= 0 || maxHeight <= 0) return null built.fitWithin(maxWidth, maxHeight) - val view = Rectangle(area.x, area.y + shadow, area.width, (area.height - shadow * 2).coerceAtLeast(0)) - val height = built.component.preferredSize.height + insets.top + insets.bottom + val body = built.component.preferredSize + val content = Dimension( + body.width + insets.left + insets.right, + body.height + insets.top + insets.bottom, + ) val tick = SwingUtilities.convertPoint(rail, Point(0, rail.tickCenterY(index.coerceAtLeast(0))), pane) - val aim = SidePopupGeometry.aim( - view = view, - subject = rect, - y = tick.y, - height = height, + val center = PromptRailPlacement.center( + railX = rect.x, + area = area, gap = gap, - indent = UiStyle.Balloon.arc(), + shadow = shadow, + content = content, + tickY = tick.y, ) return SidePopupSpot( pane = pane, - point = Point(rect.x, aim.y), + point = center, + // Ignored while the callout is off, but kept correct so enabling it would still open left. position = Balloon.Position.atLeft, - distance = aim.distance, + distance = 0, callout = false, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt new file mode 100644 index 00000000000..7454f57aac1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt @@ -0,0 +1,54 @@ +package ai.kilocode.client.session.ui.rail + +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle + +/** + * Geometry for the navigator balloon, which is shown without a callout. + * + * A pointerless balloon is placed differently from a pointed one. `BalloonImpl.getUpdatedBounds` uses + * the requested [com.intellij.openapi.ui.popup.Balloon.Position] and `cornerToPointerDistance` only + * when the pointer is shown; with the callout off it centers the content box on the target point and + * ignores both. So the caller cannot ask for "left of the rail" — it has to hand the platform the + * center that puts the box there, which is what [center] computes. + * + * All values are already-scaled device px, in the layered pane's coordinate space, whose left edge is 0. + */ +internal object PromptRailPlacement { + /** + * Largest body that fits between the pane's left edge and [railX], the left edge of the rail. + * A gap is kept on both sides, so the balloon clears the ticks and the window edge alike. + */ + fun maxWidth(railX: Int, gap: Int, chrome: Int, cap: Int): Int = + (railX - gap * 2 - chrome).coerceIn(0, cap) + + /** Largest body that fits in [height] of visible session, keeping a gap above and below. */ + fun maxHeight(height: Int, gap: Int, chrome: Int, cap: Int): Int = + (height - gap * 2 - chrome).coerceIn(0, cap) + + /** + * Center to hand the platform so the [content] box ends [gap] plus [shadow] left of [railX] — the + * shadow is counted too, so nothing the balloon paints reaches the ticks. Vertically the box follows + * the hovered tick at [tickY], clamped to keep it inside [area], the visible session. + */ + fun center(railX: Int, area: Rectangle, gap: Int, shadow: Int, content: Dimension, tickY: Int): Point { + val x = railX - gap - shadow - content.width / 2 + val min = area.y + gap + shadow + content.height / 2 + val max = area.y + area.height - gap - shadow - content.height / 2 + // A body taller than the room it was budgeted for can only be centered. + val y = if (max < min) area.y + area.height / 2 else tickY.coerceIn(min, max) + return Point(x, y) + } + + /** + * The box the platform lays out around [center] for a [content]-sized pointerless balloon, mirroring + * `BalloonImpl.getUpdatedBounds`. Excludes the shadow, which is added outside this rect. + */ + fun box(center: Point, content: Dimension): Rectangle = Rectangle( + center.x - content.width / 2, + center.y - content.height / 2, + content.width, + content.height, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt new file mode 100644 index 00000000000..84b3b6f5716 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt @@ -0,0 +1,80 @@ +package ai.kilocode.client.session.ui.rail + +import java.awt.Dimension +import java.awt.Rectangle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The navigator balloon has no callout, so the platform centers it on the target point and ignores the + * requested side. These cover the centering math that keeps it off the ticks. + */ +class PromptRailPlacementTest { + private val area = Rectangle(0, 0, 1000, 800) + + @Test + fun `body never reaches the rail or the window edge`() { + val railX = 600 + val gap = 8 + val shadow = 4 + val chrome = 20 + shadow * 2 + + val width = PromptRailPlacement.maxWidth(railX, gap, chrome, cap = 5000) + val content = Dimension(width + 20, 200) + val box = PromptRailPlacement.box( + PromptRailPlacement.center(railX, area, gap, shadow, content, tickY = 400), + content, + ) + + assertTrue("box $box must end before the rail at $railX", box.x + box.width <= railX) + assertTrue("shadow must clear the rail too", box.x + box.width + shadow <= railX) + assertTrue("box $box must stay inside the window", box.x - shadow >= 0) + } + + @Test + fun `width is capped and never negative`() { + assertEquals(100, PromptRailPlacement.maxWidth(railX = 600, gap = 8, chrome = 20, cap = 100)) + assertEquals(564, PromptRailPlacement.maxWidth(railX = 600, gap = 8, chrome = 20, cap = 5000)) + // A rail pinned against the window edge leaves nothing to open into. + assertEquals(0, PromptRailPlacement.maxWidth(railX = 10, gap = 8, chrome = 20, cap = 5000)) + assertEquals(0, PromptRailPlacement.maxHeight(height = 10, gap = 8, chrome = 20, cap = 5000)) + } + + @Test + fun `vertical centre follows the tick but stays inside the session`() { + val content = Dimension(200, 200) + + val middle = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 400) + assertEquals(400, middle.y) + + // Near either edge the box would hang outside the visible session, so the centre is pulled in. + val top = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 0) + assertEquals(112, top.y) + val bottom = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 800) + assertEquals(688, bottom.y) + + assertTrue(PromptRailPlacement.box(top, content).y >= 0) + assertTrue(PromptRailPlacement.box(bottom, content).let { it.y + it.height } <= area.height) + } + + @Test + fun `a body taller than the session is centred`() { + val content = Dimension(200, 900) + + val spot = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 20) + + assertEquals(area.height / 2, spot.y) + } + + @Test + fun `area offset is respected`() { + val offset = Rectangle(0, 300, 1000, 400) + val content = Dimension(200, 200) + + val spot = PromptRailPlacement.center(600, offset, 8, 4, content, tickY = 0) + + assertEquals(412, spot.y) + assertTrue(PromptRailPlacement.box(spot, content).y >= offset.y) + } +} From 7643cb9f0474d45a5b102edaecabd1accedb20ff Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 18:14:05 -0400 Subject: [PATCH 058/353] feat(jetbrains): refresh agent behavior settings panels on focus Add a refreshOnFocus toggle (default true) to SettingsListPanel so every Agent Behavior list page (MCP, Skills, Commands, Agents) and Marketplace/ Rules settings automatically reload when the panel becomes visible again, e.g. navigating away in Settings and back, or reopening the dialog. Wired via a HierarchyListener on SHOWING_CHANGED: the initial mount is ignored (directory-ready/initial load already handles that), and every subsequent re-show triggers reload() when refreshOnFocus() is true. No per-page changes needed since no subclass overrides the default. Adds a regression test that attaches a panel to a host, hides and shows it via removeNotify()/addNotify(), and asserts a reload happens only on the re-show. --- .../client/settings/base/SettingsListPanel.kt | 15 ++++++++++++ .../settings/agents/McpSettingsUiTest.kt | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt index 8b0f1cc7e9f..b6bf7f68a0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt @@ -30,6 +30,7 @@ import com.intellij.ui.DocumentAdapter import com.intellij.ui.SearchTextField import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.BorderLayout +import java.awt.event.HierarchyEvent import java.awt.event.KeyEvent import javax.swing.Icon import javax.swing.JComponent @@ -61,6 +62,7 @@ internal abstract class SettingsListPanel( private var request = 0 private var disposed = false private var pending = false + private var seenShowing = false protected var busy = false private set @@ -71,6 +73,16 @@ internal abstract class SettingsListPanel( view.setEmptyText(emptyText()) setHeader(header()) setContent(view) + seenShowing = isShowing + addHierarchyListener { e -> + if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener + if (!isShowing) return@addHierarchyListener + if (!seenShowing) { + seenShowing = true + return@addHierarchyListener + } + if (!disposed && refreshOnFocus()) reload() + } } @RequiresEdt @@ -161,6 +173,9 @@ internal abstract class SettingsListPanel( protected open fun showRefresh(): Boolean = true + /** Reload the list automatically whenever this panel becomes visible again, e.g. re-navigating into it. */ + protected open fun refreshOnFocus(): Boolean = true + protected open fun afterApply() = Unit private fun header(): JComponent { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index 38d620a3ee7..f9ef60514eb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -43,6 +43,7 @@ import java.awt.event.InputEvent import java.awt.event.MouseEvent import javax.swing.JButton import javax.swing.JComponent +import javax.swing.JPanel import javax.swing.JTextField import javax.swing.SwingUtilities import kotlinx.coroutines.CompletableDeferred @@ -238,6 +239,29 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf(DIR, DIR), agentRpc.mcpCalls) } + fun `test panel reloads automatically when it becomes visible again`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val calls = agentRpc.mcpCalls.size + val host = JPanel() + + edt { + host.add(panel) + host.addNotify() + true + } + try { + edt { host.removeNotify(); true } + assertEquals(calls, agentRpc.mcpCalls.size) + + edt { host.addNotify(); true } + + flushUntil { agentRpc.mcpCalls.size > calls } + } finally { + edt { host.removeNotify(); true } + } + } + fun `test connect action updates runtime status and keeps selection`() { val panel = panel() flushUntil { rows(panel).size == 3 } From 8056232b1f0f565b2927b97d3895fcbd5b8e3ecd Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 30 Sep 2026 18:34:26 -0400 Subject: [PATCH 059/353] fix(jetbrains): hold the navigator still beside the prompt ticks Three problems with the navigator balloon, all visible while hovering the ticks. The gap to the ticks was far too wide. Positioning reserved the drop shadow next to the rail on top of the padding, and the platform default shadow is 24px, so the balloon sat roughly three times further out than intended. Target the border box one standard pad short of the rail and let the translucent shadow fall inside that gap, which gives the balloon the same inset the rail keeps against the scroll pane. The balloon also jumped between ticks, because it was keyed on the hovered entry: every tick tore the old balloon down and built a new one somewhere else. Key it on the rail instead, so travelling down the ticks neither restarts the dwell nor rebuilds it, and anchor it to the centre of the rail rather than the hovered tick. The open tick is no longer cleared when the pointer leaves the band, so the rail stays lit while the pointer is inside the balloon. Selection now follows the pointer in place, scrolling a row into view only when it is not already fully visible. That check reads the viewport's own view rect rather than visibleRect, which is derived by walking up to the window and answers empty until the balloon is realised -- which would make every hover scroll. --- .../client/session/ui/rail/PromptRail.kt | 3 + .../session/ui/rail/PromptRailController.kt | 91 ++++++++++--------- .../session/ui/rail/PromptRailPlacement.kt | 26 ++++-- .../client/session/ui/rail/PromptRailPopup.kt | 44 ++++++--- .../ui/rail/PromptRailPlacementTest.kt | 40 ++++---- .../session/ui/rail/PromptRailPopupTest.kt | 40 ++++++++ 6 files changed, 160 insertions(+), 84 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt index f32a05c1a01..932e0c29070 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt @@ -99,6 +99,9 @@ internal class PromptRail : JComponent() { fun available(): Boolean = available + /** Tick under the pointer, or -1. Read when the dwell elapses, so it reflects the current pointer. */ + fun hover(): Int = hover + /** Center y, in this component's own coordinates, of tick [index] — used to place the popup. */ fun tickCenterY(index: Int): Int { val geo = geometry() ?: return height / 2 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt index d9378a701f5..327653fea8d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt @@ -51,7 +51,9 @@ internal class PromptRailController( private var style = SessionEditorStyle.current() private var dirty = false private var dead = false - private var target: Any? = null + + /** The live balloon body, so a move between ticks can re-select in place instead of reopening it. */ + private var body: PromptRailPopup? = null private val adjustment = AdjustmentListener { recomputeActive() } private val change = ChangeListener { recomputeActive() } private val geometry = object : ComponentAdapter() { @@ -124,9 +126,9 @@ internal class PromptRailController( @RequiresEdt fun hideAll() { + // Disposing the body clears `body` and the open tick through the hook registered in `request`. popup.hideAll() rail.setOpen(-1) - target = null } @RequiresEdt @@ -196,11 +198,15 @@ internal class PromptRailController( private fun select(entry: PromptRailEntry) { when (entry) { is PromptRailEntry.Prompt -> jump(rail.items().getOrNull(entry.index)?.id) + // The overflow tick stands in for prompts with no tick of their own, so its only action is + // revealing them in the list — without waiting out the hover dwell. is PromptRailEntry.Overflow -> { - val index = rail.entries().indexOfFirst { it == entry } - rail.setOpen(index) - target = entry - popup.showNow(entry, this) { request(index) } + rail.setOpen(rail.entries().indexOf(entry)) + if (popup.showing()) { + body?.select(item(entry)) + return + } + popup.showNow(rail, this) { request() } } } } @@ -210,44 +216,47 @@ internal class PromptRailController( scroll.scrollMessageTop(id) } + /** + * The balloon is keyed on the rail rather than on the hovered tick, so travelling down the ticks + * neither restarts the dwell nor rebuilds it somewhere else — only the highlighted row follows the + * pointer. The open tick is left alone on exit so the rail stays lit while the pointer is inside the + * balloon; it is cleared when the balloon actually goes away. + */ private fun hover(entry: PromptRailEntry?) { if (entry == null) { - target?.let(popup::notifyExit) - rail.setOpen(-1) + popup.notifyExit(rail) return } - val index = rail.entries().indexOf(entry) - rail.setOpen(index) - val showing = popup.showing() - target = entry - if (showing) { - popup.hideAll() - target = entry - popup.showNow(entry, this) { request(index) } - return - } - popup.show(entry, this) { request(index) } + rail.setOpen(rail.entries().indexOf(entry)) + popup.show(rail, this) { request() } + body?.select(item(entry)) } - private fun request(hoveredIndex: Int): SidePopupRequest { - val hoveredItemIndex = when (val e = rail.entries().getOrNull(hoveredIndex)) { - is PromptRailEntry.Prompt -> e.index - else -> -1 - } - return SidePopupRequest( - build = { - PromptRailPopup( - items = rail.items(), - hovered = hoveredItemIndex, - onSelect = { item -> jump(item.id) }, - onFirst = { rail.items().firstOrNull()?.let { jump(it.id) } }, - onLatest = { rail.items().lastOrNull()?.let { jump(it.id) } }, - ) - }, - place = { built -> place(hoveredIndex, built) }, - ) + /** Item the list should highlight for [entry]; an overflow tick points at the first prompt it hides. */ + private fun item(entry: PromptRailEntry): Int = when (entry) { + is PromptRailEntry.Prompt -> entry.index + is PromptRailEntry.Overflow -> entry.hidden.first } + private fun request(): SidePopupRequest = SidePopupRequest( + build = { + PromptRailPopup( + items = rail.items(), + hovered = rail.entries().getOrNull(rail.hover())?.let(::item) ?: 0, + onSelect = { item -> jump(item.id) }, + onFirst = { rail.items().firstOrNull()?.let { jump(it.id) } }, + onLatest = { rail.items().lastOrNull()?.let { jump(it.id) } }, + ).also { built -> + body = built + Disposer.register(built.disposable) { + if (body === built) body = null + rail.setOpen(-1) + } + } + }, + place = { built -> place(built) }, + ) + /** * Puts the balloon entirely left of the ticks, so the body opens into the transcript instead of * covering the rail it was opened from. The body is capped to the room between the rail and the @@ -255,10 +264,10 @@ internal class PromptRailController( * * The point handed back is the balloon's intended center, not an edge: with the callout off the * platform ignores the position and the pointer distance and centers the box on the target (see - * [PromptRailPlacement]). [SidePopupGeometry] is deliberately not used here — its `aim` result drives + * [PromptRailPlacement]). `SidePopupGeometry` is deliberately not used here — its `aim` result drives * `cornerToPointerDistance`, which only applies to balloons that draw a pointer. */ - private fun place(index: Int, built: SidePopupContent): SidePopupSpot? { + private fun place(built: SidePopupContent): SidePopupSpot? { val pane = SwingUtilities.getRootPane(rail)?.layeredPane ?: return null if (!rail.isShowing) return null val area = SwingUtilities.convertRectangle(root, root.visibleRect, pane) @@ -292,14 +301,14 @@ internal class PromptRailController( body.width + insets.left + insets.right, body.height + insets.top + insets.bottom, ) - val tick = SwingUtilities.convertPoint(rail, Point(0, rail.tickCenterY(index.coerceAtLeast(0))), pane) val center = PromptRailPlacement.center( railX = rect.x, area = area, gap = gap, - shadow = shadow, content = content, - tickY = tick.y, + // Centred on the rail, not the hovered tick, so the balloon does not drift while the pointer + // moves between ticks. + anchorY = rect.y + rect.height / 2, ) return SidePopupSpot( pane = pane, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt index 7454f57aac1..6d652fe3c8f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt @@ -18,7 +18,10 @@ import java.awt.Rectangle internal object PromptRailPlacement { /** * Largest body that fits between the pane's left edge and [railX], the left edge of the rail. - * A gap is kept on both sides, so the balloon clears the ticks and the window edge alike. + * + * [chrome] must include the drop shadow on both sides even though the shadow is allowed to fall + * inside the gap: the platform runs `ScreenUtil.moveToFit` on the shadowed rect, so a body sized to + * the border box alone would be nudged back to the right, over the ticks. */ fun maxWidth(railX: Int, gap: Int, chrome: Int, cap: Int): Int = (railX - gap * 2 - chrome).coerceIn(0, cap) @@ -28,16 +31,21 @@ internal object PromptRailPlacement { (height - gap * 2 - chrome).coerceIn(0, cap) /** - * Center to hand the platform so the [content] box ends [gap] plus [shadow] left of [railX] — the - * shadow is counted too, so nothing the balloon paints reaches the ticks. Vertically the box follows - * the hovered tick at [tickY], clamped to keep it inside [area], the visible session. + * Center to hand the platform so the [content] box ends exactly [gap] left of [railX], giving the + * balloon the same breathing room against the ticks that the rail itself keeps against the scroll + * pane. The shadow is deliberately not counted here — it is translucent and reaches into that gap, + * and reserving it as well pushed the balloon a full shadow width away from the rail. + * + * [anchorY] is the center the balloon should sit on, clamped to keep the box inside [area], the + * visible session. It is the center of the rail rather than of the hovered tick, so the balloon holds + * still while the pointer travels down the ticks. */ - fun center(railX: Int, area: Rectangle, gap: Int, shadow: Int, content: Dimension, tickY: Int): Point { - val x = railX - gap - shadow - content.width / 2 - val min = area.y + gap + shadow + content.height / 2 - val max = area.y + area.height - gap - shadow - content.height / 2 + fun center(railX: Int, area: Rectangle, gap: Int, content: Dimension, anchorY: Int): Point { + val x = railX - gap - content.width / 2 + val min = area.y + gap + content.height / 2 + val max = area.y + area.height - gap - content.height / 2 // A body taller than the room it was budgeted for can only be centered. - val y = if (max < min) area.y + area.height / 2 else tickY.coerceIn(min, max) + val y = if (max < min) area.y + area.height / 2 else anchorY.coerceIn(min, max) return Point(x, y) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt index 56a561bb5dd..7929354330f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt @@ -21,6 +21,7 @@ import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout import java.awt.Component +import java.awt.Rectangle import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JList @@ -59,6 +60,16 @@ internal class PromptRailPopup( }) } + private val scroll = JBScrollPane( + list, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, + ).apply { + isOpaque = false + viewport.isOpaque = false + border = JBUI.Borders.empty() + } + private val first = HoverIcon().apply { icon = AllIcons.Actions.MoveUp toolTipText = KiloBundle.message("session.prompts.first") @@ -83,11 +94,27 @@ internal class PromptRailPopup( override fun fitWithin(width: Int, height: Int) = body.fitWithin(width, height) + /** The row list, for tests that need to drive layout or inspect selection. */ + internal val rows get() = scroll + init { - if (hovered in 0 until model.size) { - list.selectedIndex = hovered - list.scrollRectToVisible(list.getCellBounds(hovered, hovered)) - } + select(hovered) + } + + /** + * Highlights [index] and scrolls it into view when it is not already fully visible. Called while the + * balloon stays open and the pointer moves between ticks, so it must not resize the body — a change + * in preferred size would make the platform re-place the balloon. + */ + fun select(index: Int) { + if (index !in 0 until model.size) return + if (list.selectedIndex != index) list.selectedIndex = index + val cell = list.getCellBounds(index, index) ?: return + // The viewport's own view rect, not list.visibleRect: the latter is derived by walking up to the + // window, so it answers empty until the balloon is realised and would scroll on every hover. + val view = Rectangle(scroll.viewport.viewPosition, scroll.viewport.extentSize) + if (view.contains(cell)) return + list.scrollRectToVisible(cell) } private fun content(): BorderLayoutPanel { @@ -105,15 +132,6 @@ internal class PromptRailPopup( BorderLayout.EAST, ) } - val scroll = JBScrollPane( - list, - ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, - ).apply { - isOpaque = false - viewport.isOpaque = false - border = JBUI.Borders.empty() - } return BorderLayoutPanel().apply { isOpaque = false add(header, BorderLayout.NORTH) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt index 84b3b6f5716..7592d414274 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt @@ -14,22 +14,24 @@ class PromptRailPlacementTest { private val area = Rectangle(0, 0, 1000, 800) @Test - fun `body never reaches the rail or the window edge`() { + fun `body ends exactly one gap short of the rail`() { val railX = 600 val gap = 8 val shadow = 4 - val chrome = 20 + shadow * 2 + val insets = 20 - val width = PromptRailPlacement.maxWidth(railX, gap, chrome, cap = 5000) - val content = Dimension(width + 20, 200) + val width = PromptRailPlacement.maxWidth(railX, gap, insets + shadow * 2, cap = 5000) + val content = Dimension(width + insets, 200) val box = PromptRailPlacement.box( - PromptRailPlacement.center(railX, area, gap, shadow, content, tickY = 400), + PromptRailPlacement.center(railX, area, gap, content, anchorY = 400), content, ) - assertTrue("box $box must end before the rail at $railX", box.x + box.width <= railX) - assertTrue("shadow must clear the rail too", box.x + box.width + shadow <= railX) - assertTrue("box $box must stay inside the window", box.x - shadow >= 0) + // The same inset the rail keeps against the scroll pane, so the two read as one column. + assertEquals(railX - gap, box.x + box.width) + // The shadow may fall inside that gap, but must not push the balloon outside the window, which + // is what would make the platform nudge it back over the ticks. + assertTrue("box $box with shadow must stay in the window", box.x - shadow >= 0) } @Test @@ -42,18 +44,16 @@ class PromptRailPlacementTest { } @Test - fun `vertical centre follows the tick but stays inside the session`() { + fun `centre holds the anchor but stays inside the session`() { val content = Dimension(200, 200) - val middle = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 400) - assertEquals(400, middle.y) + assertEquals(400, PromptRailPlacement.center(600, area, 8, content, anchorY = 400).y) // Near either edge the box would hang outside the visible session, so the centre is pulled in. - val top = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 0) - assertEquals(112, top.y) - val bottom = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 800) - assertEquals(688, bottom.y) - + val top = PromptRailPlacement.center(600, area, 8, content, anchorY = 0) + val bottom = PromptRailPlacement.center(600, area, 8, content, anchorY = 800) + assertEquals(108, top.y) + assertEquals(692, bottom.y) assertTrue(PromptRailPlacement.box(top, content).y >= 0) assertTrue(PromptRailPlacement.box(bottom, content).let { it.y + it.height } <= area.height) } @@ -62,9 +62,7 @@ class PromptRailPlacementTest { fun `a body taller than the session is centred`() { val content = Dimension(200, 900) - val spot = PromptRailPlacement.center(600, area, 8, 4, content, tickY = 20) - - assertEquals(area.height / 2, spot.y) + assertEquals(area.height / 2, PromptRailPlacement.center(600, area, 8, content, anchorY = 20).y) } @Test @@ -72,9 +70,9 @@ class PromptRailPlacementTest { val offset = Rectangle(0, 300, 1000, 400) val content = Dimension(200, 200) - val spot = PromptRailPlacement.center(600, offset, 8, 4, content, tickY = 0) + val spot = PromptRailPlacement.center(600, offset, 8, content, anchorY = 0) - assertEquals(412, spot.y) + assertEquals(408, spot.y) assertTrue(PromptRailPlacement.box(spot, content).y >= offset.y) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt index 7d34f096ba9..5ade65c257e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt @@ -5,6 +5,8 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBList import java.awt.Component import java.awt.Container +import java.awt.Dimension +import java.awt.Rectangle class PromptRailPopupTest : BasePlatformTestCase() { /** @@ -36,6 +38,44 @@ class PromptRailPopupTest : BasePlatformTestCase() { Disposer.dispose(popup.disposable) } + /** + * Hovering a tick re-selects in the open balloon, so a row below the fold has to be scrolled to. + * Rows already fully visible must not scroll, or the list would jump while the pointer moves. + */ + fun `test selecting an offscreen row scrolls it into view`() { + val popup = popup(items(40)) + popup.fitWithin(320, 220) + val root = popup.component + // scrollRectToVisible re-lays out the scroll pane, so the tree needs real sizes first or the + // viewport collapses to zero and every row counts as off screen. + root.size = Dimension(320, 220) + layoutAll(root) + val port = popup.rows.viewport + val list = find(root) ?: error("expected a JBList in the navigator body") + + popup.select(0) + assertEquals(0, port.viewPosition.y) + + popup.select(35) + + assertEquals(35, list.selectedIndex) + val cell = list.getCellBounds(35, 35) + val view = Rectangle(port.viewPosition, port.extentSize) + assertTrue("row 35 at $cell must be visible in $view", view.contains(cell)) + + // A row already in view must not scroll, or the list would jump as the pointer moves. + val settled = port.viewPosition + popup.select(35) + assertEquals(settled, port.viewPosition) + + Disposer.dispose(popup.disposable) + } + + private fun layoutAll(comp: Component) { + comp.doLayout() + if (comp is Container) comp.components.forEach(::layoutAll) + } + private fun popup(items: List) = PromptRailPopup( items = items, hovered = 0, From 830413669dcf3fedb0bd234981c0e3f9f57e868b Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <260744684+kilo-maintainer[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:26:00 +0000 Subject: [PATCH 060/353] chore(jetbrains): bump CLI pin to v7.8.3 --- packages/kilo-jetbrains/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 3740ddbc848..5b5fa21ce7a 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.8.1", + "version": "7.8.3", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} From 8e99b007e71893ee003915945c006ac4c2ab25e3 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 11:09:08 -0400 Subject: [PATCH 061/353] Revert "fix(cli): wait for the TUI worker before sending RPC calls" This reverts commit 3b88c2e418. The worker ready handshake came from #14700, which was closed without merging, and does not belong in this PR. The changeset no longer mentions the blank-screen fix. --- .changeset/tui-subagent-esc-interrupt.md | 2 +- packages/opencode/src/cli/cmd/tui.ts | 9 ----- packages/opencode/src/cli/tui/worker.ts | 2 -- .../src/kilocode/cli/cmd/tui/worker-ready.ts | 17 ---------- .../kilocode/cli/tui/fixture/slow-worker.ts | 10 ------ .../kilocode/cli/tui/worker-ready.test.ts | 33 ------------------- 6 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts delete mode 100644 packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts delete mode 100644 packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts diff --git a/.changeset/tui-subagent-esc-interrupt.md b/.changeset/tui-subagent-esc-interrupt.md index fe0d4d99828..9ca9487a414 100644 --- a/.changeset/tui-subagent-esc-interrupt.md +++ b/.changeset/tui-subagent-esc-interrupt.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Press Esc twice in a subagent view to stop just that subagent while the parent session keeps working, and press the exit key twice to leave the TUI from a subagent view. Fix a blank screen that could appear when starting the TUI. +Press Esc twice in a subagent view to stop just that subagent while the parent session keeps working, and press the exit key twice to leave the TUI from a subagent view. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a9e08500432..7ac90e9e10e 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -25,7 +25,6 @@ import { // kilocode_change end import type { RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change - runtime import deferred import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change -import { WorkerReady } from "@/kilocode/cli/cmd/tui/worker-ready" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -335,7 +334,6 @@ export const TuiThreadCommand = cmd({ console.error("TUI worker error", e.error ?? e.message) } const client = Rpc.client(worker) - const ready = WorkerReady.wait(client) // kilocode_change - subscribe before the worker can signal const reload = () => { client.call("reload", undefined).catch((err) => console.error("TUI worker reload failed", err)) } @@ -419,13 +417,6 @@ export const TuiThreadCommand = cmd({ orphanWatch.unref() // kilocode_change end - // kilocode_change start - calls sent before the worker listens are dropped, leaving a blank TUI - if (!(await ready)) { - UI.error(`TUI worker did not start within ${WorkerReady.Timeout / 1000}s`) - shutdownAndExit({ reason: "worker-timeout", code: 1 }) - return - } - // kilocode_change end const prompt = await input(args.prompt) const { TuiConfig } = await import("@/config/tui") // kilocode_change const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 45258df3805..0e2fcaaef6f 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -15,7 +15,6 @@ import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change -import { WorkerReady } from "@/kilocode/cli/cmd/tui/worker-ready" // kilocode_change ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal @@ -115,4 +114,3 @@ export const rpc = { } Rpc.listen(rpc) -Rpc.emit(WorkerReady.Event, undefined) // kilocode_change - tell the parent it is safe to send RPC calls diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts b/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts deleted file mode 100644 index e6b3606280a..00000000000 --- a/packages/opencode/src/kilocode/cli/cmd/tui/worker-ready.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Bun drops worker messages that arrive before the worker installs its handler, so the -// parent must not send RPC calls until the worker announces it is listening. -export namespace WorkerReady { - export const Event = "tui.worker.ready" - export const Timeout = 30_000 - - // Subscribe synchronously right after creating the RPC client so the signal cannot be missed. - export function wait(client: { on(event: string, handler: () => void): () => void }, timeout = Timeout) { - const ready = Promise.withResolvers() - const off = client.on(Event, () => ready.resolve(true)) - const timer = setTimeout(() => ready.resolve(false), timeout) - return ready.promise.finally(() => { - clearTimeout(timer) - off() - }) - } -} diff --git a/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts b/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts deleted file mode 100644 index 31456d0c030..00000000000 --- a/packages/opencode/test/kilocode/cli/tui/fixture/slow-worker.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Rpc } from "../../../../../src/util/rpc" -import { WorkerReady } from "../../../../../src/kilocode/cli/cmd/tui/worker-ready" - -// Mimics the TUI worker: slow module startup before the RPC handler is installed. -await Bun.sleep(Number(process.env.KILO_TEST_WORKER_DELAY ?? 200)) - -Rpc.listen({ - ping: () => "pong", -}) -if (!process.env.KILO_TEST_WORKER_SILENT) Rpc.emit(WorkerReady.Event, undefined) diff --git a/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts b/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts deleted file mode 100644 index efb9978a7fd..00000000000 --- a/packages/opencode/test/kilocode/cli/tui/worker-ready.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { WorkerReady } from "../../../../src/kilocode/cli/cmd/tui/worker-ready" -import { Rpc } from "../../../../src/util/rpc" -import { withTimeout } from "../../../../src/util/timeout" - -const file = new URL("./fixture/slow-worker.ts", import.meta.url) -const workers: Worker[] = [] - -function spawn(env?: Record) { - const worker = new Worker(file, { env }) - workers.push(worker) - return Rpc.client<{ ping: () => string }>(worker) -} - -afterEach(() => { - for (const worker of workers.splice(0)) worker.terminate() -}) - -describe("TUI worker ready handshake", () => { - test("calls made after the ready signal reach a slow-starting worker", async () => { - const client = spawn() - const ready = WorkerReady.wait(client) - - expect(await ready).toBe(true) - expect(await withTimeout(client.call("ping", undefined), 2_000)).toBe("pong") - }) - - test("resolves false when the worker never signals ready", async () => { - const client = spawn({ KILO_TEST_WORKER_SILENT: "1", KILO_TEST_WORKER_DELAY: "0" }) - - expect(await WorkerReady.wait(client, 300)).toBe(false) - }) -}) From 6dda4b78e53a63bd499ab8c8eb5213ed17b1dcd1 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 12:32:20 -0400 Subject: [PATCH 062/353] refactor(cli): stop subagents from the TUI like the VS Code task card Double Esc in the subagent view now sends the same tree-scoped abort as VS Code's task-card Stop, so the parent keeps running and receives the existing 'Task cancelled' result. Drops the server-side interruption message; packages/opencode only gains a test pinning that the parent is not aborted. --- .changeset/tui-subagent-esc-interrupt.md | 2 +- packages/opencode/src/kilocode/tool/task.ts | 26 ------------------- packages/opencode/src/tool/task.ts | 5 ---- .../opencode/test/kilocode/tool/task.test.ts | 23 ---------------- packages/opencode/test/session/prompt.test.ts | 13 +++++----- packages/tui/src/routes/session/index.tsx | 6 +++-- .../tui/test/kilocode/subagent-view.test.tsx | 5 ++-- 7 files changed, 14 insertions(+), 66 deletions(-) delete mode 100644 packages/opencode/test/kilocode/tool/task.test.ts diff --git a/.changeset/tui-subagent-esc-interrupt.md b/.changeset/tui-subagent-esc-interrupt.md index 9ca9487a414..d300b150ae4 100644 --- a/.changeset/tui-subagent-esc-interrupt.md +++ b/.changeset/tui-subagent-esc-interrupt.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Press Esc twice in a subagent view to stop just that subagent while the parent session keeps working, and press the exit key twice to leave the TUI from a subagent view. +Press Esc twice in a subagent view to stop that subagent and anything it started while the parent session keeps working, the same as Stop on a VS Code task card. Leaving the TUI from a subagent view now needs a second press of the exit key. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 2d6e7584647..3580c71e40f 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -35,32 +35,6 @@ const ModelState = z .passthrough() export namespace KiloTask { - /** - * Raised by the task tool when the user aborts a foreground subagent's own turn - * (a session-scoped abort from the subagent view). The stop was deliberate, so - * the message tells the parent not to resume on its own: the generic resume hint - * reads as an instruction and models immediately relaunched the subagent. The - * task_id stays available for when the user asks to continue. The task prompt is - * omitted: the parent already has it in its own tool call. - */ - export class Interrupted extends Error { - readonly sessionID: SessionID - readonly activity: string - - constructor(input: { sessionID: SessionID; activity: string }) { - super( - [ - `Interrupted by user: the user deliberately stopped subagent ${input.sessionID} during "${input.activity}".`, - "Do not resume, retry, or replace this subagent on your own. Tell the user it was stopped and wait for their direction.", - `If the user asks to continue it, call the task tool with task_id="${input.sessionID}"; its prior context is preserved.`, - ].join("\n"), - ) - this.name = "TaskInterrupted" - this.sessionID = input.sessionID - this.activity = input.activity - } - } - export const ModelFields = { model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8b2d5696b01..e8e6186f0bb 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -282,11 +282,6 @@ export const TaskTool = Tool.define( // kilocode_change start - expose terminal child assistant errors through the task tool boundary, // including the resumable task_id so the parent agent can continue the subagent (#11620) if (result.info.role === "assistant" && result.info.error) { - // a session-scoped abort (Esc in the subagent view) ends the child's turn with AbortedError - if (MessageV2.AbortedError.isInstance(result.info.error)) - return yield* Effect.fail( - new KiloTask.Interrupted({ sessionID: nextSession.id, activity: params.description }), - ) return yield* Effect.fail(new Error(`${errorMessage(result.info.error)}\n${resumeHint(nextSession.id)}`)) } const failed = result.parts.findLast((item) => item.type === "tool" && item.state.status === "error") diff --git a/packages/opencode/test/kilocode/tool/task.test.ts b/packages/opencode/test/kilocode/tool/task.test.ts deleted file mode 100644 index 12338736985..00000000000 --- a/packages/opencode/test/kilocode/tool/task.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { KiloTask } from "../../../src/kilocode/tool/task" -import { SessionID } from "../../../src/session/schema" - -describe("KiloTask.Interrupted", () => { - const sessionID = SessionID.make("ses_interrupted") - const error = new KiloTask.Interrupted({ sessionID, activity: "inspect bug" }) - - test("names the user interruption, subagent, and activity", () => { - expect(error.name).toBe("TaskInterrupted") - expect(error.sessionID).toBe(sessionID) - expect(error.message).toContain("Interrupted by user") - expect(error.message).toContain(`"inspect bug"`) - }) - - test("tells the parent not to relaunch, but keeps the task_id for a user-requested resume", () => { - expect(error.message).toContain("Do not resume, retry, or replace this subagent on your own") - expect(error.message).toContain("If the user asks to continue it") - expect(error.message).toContain(`task_id="${sessionID}"`) - // the generic resume hint reads as an instruction and made models relaunch immediately - expect(error.message).not.toContain("can be resumed: call the task tool again") - }) -}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 11773d5a644..a8d56e0a403 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1365,9 +1365,9 @@ it.instance( 10_000, ) -// kilocode_change start - Esc in the subagent view aborts only the child (scope=session) +// kilocode_change start - TUI subagent-view Esc and the VS Code task-card Stop both abort the child as a tree it.instance( - "session-scoped child abort reports a resumable user interruption to the parent", + "tree abort of a running subagent leaves the parent running and reports the task as cancelled", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) @@ -1399,13 +1399,14 @@ it.instance( "10 seconds", ) - yield* prompt.cancel(child, "session") + yield* prompt.cancel(child, "tree") const result = yield* awaitWithTimeout( Fiber.join(fiber), "parent did not continue after child abort", "15 seconds", ) + // the parent was not aborted: it finished its step and made its next model call expect(result.parts.some((part) => part.type === "text" && part.text === "parent recovered")).toBe(true) const part = (yield* MessageV2.filterCompactedEffect(chat.id)) .flatMap((msg) => msg.parts) @@ -1413,10 +1414,8 @@ it.instance( (part): part is ErrorToolPart => part.type === "tool" && part.tool === "task" && part.state.status === "error", ) - expect(part?.state.error).toContain("Interrupted by user") - expect(part?.state.error).toContain(`task_id="${child}"`) - expect(part?.state.error).not.toContain("look into the cache key path") - expect(JSON.stringify((yield* llm.hits).at(-1)?.body)).toContain("Interrupted by user") + expect(part?.state.error).toBe("Task cancelled") + expect((yield* status.get(child)).type).toBe("idle") }), 30_000, ) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c33a13ee927..152c5e865b0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -355,10 +355,12 @@ export function Session() { const status = sync.data.session_status?.[route.sessionID] return status ? running(status.type) : false }) + // Same stop as the VS Code task card: this subagent and anything it started. The parent + // keeps running and receives the cancelled task result. function interruptSubagent() { if (!interrupt.press()) return const fail = () => toast.show({ message: "Failed to interrupt subagent", variant: "error" }) - void sdk.client.session.abort({ sessionID: route.sessionID, scope: "session" }).then((res) => { + void sdk.client.session.abort({ sessionID: route.sessionID, scope: "tree" }).then((res) => { if (res.error) fail() }, fail) } @@ -1272,7 +1274,7 @@ export function Session() { bindings: tuiConfig.keybinds.get("session.background"), })) - // kilocode_change start - subagent view: double Esc interrupts only this subagent (scope=session), + // kilocode_change start - subagent view: double Esc stops this subagent (VS Code's task-card Stop), // and the configured exit keys need a second press. `get` (not `gather`) because gather caches by name. useBindings(() => ({ mode: KILO_BASE_MODE, diff --git a/packages/tui/test/kilocode/subagent-view.test.tsx b/packages/tui/test/kilocode/subagent-view.test.tsx index 6e309093cf9..14cc758019e 100644 --- a/packages/tui/test/kilocode/subagent-view.test.tsx +++ b/packages/tui/test/kilocode/subagent-view.test.tsx @@ -221,7 +221,7 @@ test("running subagent view shows the interrupt shortcut beside the navigation s expect(row).toMatch(/Interrupt esc\s+Parent up\s+Prev left\s+Next right/) }) -test("double Esc interrupts only the running subagent", async () => { +test("double Esc stops the running subagent like the VS Code task-card Stop", async () => { await using tmp = await tmpdir() using scene = await mount(tmp.path) await scene.press("\x1b") @@ -229,7 +229,8 @@ test("double Esc interrupts only the running subagent", async () => { expect(scene.aborts).toHaveLength(0) await scene.press("\x1b") await wait(() => scene.aborts.length === 1) - expect(scene.aborts[0]?.searchParams.get("scope")).toBe("session") + expect(scene.aborts[0]?.pathname).toBe(`/session/${child.id}/abort`) + expect(scene.aborts[0]?.searchParams.get("scope")).toBe("tree") expect(scene.frame()).not.toContain("esc again") }) From e9a6365185bd93c740754ea956e9fa578593189e Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 13:08:24 -0400 Subject: [PATCH 063/353] refactor(tui): limit subagent views to plain steering prompts --- .../cli/cmd/tui/component/memory-prompt.tsx | 3 ++ .../tui/src/component/prompt/autocomplete.tsx | 2 + packages/tui/src/component/prompt/index.tsx | 18 ++++--- packages/tui/src/kilocode/steer.ts | 49 ++++--------------- packages/tui/test/kilocode/steer.test.ts | 6 +-- 5 files changed, 27 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/memory-prompt.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/memory-prompt.tsx index 2a202e1c63e..53dfcbacb4f 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/memory-prompt.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/memory-prompt.tsx @@ -19,7 +19,10 @@ export namespace MemoryPrompt { toast: ToastContext dialog: DialogContext done(): void + /** Leave the text alone, e.g. in a subagent view where slash commands are off. */ + skip?: boolean }) { + if (input.skip) return false const handled = await runMemoryCommand({ text: input.text, client: input.client, diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 489e6ae76d9..5c94cf95c2a 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -18,6 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { slashDisplay } from "@/kilocode/cli/cmd/command-display" // kilocode_change +import { KiloSteer } from "../../kilocode/steer" // kilocode_change - subagent views have no slash commands import { createSessionPart, sessionMentionText } from "../../kilocode/session-mentions" // kilocode_change import { DialogSessionMention } from "../../kilocode/dialog-session-mention" // kilocode_change import { useDialog } from "../../ui/dialog" // kilocode_change @@ -682,6 +683,7 @@ export function Autocomplete(props: { })) function show(mode: "@" | "/") { + if (mode === "/" && KiloSteer.steering(sync.session.get(props.sessionID ?? ""))) return // kilocode_change setStore({ visible: mode, index: props.input().cursorOffset, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 8615700c27d..f931b168664 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -236,10 +236,15 @@ export function Prompt(props: PromptProps) { bumpCursor: () => setCursorVersion((value) => value + 1), cursorVersion: () => cursorVersion(), }) - // Subagent views never interrupt: aborting the child would fail the parent's pending task call. + // A subagent view only sends steering prompts: no shell mode, no slash commands, no interrupt. + const steer = createMemo(() => KiloSteer.steering(sync.session.get(props.sessionID ?? ""))) + createEffect(() => { + if (steer() && store.mode === "shell") setStore("mode", "normal") + }) + // Aborting the child from its own view would fail the parent's pending task call. const interruptible = createMemo( () => - !KiloSteer.steering(sync.session.get(props.sessionID ?? "")) && + !steer() && (running(status().type) || (goal()?.active === true && (!vim.vimEnabled() || vim.vimMode() === "normal"))), ) // kilocode_change end @@ -919,6 +924,7 @@ export function Prompt(props: PromptProps) { return ( inputTarget() !== undefined && !props.disabled && + !steer() && // kilocode_change - no shell mode while steering a subagent store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 @@ -1059,7 +1065,7 @@ export function Prompt(props: PromptProps) { if (auto()?.visible) return false if (!store.prompt.input) return false // kilocode_change start - in-memory cost alert command - if (costAlert.handle(store.prompt.input.trim())) return true + if (!steer() && costAlert.handle(store.prompt.input.trim())) return true // kilocode_change end const agent = local.agent.current() if (!agent) return false @@ -1077,6 +1083,7 @@ export function Prompt(props: PromptProps) { sessionID: props.sessionID, toast, dialog, + skip: steer(), done: () => { history.append({ ...store.prompt, @@ -1186,7 +1193,7 @@ export function Prompt(props: PromptProps) { : [] const target = sync.session.get(sessionID) // kilocode_change - subagent steering target - if (store.mode === "shell") { + if (store.mode === "shell" && !steer() /* kilocode_change - subagent views never run shell */) { move.startSubmit() void sdk.client.session.shell({ sessionID, @@ -1196,10 +1203,10 @@ export function Prompt(props: PromptProps) { modelID: selectedModel.modelID, }, command: inputText, - ...KiloSteer.shell(target), // kilocode_change - run subagent shell as the subagent }) setStore("mode", "normal") } else if ( + !steer() && // kilocode_change - subagent views send slash text as a plain steer inputText.startsWith("/") && sync.data.command.some((x) => slashMatches(x, inputText.split("\n")[0].split(" ")[0].slice(1))) // kilocode_change ) { @@ -1218,7 +1225,6 @@ export function Prompt(props: PromptProps) { agent: local.agent.current()?.name ?? "", // kilocode_change model: `${selectedModel.providerID}/${selectedModel.modelID}`, variant, - ...KiloSteer.command(target), // kilocode_change - run subagent commands as the subagent parts: nonTextParts.filter((x) => x.type === "file"), }).then((result) => GoalPrompt.feedback(command.slice(1), args, result, toast)) // kilocode_change } else { diff --git a/packages/tui/src/kilocode/steer.ts b/packages/tui/src/kilocode/steer.ts index 07e119d42d6..81a219a305b 100644 --- a/packages/tui/src/kilocode/steer.ts +++ b/packages/tui/src/kilocode/steer.ts @@ -1,12 +1,12 @@ // Subagent steering from the TUI. // // A subagent view mounts the normal prompt so the user can redirect a running -// child. Every path that sends input to the child (prompt, slash command, shell) -// must use the child's own agent and model: sending the primary selection would -// run the child as the primary agent, and the server persists that choice onto -// the child session. Plain prompts also mark the typed text so the server can -// tell a human steer apart from the parent's own task-tool prompt and notify the -// parent over the shared agent board. +// child with plain steering prompts only: shell mode and slash commands are off +// there. A steer uses the child's own agent and model, since sending the primary +// selection would run the child as the primary agent and the server persists +// that choice onto the child session. The typed text is also marked so the +// server can tell a human steer apart from the parent's own task-tool prompt and +// notify the parent over the shared agent board. import type { Session } from "@kilocode/sdk/v2" import { running } from "../util/session" @@ -20,17 +20,6 @@ function child(session: Target) { return session?.parentID ? session : undefined } -function pick(session: Target) { - const target = child(session) - if (!target) return undefined - const variant = target.model?.variant - return { - agent: target.agent, - model: target.model, - variant: variant && variant !== "default" ? variant : undefined, - } -} - /** Whether `session` is a subagent that the TUI prompt would steer. */ export function steering(session: Target) { return !!child(session) @@ -53,33 +42,13 @@ export function mark(session: Target) { /** `session.prompt` overrides that keep the child's agent, model, and variant. */ export function prompt(session: Target) { - const target = pick(session) - if (!target) return {} - return { - ...(target.agent ? { agent: target.agent } : {}), - ...(target.model ? { model: { providerID: target.model.providerID, modelID: target.model.id } } : {}), - variant: target.variant, - } -} - -/** `session.command` overrides that keep the child's agent, model, and variant. */ -export function command(session: Target) { - const target = pick(session) - if (!target) return {} - return { - ...(target.agent ? { agent: target.agent } : {}), - ...(target.model ? { model: `${target.model.providerID}/${target.model.id}` } : {}), - variant: target.variant, - } -} - -/** `session.shell` overrides that keep the child's agent and model. */ -export function shell(session: Target) { - const target = pick(session) + const target = child(session) if (!target) return {} + const variant = target.model?.variant return { ...(target.agent ? { agent: target.agent } : {}), ...(target.model ? { model: { providerID: target.model.providerID, modelID: target.model.id } } : {}), + variant: variant && variant !== "default" ? variant : undefined, } } diff --git a/packages/tui/test/kilocode/steer.test.ts b/packages/tui/test/kilocode/steer.test.ts index 023aeb3ac3b..2619460ba92 100644 --- a/packages/tui/test/kilocode/steer.test.ts +++ b/packages/tui/test/kilocode/steer.test.ts @@ -8,21 +8,17 @@ const child = { } const root = { agent: "code", model: { id: "main-model", providerID: "main" } } -test("subagent input keeps the child's agent, model, and variant on every path", () => { +test("a steering prompt keeps the child's agent, model, and variant", () => { expect(KiloSteer.prompt(child)).toEqual({ agent: "explore", model: { providerID: "sub", modelID: "sub-model" }, variant: "high", }) - expect(KiloSteer.command(child)).toEqual({ agent: "explore", model: "sub/sub-model", variant: "high" }) - expect(KiloSteer.shell(child)).toEqual({ agent: "explore", model: { providerID: "sub", modelID: "sub-model" } }) }) test("primary sessions keep the current selection", () => { for (const session of [root, undefined]) { expect(KiloSteer.prompt(session)).toEqual({}) - expect(KiloSteer.command(session)).toEqual({}) - expect(KiloSteer.shell(session)).toEqual({}) expect(KiloSteer.mark(session)).toEqual({}) expect(KiloSteer.steering(session)).toBe(false) } From b299b9779abaa3e2951c9141a36b9df46cf2f9c2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 1 Oct 2026 19:28:10 +0200 Subject: [PATCH 064/353] fix(agent-manager): keep pooled worktrees outside the project --- .changeset/agent-manager-pool-home.md | 5 + .../src/agent-manager/WorktreeManager.ts | 121 +++++++-- .../src/agent-manager/project/context.ts | 11 +- .../src/agent-manager/project/wiring.ts | 2 + .../src/agent-manager/worktree-pool.ts | 240 ++++++++++++++---- .../tests/unit/worktree-pool.test.ts | 149 ++++++++++- 6 files changed, 451 insertions(+), 77 deletions(-) create mode 100644 .changeset/agent-manager-pool-home.md diff --git a/.changeset/agent-manager-pool-home.md b/.changeset/agent-manager-pool-home.md new file mode 100644 index 00000000000..49222dc5873 --- /dev/null +++ b/.changeset/agent-manager-pool-home.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep pre-warmed Agent Manager worktrees outside the project folder, so build tools, test runners, and file watchers that scan the project no longer find an extra checkout in `.kilo/worktrees/`. Pre-warmed worktrees left in `.kilo/worktrees/` by earlier versions are removed automatically. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 591d952f0d8..f9058533226 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -16,7 +16,7 @@ import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { markNoIndex } from "../util/spotlight" import { BUDGET, isTimeout } from "./command-budget" -import { WorktreePool, type PoolStart } from "./worktree-pool" +import { WorktreePool, locate, poolDir, sweep, type PoolStart } from "./worktree-pool" import { parsePRUrl, localBranchName, @@ -136,6 +136,12 @@ export class WorktreeManager { private readonly binary: string private readonly log: (msg: string) => void private readonly pool: WorktreePool + /** Per-user pool home shared by every repository, or undefined to keep slots in `.kilo/worktrees/`. */ + private readonly home: string | undefined + /** This repository's slot directory under {@link home}. */ + private readonly shared: string | undefined + /** Resolved directory for new slots, see {@link slotDir}. */ + private place: Promise | undefined /** Deferred git bookkeeping from `detachWorktree`, flushed by `settle()`. */ private readonly pending = new Set>() /** @@ -157,6 +163,7 @@ export class WorktreeManager { ops?: GitOps, binary?: string, poolSize: number | (() => number) = 1, + home?: string, ) { this.root = root this.dir = path.join(root, KILO_DIR, "worktrees") @@ -164,9 +171,12 @@ export class WorktreeManager { this.binary = binary ?? ops?.path ?? "git" this.git = this.client(root) this.log = log + this.home = home + this.shared = home ? poolDir(home, root) : undefined this.pool = new WorktreePool({ root, - dir: this.dir, + dir: () => this.slotDir(), + dirs: this.shared ? [this.shared, this.dir] : [this.dir], poolSize, log, client: (cwd) => this.client(cwd), @@ -197,6 +207,8 @@ export class WorktreeManager { // Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done private static fetchCache = new Map() private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute + // Pool homes already swept by this process, keyed by home and pool state. + private static swept = new Set() private gitAvailable = false private probeFailed = false private lfsAvailable: boolean | undefined @@ -278,19 +290,71 @@ export class WorktreeManager { /** Adopt leftover pooled slots at startup and discard broken ones. */ async reconcilePool(): Promise { await this.ensureMigrated() - // With the pool disabled there is nothing to warm, so never create the - // directory. Leftover pooled slots are still adopted and removed. - if (!this.pool.enabled()) return this.pool.reconcile() - // Exclude before creating anything: a repository that cannot be excluded - // must not leave an untracked `.kilo/worktrees` directory behind. - await this.ensureGitExclude() - await this.ensureDir() - return this.pool.reconcile() + // Only slots in `.kilo/worktrees/` touch the project. With the pool + // disabled or slots in the pool home, never create the directory. + // Leftover pooled slots are still adopted or removed. + const local = this.pool.enabled() && (await this.slotDir()) === this.dir + if (local) { + // Exclude before creating anything: a repository that cannot be excluded + // must not leave an untracked `.kilo/worktrees` directory behind. + await this.ensureGitExclude() + await this.ensureDir() + } + await this.pool.reconcile() + if (!local) await this.withGitLock(() => this.tidy()) + this.sweepPool() } /** Remove idle pooled slots when the feature is turned off. */ async disposePool(): Promise { - return this.pool.dispose() + await this.pool.dispose() + this.sweepPool() + } + + /** + * Directory for new pool slots: this repository's directory in the pool home + * when it is on the same filesystem, otherwise `.kilo/worktrees/`. + */ + private slotDir(): Promise { + if (!this.shared) return Promise.resolve(this.dir) + this.place ??= locate(this.root, this.shared, this.dir, this.log) + return this.place + } + + /** Clean slots of every repository in the pool home, once per process and pool state. */ + private sweepPool(): void { + if (!this.home) return + const enabled = this.pool.enabled() + const key = `${this.home}\0${enabled}` + if (WorktreeManager.swept.has(key)) return + WorktreeManager.swept.add(key) + void sweep(this.home, enabled, (cwd) => this.client(cwd), this.log).catch((err: unknown) => + this.log(`worktree pool: sweep failed: ${err}`), + ) + } + + /** + * Remove a `.kilo/worktrees/` that holds no worktree, for example after the + * slot of an older version was removed, so the project is clean again. + * Runs under the git lock so it cannot race a creation. + */ + private async tidy(): Promise { + // A missing directory is the normal case. + const names = await fs.promises.readdir(this.dir).catch(() => undefined) + if (!names || names.some((name) => name !== ".metadata_never_index")) return + const removed = await fs.promises.rm(this.dir, RM_OPTS).then( + () => true, + (err: unknown) => { + this.log(`tidy: failed to remove ${this.dir}: ${err}`) + return false + }, + ) + if (!removed) return + this.log(`Removed empty ${this.dir}`) + // `.kilo/` usually holds project config, so only an empty one is removed. + await fs.promises.rmdir(path.dirname(this.dir)).catch((err: NodeJS.ErrnoException) => { + if (err.code !== "ENOTEMPTY" && err.code !== "EEXIST") this.log(`tidy: failed to remove ${KILO_DIR}: ${err}`) + }) } private async poolStart(base?: string): Promise { @@ -344,20 +408,33 @@ export class WorktreeManager { // Keep the folder name aligned with the branch, as the normal path does. const target = path.join(this.dir, directory(slot.branch)) - if (target === slot.path || fs.existsSync(target)) { + if (target === slot.path) { this.log(`Reused pooled worktree: ${slot.path} (branch: ${slot.branch})`) return slot } - const moved = await this.git - .raw(["worktree", "move", slot.path, target]) - .then(() => true) - .catch((error: unknown) => { - this.log(`Pooled worktree move failed, keeping ${slot.path}: ${error}`) - return false - }) - const result = moved ? { path: target, branch: slot.branch } : slot - this.log(`Reused pooled worktree: ${result.path} (branch: ${result.branch})`) - return result + const error = fs.existsSync(target) + ? new Error(`${target} already exists`) + : await this.git.raw(["worktree", "move", slot.path, target]).then( + () => undefined, + (err: unknown) => err, + ) + if (!error) { + this.log(`Reused pooled worktree: ${target} (branch: ${slot.branch})`) + return { path: target, branch: slot.branch } + } + // A slot inside `.kilo/worktrees/` can stay where it is. + if (this.isManagedPath(slot.path)) { + this.log(`Pooled worktree move failed, keeping ${slot.path}: ${error}`) + return slot + } + // A slot in the pool home must never become a session worktree. Remove it + // and its new branch, then fall back to a normal `git worktree add`. + this.log(`Pooled worktree move failed, discarding ${slot.path}: ${error}`) + // A device check cannot see every mount layout, for example two bind mounts of one disk. + if (/cross-device/i.test(String(error))) this.place = Promise.resolve(this.dir) + await this.pool.drop(slot.path) + await this.deleteBranch(slot.branch) + return undefined } async renameBranch(worktreePath: string, current: string, requested: string): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/project/context.ts b/packages/kilo-vscode/src/agent-manager/project/context.ts index 4f1b405a4e1..6067ee24a4b 100644 --- a/packages/kilo-vscode/src/agent-manager/project/context.ts +++ b/packages/kilo-vscode/src/agent-manager/project/context.ts @@ -41,6 +41,8 @@ export interface ProjectContextDeps { sized?: (ctx: ProjectContext) => void /** Whether background worktree pre-warming is enabled for this project. */ worktreePool?: () => boolean + /** Per-user directory for pooled slots. When omitted, slots stay in `.kilo/worktrees/`. */ + poolHome?: string /** Factory overrides for tests. */ state?: (root: string, log: (msg: string) => void) => WorktreeStateManager worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager @@ -180,7 +182,14 @@ export class ProjectContext { this.worktrees ??= ( this.deps.worktrees ?? ((root, log, git) => - new WorktreeManager(root, log, git, undefined, () => (this.deps.worktreePool?.() === false ? 0 : 1))) + new WorktreeManager( + root, + log, + git, + undefined, + () => (this.deps.worktreePool?.() === false ? 0 : 1), + this.deps.poolHome, + )) )(this.root, (msg) => this.deps.log(`[WorktreeManager] ${msg}`), this.deps.git) return this.worktrees } diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 1efd0d2eab4..34255fa3ea0 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -13,6 +13,7 @@ import type { ProjectContext, ProjectInitResult } from "./context" import { ProjectContexts, type ProjectSnapshot } from "./contexts" import type { ProjectMessageDeps } from "./messages" import { createSettingsHandler, type SettingsHandler } from "./settings" +import { poolHome } from "../worktree-pool" export interface ProjectWiring { registry: ProjectRegistry @@ -66,6 +67,7 @@ export function createProjectWiring(opts: { log: opts.output, git: opts.git, worktreePool: () => opts.host.worktreePool(), + poolHome: poolHome(), sized: (ctx) => opts.pushState(ctx), }, }) diff --git a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts index 2ac76c82d68..c3cdb7d95f1 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts @@ -3,22 +3,166 @@ * sessions can claim a ready worktree instead of paying the full * `git worktree add` checkout cost. * - * Slots are created at their final path under `.kilo/worktrees/` and tagged - * with pooled metadata. A later claim turns a - * slot into a named branch with a cheap ref update (exact match) or a bounded - * checkout (small delta). This module is vscode-free so it can be tested with a - * real temporary git repository. + * Slots are tagged with pooled metadata. A later claim turns a slot into a + * named branch with a cheap ref update (exact match) or a bounded checkout + * (small delta). This module is vscode-free so it can be tested with a real + * temporary git repository. + * + * Slots live in a per-user directory outside the project when possible. A slot + * is a full checkout, and inside the project every tool that walks the tree + * sees it: `conda-build .` finds a second recipe, test runners collect + * duplicate tests, file watchers and language servers index a copy. Ignore + * rules do not help, because these tools do not read them. The claim moves the + * slot into `.kilo/worktrees/`. `git worktree move` is a plain rename, so the + * per-user directory is only used on the same filesystem as the project. */ import * as path from "path" import * as fs from "fs" +import * as os from "os" +import { createHash } from "crypto" import type { SimpleGit } from "simple-git" -import { generateBranchName } from "./branch-name" +import { generateBranchName, sanitizeBranchName } from "./branch-name" import { normalizePath, parseWorktreeList } from "./git-import" +import { pathKey } from "./project/paths" +import { markNoIndex } from "../util/spotlight" const METADATA_FILE = "kilo-agent-manager-metadata.json" /** Maximum commits between a slot base and the requested base for a delta claim. */ const MAX_DELTA = 50 +/** A slot that no live process owns and that nothing touched for this long is removed by {@link sweep}. */ +const STALE = 14 * 24 * 60 * 60 * 1000 + +/** Per-user pool home next to the Kilo CLI data. Honors `XDG_DATA_HOME` and is never roaming on Windows. */ +export function poolHome(): string { + const data = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share") + return path.join(data, "kilo", "worktree-pool") +} + +/** Stable slot directory for one repository root. The readable prefix helps users who inspect disk usage. */ +export function poolDir(home: string, root: string): string { + const slug = sanitizeBranchName(path.basename(root), 32) || "repo" + const hash = createHash("sha256").update(pathKey(root)).digest("hex").slice(0, 12) + return path.join(home, `${slug}-${hash}`) +} + +/** + * Select the directory for new slots: `shared` when a rename from it into the + * project can work, otherwise `local`. Creates only the pool home, never + * anything in the project. + */ +export async function locate(root: string, shared: string, local: string, log: (msg: string) => void): Promise { + const home = path.dirname(shared) + const same = await fs.promises + .mkdir(home, { recursive: true }) + .then(() => Promise.all([fs.promises.stat(root), fs.promises.stat(home)])) + .then(([a, b]) => a.dev === b.dev) + .catch((e) => { + log(`worktree pool: cannot use ${home}: ${e}`) + return false + }) + if (!same) { + log(`worktree pool: ${home} is on another filesystem than ${root}, keeping slots in ${local}`) + return local + } + await markNoIndex(home, log) + return shared +} + +/** + * Remove slots that no Kilo process can claim, across every repository in the + * pool home. A slot whose repository is gone (deleted, moved, or pruned) is + * removed at once. Other slots are removed when no live process owns them and + * nothing touched them for {@link STALE}, or at once when the pool is disabled. + * Without this, a deleted project leaves a full checkout behind in a directory + * the user never sees. + */ +export async function sweep( + home: string, + enabled: boolean, + client: (cwd: string) => SimpleGit, + log: (msg: string) => void, +): Promise { + const age = enabled ? STALE : 0 + for (const project of await subdirs(home)) { + const dir = path.join(home, project) + for (const name of await subdirs(dir)) await reap(path.join(dir, name), age, client, log) + await fs.promises.rmdir(dir).catch((e: NodeJS.ErrnoException) => { + if (e.code !== "ENOTEMPTY" && e.code !== "EEXIST") log(`worktree pool: remove ${dir}: ${e}`) + }) + } +} + +async function reap(slot: string, age: number, client: (cwd: string) => SimpleGit, log: (msg: string) => void) { + const gitdir = await linked(slot) + // git creates the registration before it writes the `.git` file, so a + // missing target means git no longer tracks this slot. + if (gitdir && !fs.existsSync(gitdir)) { + log(`worktree pool: removing slot of a missing repository ${slot}`) + return purge(slot, log) + } + const file = gitdir ? path.join(gitdir, METADATA_FILE) : undefined + const meta = file ? await readMeta(file, log) : undefined + if (meta?.pooled && alive(meta.owner)) return + // A slot without metadata can still be in creation by another process. + const wait = meta?.pooled ? age : Math.max(age, 60 * 60 * 1000) + // Metadata is rewritten whenever a process adopts or retargets the slot. + const stamp = await fs.promises + .stat(meta && file ? file : slot) + .then((stat) => stat.mtimeMs) + .catch(() => undefined) + if (stamp === undefined || Date.now() - stamp < wait) return + log(`worktree pool: removing unused slot ${slot}`) + if (gitdir) { + // simple-git throws at once when the directory vanished meanwhile. + await Promise.resolve() + .then(() => client(slot).raw(["worktree", "remove", "--force", "--force", slot])) + .catch((e) => log(`worktree pool: remove ${slot}: ${e}`)) + } + if (fs.existsSync(slot)) await purge(slot, log) +} + +async function purge(dir: string, log: (msg: string) => void): Promise { + await fs.promises.rm(dir, { recursive: true, force: true }).catch((e) => log(`worktree pool: rm ${dir}: ${e}`)) +} + +async function subdirs(dir: string): Promise { + if (!fs.existsSync(dir)) return [] + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) +} + +/** Git directory a linked worktree points at, or undefined when it has no `.git` file. */ +async function linked(dir: string): Promise { + const content = await fs.promises.readFile(path.join(dir, ".git"), "utf-8").catch(() => undefined) + const match = content?.match(/^gitdir:\s*(.+)$/m) + return match ? path.resolve(dir, match[1].trim()) : undefined +} + +async function readMeta(file: string, log: (msg: string) => void): Promise { + // A missing file is the normal case for a non-pooled worktree, so stay quiet. + const content = await fs.promises.readFile(file, "utf-8").catch((e: NodeJS.ErrnoException) => { + if (e.code !== "ENOENT") log(`worktree pool: read metadata ${file}: ${e}`) + return undefined + }) + if (content === undefined) return undefined + return await Promise.resolve() + .then(() => JSON.parse(content) as PoolMeta) + .catch((e) => { + log(`worktree pool: parse metadata ${file}: ${e}`) + return undefined + }) +} + +function alive(pid: number | undefined): boolean { + if (pid === undefined) return false + try { + process.kill(pid, 0) + return true + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM" + } +} export interface PoolStart { ref: string @@ -28,7 +172,10 @@ export interface PoolStart { export interface PoolDeps { root: string - dir: string + /** Directory for new slots. Resolved on first use, because it depends on the filesystem layout. */ + dir: () => Promise + /** Every directory that can hold slots of this repository, including the one `dir` resolves to. */ + dirs: string[] /** Target slot count. A function is read live so a settings change applies without a restart. */ poolSize: number | (() => number) log: (msg: string) => void @@ -146,27 +293,36 @@ export class WorktreePool { this.slots = this.slots.filter((slot) => normalizePath(slot.path) !== normalizePath(wtPath)) } + /** Remove a claimed slot that could not be moved into place. Runs while the caller holds the git lock. */ + async drop(wtPath: string): Promise { + this.release(wtPath) + await this.removePath(wtPath) + } + private async fill(point: PoolStart, oid: string): Promise { if (this.size() <= 0) return - await fs.promises.mkdir(this.deps.dir, { recursive: true }) + const dir = await this.deps.dir() + await fs.promises.mkdir(dir, { recursive: true }) await this.prune() await this.retarget(point, oid) const missing = this.size() - this.slots.length if (missing <= 0) return - const names = await this.dirNames() + // A claim can reuse the slot name for the branch and its folder in + // `.kilo/worktrees/`, so avoid names that are taken in any slot directory. + const names = (await Promise.all(this.deps.dirs.map(subdirs))).flat() for (let i = 0; i < missing; i++) { - const slot = await this.build(point, oid, names) + const slot = await this.build(point, oid, dir, names) if (!slot) continue names.push(path.basename(slot.path)) this.slots.push(slot) } } - private async build(point: PoolStart, oid: string, names: string[]): Promise { + private async build(point: PoolStart, oid: string, dir: string, names: string[]): Promise { const name = generateBranchName("pool", names) - const slotPath = path.join(this.deps.dir, name) + const slotPath = path.join(dir, name) const ok = await this.attempt(async () => { await this.raw(["worktree", "add", "--detach", slotPath, oid]) await this.writeMeta(slotPath, { pooled: true, owner: process.pid, baseRef: point.ref, baseOid: oid }) @@ -264,19 +420,28 @@ export class WorktreePool { return ok === true } + /** + * Adopt slots in the directory for new slots. Slots in other directories, + * for example `.kilo/worktrees/` slots from an older version, are removed. + */ private async adopt(): Promise { - if (!fs.existsSync(this.deps.dir)) return + const active = this.size() > 0 ? await this.deps.dir() : undefined + for (const dir of this.deps.dirs) await this.collect(dir, dir === active) + } + + private async collect(dir: string, keep: boolean): Promise { + if (!fs.existsSync(dir)) return const known = new Set(this.slots.map((slot) => normalizePath(slot.path))) - const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".kilo-delete-")) continue - const slotPath = path.join(this.deps.dir, entry.name) + const slotPath = path.join(dir, entry.name) if (known.has(normalizePath(slotPath))) continue const meta = await this.readMeta(slotPath) if (!meta?.pooled) continue - if (meta.owner !== process.pid && this.alive(meta.owner)) continue + if (meta.owner !== process.pid && alive(meta.owner)) continue // Turning the feature off must clean slots owned by this or a dead process. - if (this.size() <= 0) { + if (!keep) { await this.removePath(slotPath) continue } @@ -328,15 +493,15 @@ export class WorktreePool { } private async prune(): Promise { - const alive: PoolSlot[] = [] + const kept: PoolSlot[] = [] for (const slot of this.slots) { if (await this.registered(slot.path)) { - alive.push(slot) + kept.push(slot) continue } await this.removePath(slot.path) } - this.slots = alive + this.slots = kept } private async registered(wtPath: string): Promise { @@ -382,12 +547,6 @@ export class WorktreePool { }) } - private async dirNames(): Promise { - if (!fs.existsSync(this.deps.dir)) return [] - const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) - return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) - } - private raw(args: string[]): Promise { return this.deps.client(this.deps.root).raw(args) } @@ -410,33 +569,8 @@ export class WorktreePool { } private async readMeta(wtPath: string): Promise { - return this.readMetaFile(await this.metaPath(wtPath)) - } - - private async readMetaFile(file: string | undefined): Promise { - if (!file) return undefined - // A missing file is the normal case for a non-pooled worktree, so stay quiet. - const content = await fs.promises.readFile(file, "utf-8").catch((e: NodeJS.ErrnoException) => { - if (e.code !== "ENOENT") this.deps.log(`worktree pool: read metadata ${file}: ${e}`) - return undefined - }) - if (content === undefined) return undefined - return await Promise.resolve() - .then(() => JSON.parse(content) as PoolMeta) - .catch((e) => { - this.deps.log(`worktree pool: parse metadata ${file}: ${e}`) - return undefined - }) - } - - private alive(pid: number | undefined): boolean { - if (pid === undefined) return false - try { - process.kill(pid, 0) - return true - } catch (e) { - return (e as NodeJS.ErrnoException).code === "EPERM" - } + const file = await this.metaPath(wtPath) + return file ? readMeta(file, this.deps.log) : undefined } private async attempt(fn: () => Promise, label: string): Promise { diff --git a/packages/kilo-vscode/tests/unit/worktree-pool.test.ts b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts index 2705f29f62f..485a781f76a 100644 --- a/packages/kilo-vscode/tests/unit/worktree-pool.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises" import { existsSync } from "node:fs" import simpleGit from "simple-git" import { WorktreeManager } from "../../src/agent-manager/WorktreeManager" +import { locate, sweep } from "../../src/agent-manager/worktree-pool" const tempDirs: string[] = [] @@ -36,18 +37,29 @@ async function createTempRepo(): Promise { return dir } -function createManager(root: string, poolSize = 1, rewarmDelay = 0, logs?: string[]): WorktreeManager { +function createManager(root: string, poolSize = 1, rewarmDelay = 0, logs?: string[], home?: string): WorktreeManager { const manager = new WorktreeManager( root, logs ? (msg) => logs.push(msg) : () => undefined, undefined, undefined, poolSize, + home, ) manager.rewarmDelay = rewarmDelay return manager } +async function createHome(): Promise { + const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-pool-home-"))) + tempDirs.push(dir) + return path.join(dir, "worktree-pool") +} + +async function clean(root: string): Promise { + return (await simpleGit(root).raw(["status", "--porcelain", "--untracked-files=all"])).trim() +} + async function pooledSlots(root: string): Promise { const raw = await simpleGit(root).raw(["worktree", "list", "--porcelain"]) const slots: string[] = [] @@ -330,6 +342,141 @@ describe("WorktreeManager pool disabled", () => { }) }) +describe("WorktreeManager pool home", () => { + it("warms slots in the pool home without touching the project", async () => { + const root = await createTempRepo() + const home = await createHome() + const manager = createManager(root, 1, 0, undefined, home) + + await manager.reconcilePool() + manager.warmPool() + const slot = await waitForPooledSlot(root) + + expect(slot.startsWith(home + path.sep)).toBe(true) + expect(existsSync(path.join(root, ".kilo"))).toBe(false) + expect(await clean(root)).toBe("") + expect(await manager.discoverWorktrees()).toEqual([]) + }) + + it("moves a claimed slot into .kilo/worktrees", async () => { + const root = await createTempRepo() + const home = await createHome() + const manager = createManager(root, 1, 60_000, undefined, home) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + const result = await manager.createWorktree({}) + + expect(result.branch).toBe(path.basename(slot)) + expect(result.path).toBe(path.join(root, ".kilo", "worktrees", result.branch)) + expect(existsSync(slot)).toBe(false) + expect((await simpleGit(result.path).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe(result.branch) + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + expect(await clean(root)).toBe("") + }) + + it("discards a slot that cannot be moved and creates the worktree normally", async () => { + const root = await createTempRepo() + const home = await createHome() + const logs: string[] = [] + const manager = createManager(root, 1, 60_000, logs, home) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + const name = path.basename(slot) + const blocked = path.join(root, ".kilo", "worktrees", name) + await fs.mkdir(blocked, { recursive: true }) + + const result = await manager.createWorktree({}) + + expect(result.path).toBe(path.join(root, ".kilo", "worktrees", result.branch)) + expect(result.branch).not.toBe(name) + expect(existsSync(slot)).toBe(false) + expect(await pooledSlots(root)).toEqual([]) + expect(await simpleGit(root).raw(["branch", "--list", name])).toBe("") + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + expect(logs.some((line) => line.includes("discarding"))).toBe(true) + }) + + it("removes slots an older version left in .kilo/worktrees", async () => { + const root = await createTempRepo() + const home = await createHome() + createManager(root).warmPool() + const legacy = await waitForPooledSlot(root) + expect(legacy.includes(`${path.sep}.kilo${path.sep}worktrees${path.sep}`)).toBe(true) + + const manager = createManager(root, 1, 0, undefined, home) + await manager.reconcilePool() + + expect(existsSync(legacy)).toBe(false) + expect(existsSync(path.join(root, ".kilo"))).toBe(false) + expect(await pooledSlots(root)).toEqual([]) + + manager.warmPool() + expect((await waitForPooledSlot(root)).startsWith(home + path.sep)).toBe(true) + }) + + it("removes pool home slots when the pool is disabled", async () => { + const root = await createTempRepo() + const home = await createHome() + createManager(root, 1, 0, undefined, home).warmPool() + const slot = await waitForPooledSlot(root) + + await createManager(root, 0, 0, undefined, home).reconcilePool() + + expect(existsSync(slot)).toBe(false) + expect(await pooledSlots(root)).toEqual([]) + expect(existsSync(path.join(root, ".kilo"))).toBe(false) + }) + + it("sweeps slots of deleted repositories and unused slots", async () => { + const home = await createHome() + const gone = await createTempRepo() + createManager(gone, 1, 0, undefined, home).warmPool() + const orphan = await waitForPooledSlot(gone) + await fs.rm(gone, { recursive: true, force: true }) + + const root = await createTempRepo() + createManager(root, 1, 0, undefined, home).warmPool() + const unused = await waitForPooledSlot(root) + const pointer = await fs.readFile(path.join(unused, ".git"), "utf-8") + const file = path.join(pointer.match(/^gitdir:\s*(.+)$/m)![1]!.trim(), "kilo-agent-manager-metadata.json") + const meta = JSON.parse(await fs.readFile(file, "utf-8")) as Record + await fs.writeFile(file, JSON.stringify({ ...meta, owner: 999999 })) + + await sweep( + home, + true, + (cwd) => simpleGit(cwd), + () => undefined, + ) + expect(existsSync(orphan)).toBe(false) + expect(existsSync(path.dirname(orphan))).toBe(false) + // A recently used slot survives even when its owner is gone. + expect(existsSync(unused)).toBe(true) + + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + await fs.utimes(file, old, old) + await sweep( + home, + true, + (cwd) => simpleGit(cwd), + () => undefined, + ) + expect(existsSync(unused)).toBe(false) + expect(await pooledSlots(root)).toEqual([]) + }) + + it.skipIf(process.platform === "win32")("keeps slots in .kilo/worktrees on another filesystem", async () => { + const home = await createHome() + const local = path.join("/dev", ".kilo", "worktrees") + expect(await locate("/dev", path.join(home, "dev"), local, () => undefined)).toBe(local) + expect(await locate(path.dirname(home), path.join(home, "repo"), "local", () => undefined)).toBe( + path.join(home, "repo"), + ) + }) +}) + describe("WorktreeManager commit detection", () => { it("reports an empty repository through the commit check", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-pool-empty-")) From 71c42fb56d872b7e7e4b3abe60d6fcbd079122e3 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 13:33:37 -0400 Subject: [PATCH 065/353] fix(tui): hide the interrupt hint in subagent views --- packages/tui/src/component/prompt/index.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f931b168664..6806e9c20af 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1786,12 +1786,16 @@ export function Prompt(props: PromptProps) { })()} - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - + {/* kilocode_change start - subagent views cannot interrupt, so hide the hint */} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + + + {/* kilocode_change end */} From 54eacd5ff640c3ef67e27f1dba7c94c332f313e2 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 1 Oct 2026 19:46:15 +0200 Subject: [PATCH 066/353] fix(cli): finalize reasoning before stream retries --- .changeset/retry-thinking-indicator.md | 5 ++ packages/opencode/src/session/processor.ts | 1 + ...rocessor-incomplete-response-retry.test.ts | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 .changeset/retry-thinking-indicator.md diff --git a/.changeset/retry-thinking-indicator.md b/.changeset/retry-thinking-indicator.md new file mode 100644 index 00000000000..3a6174d7e61 --- /dev/null +++ b/.changeset/retry-thinking-indicator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent thinking indicators from remaining active after a model response is retried. diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index ffd2e2e52cf..651d60d3661 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1013,6 +1013,7 @@ const layer = Layer.effect( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), ), + Effect.tapError(() => Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)), // kilocode_change - close failed-attempt reasoning before retry resets tracking Effect.retry( SessionRetry.policy({ provider: input.model.providerID, diff --git a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts index f8c9ab75906..68a12cf8ec3 100644 --- a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts +++ b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts @@ -17,6 +17,7 @@ import { Image } from "../../src/image/image" import { KiloSessionProcessor } from "../../src/kilocode/session/processor" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" +import { ProviderError } from "../../src/provider/error" import type { Provider } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -574,6 +575,82 @@ describe("session processor incomplete response retry", () => { ), ) + it.live("finishes opaque reasoning from dropped attempts before each retry", () => + provideTmpdirProject( + (dir) => + Effect.gen(function* () { + const ctx = yield* setup(dir) + const db = yield* Database.Service + const seen: { reasoning: number; open: number; completed: boolean }[] = [] + // Snapshot persisted state as each scripted attempt starts streaming. + const snap = Stream.fromEffectDrain( + Effect.gen(function* () { + const parts = yield* MessageV2.parts(ctx.msg.id) + const info = (yield* MessageV2.get({ sessionID: ctx.msg.sessionID, messageID: ctx.msg.id })).info + const reasoning = parts.filter((part) => part.type === "reasoning") + seen.push({ + reasoning: reasoning.length, + open: reasoning.filter((part) => part.time.end === undefined).length, + completed: info.role === "assistant" && info.time.completed !== undefined, + }) + }).pipe(Effect.provideService(Database.Service, db), Effect.orDie), + ) + const drop = (item: string) => + snap.pipe( + Stream.concat( + Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: item } } }), + ), + ), + Stream.concat(Stream.fail(new ProviderError.ResponseStreamError("stream dropped"))), + ) + yield* ctx.test.push(drop("rs_1")) + yield* ctx.test.push(drop("rs_2")) + yield* ctx.test.push( + snap.pipe( + Stream.concat( + Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.reasoningStart({ id: "reasoning" }), + LLMEvent.reasoningDelta({ id: "reasoning", text: "Thought" }), + LLMEvent.reasoningEnd({ id: "reasoning" }), + ...success().slice(1), + ), + ), + ), + ) + const delay = spyOn(SessionRetry, "delay").mockReturnValue(0) + + try { + expect(yield* ctx.handle.process(ctx.input)).toBe("continue") + } finally { + delay.mockRestore() + } + + expect(yield* ctx.test.calls).toBe(3) + expect(seen).toEqual([ + { reasoning: 0, open: 0, completed: false }, + { reasoning: 1, open: 0, completed: false }, + { reasoning: 2, open: 0, completed: false }, + ]) + const info = (yield* MessageV2.get({ sessionID: ctx.msg.sessionID, messageID: ctx.msg.id })).info + expect(info.role === "assistant" && info.time.completed).toBeNumber() + const parts = yield* MessageV2.parts(ctx.msg.id) + expect(parts.find((part) => part.type === "text")?.text).toBe("Recovered") + const reasoning = parts.filter((part) => part.type === "reasoning") + expect(reasoning.map((part) => [part.text, part.metadata])).toEqual([ + ["", { openai: { itemId: "rs_1" } }], + ["", { openai: { itemId: "rs_2" } }], + ["Thought", undefined], + ]) + expect(new Set(reasoning.map((part) => part.id)).size).toBe(3) + for (const part of reasoning) expect(part.time.end ?? 0).toBeGreaterThanOrEqual(part.time.start) + }), + { git: true }, + ), + ) + it.effect("does not retry a provider error after final output", () => provideTmpdirProject( (dir) => From 958f1c4fff35f6ac200521819229b7da77617a97 Mon Sep 17 00:00:00 2001 From: webreflection Date: Thu, 1 Oct 2026 21:32:54 +0200 Subject: [PATCH 067/353] fix(vscode): improve default models on session bootstrap --- .changeset/per-agent-model-precedence.md | 6 + .../code-with-ai/agents/model-selection.md | 4 +- .../kilo-docs/pages/customize/custom-modes.md | 2 +- packages/kilo-vscode/src/KiloProvider.ts | 2 - .../src/kilo-provider/early-message.ts | 2 - .../src/kilo-provider/model-state.ts | 137 ------ packages/kilo-vscode/src/provider-actions.ts | 12 - .../fixtures/session-preference-loader.ts | 169 -------- .../fixtures/session-provider-activity.tsx | 266 +++++------- .../tests/unit/model-selection.test.ts | 258 ++++-------- .../tests/unit/model-state.test.ts | 343 --------------- .../unit/new-worktree-dialog-sandbox.test.ts | 208 ++++----- .../tests/unit/prompt-send-contract.test.ts | 24 +- .../tests/unit/session-model-selector.test.ts | 16 +- .../tests/unit/session-model-store.test.ts | 393 +++++------------- .../unit/session-preference-loader.test.ts | 20 - .../tests/unit/session-preferences.test.ts | 54 ++- .../tests/unit/session-variant-store.test.ts | 6 +- .../tests/unit/session-variants.test.ts | 32 +- .../agent-manager/NewWorktreeDialog.tsx | 6 +- .../agent-manager/new-worktree-models.ts | 60 ++- .../webview-ui/src/context/model-selection.ts | 9 +- .../src/context/session-model-preferences.ts | 54 +-- .../src/context/session-model-selector.ts | 35 +- .../src/context/session-model-store.ts | 77 ++-- .../src/context/session-preference-loader.ts | 55 --- .../src/context/session-preferences.ts | 36 +- .../webview-ui/src/context/session-types.ts | 5 +- .../src/context/session-variant-store.ts | 6 +- .../src/context/session-variants.ts | 41 +- .../webview-ui/src/context/session.tsx | 255 ++++++------ .../webview-ui/src/stories/StoryProviders.tsx | 3 - .../src/types/messages/extension-messages.ts | 8 - .../src/types/messages/webview-messages.ts | 15 - .../opencode/src/kilocode/plan-followup.ts | 1 - .../test/kilocode/plan-followup.test.ts | 2 +- 36 files changed, 713 insertions(+), 1909 deletions(-) create mode 100644 .changeset/per-agent-model-precedence.md delete mode 100644 packages/kilo-vscode/src/kilo-provider/model-state.ts delete mode 100644 packages/kilo-vscode/tests/fixtures/session-preference-loader.ts delete mode 100644 packages/kilo-vscode/tests/unit/model-state.test.ts delete mode 100644 packages/kilo-vscode/tests/unit/session-preference-loader.test.ts delete mode 100644 packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts diff --git a/.changeset/per-agent-model-precedence.md b/.changeset/per-agent-model-precedence.md new file mode 100644 index 00000000000..d820f9d2aff --- /dev/null +++ b/.changeset/per-agent-model-precedence.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Fix model precedence so mode-configured models win over remembered picks in new sessions. Manual model picks now apply per agent within each session or draft, and the plan follow-up "Start new session" keeps the planning session in Plan mode. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 7595b492443..d7e22182fea 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -57,8 +57,8 @@ While the specifics change constantly, some principles stay consistent: - When the selected model supports variants, type `/variant` to open the reasoning effort selector. - Press `Shift+Tab` in the prompt input to cycle to the next reasoning effort variant, wrapping after the last one. This works in the sidebar chat, the Agent Manager prompt, and the New Worktree dialog, and the variant selector tooltip shows the shortcut on hover. To keep `Shift+Tab` for keyboard focus navigation instead, disable the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under **Settings → Display**). - Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file. -- **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details). -- The model selector remembers the last model you picked for each agent, so switching agents restores your previous choice. A manual pick always beats config settings. +- **Model precedence:** Pick for the current session or draft and agent → Per-agent config → Global config → Organization recommendation (then the first available Kilo model), or the most recently used available model for individual users → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details). +- The model selector remembers the model you picked for each agent within a session or draft, so switching agents and back restores your choice. New sessions start from config or the default; existing sessions recover each agent's model and reasoning effort from its latest user message. A manual pick beats config only within the session or draft and agent where it was made. {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 442777eee7d..a6ce037cb06 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -175,7 +175,7 @@ Pin a specific model using the `provider/model` format: model: anthropic/claude-sonnet-4-20250514 ``` -The model selector also **remembers the last model you picked for each agent** across sessions. A config-pinned `model` acts as the default when no manual pick exists. To reset a pick and let the config take over, use the **reset button** in the model selector (visible when your active model differs from what the config specifies). +The model selector remembers the model you picked for each agent within a session or draft. A config-pinned `model` is the default for new sessions; a manual pick beats config only for the agent and session or draft where it was made. Switching to another agent uses that agent's pick or configured default, and switching back restores your choice. Existing sessions recover each agent's model from its latest user message rather than a shared model preference. ### `steps` diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index f73d4ef9196..5b0024dd257 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -97,7 +97,6 @@ import { } from "./services/autocomplete/settings" import { routeEarlyMessage } from "./kilo-provider/early-message" import * as Board from "./kilo-provider/session-board" -import * as ModelState from "./kilo-provider/model-state" import { handleModelUsageMessage } from "./kilo-provider/model-usage" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" @@ -5049,7 +5048,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(buildAutoApprovalReasonSettingMessage()) this.postMessage(buildPushFixesSettingMessage()) this.sendWorkStyle() - await ModelState.reset(this.client, (msg) => this.postMessage(msg)) // Re-send globalState items to the webview this.postMessage({ type: "variantsLoaded", variants: {} }) diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 7b36339de6a..2e0493f82e5 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -1,5 +1,4 @@ import { routeSuggestionWebviewMessage } from "./handlers/suggestion" -import * as ModelState from "./model-state" import { routeInputToolMessage } from "../services/input-tools" import type { KiloConnectionService } from "../services/cli-backend/connection-service" import type { SpeechToTextSource } from "../speech-to-text/source" @@ -110,7 +109,6 @@ export async function routeEarlyMessage( return true } await routeSuggestionWebviewMessage(ctx.question, message) - if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { const input = message as { sessionID?: unknown } if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID) diff --git a/packages/kilo-vscode/src/kilo-provider/model-state.ts b/packages/kilo-vscode/src/kilo-provider/model-state.ts deleted file mode 100644 index 815d85a2e23..00000000000 --- a/packages/kilo-vscode/src/kilo-provider/model-state.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Model selection persistence in the CLI state directory. - * - * Per-mode choices use the shared model.json. The explicit preferred combo uses - * vscode-model.json so CLI/TUI writers cannot discard extension-only state. - */ - -import * as fs from "fs" -import * as path from "path" -import { randomUUID } from "crypto" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import { validateModelSelections } from "../provider-actions" - -type PostMessage = (msg: unknown) => void - -let cached: string | undefined -let queue: Promise = Promise.resolve() - -async function resolve(client: KiloClient | null): Promise { - if (cached) return cached - if (!client) return undefined - try { - const resp = await client.path.get() - if (typeof resp?.data?.state !== "string" || !resp.data.state) return undefined - cached = resp.data.state - return cached - } catch { - return undefined - } -} - -async function read(file: string): Promise> { - try { - const raw = await fs.promises.readFile(file, "utf-8") - const parsed = JSON.parse(raw) - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - return {} - } -} - -function selection(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined - const value = raw as Record - if ( - typeof value.providerID !== "string" || - !value.providerID || - typeof value.modelID !== "string" || - !value.modelID || - (value.variant !== undefined && typeof value.variant !== "string") - ) { - return undefined - } - return { - providerID: value.providerID, - modelID: value.modelID, - ...(typeof value.variant === "string" ? { variant: value.variant } : {}), - } -} - -function write( - client: KiloClient | null, - update: (data: Record) => void, - preferred?: ReturnType, -): Promise { - const op = queue.then(async () => { - const dir = await resolve(client) - if (!dir) return - const file = path.join(dir, "model.json") - const existing = await read(file) - update(existing) - await fs.promises.writeFile(file, JSON.stringify(existing, null, 2)) - const target = path.join(dir, "vscode-model.json") - const temp = `${target}.${randomUUID()}.tmp` - try { - await fs.promises.writeFile(temp, JSON.stringify({ preferred }, null, 2)) - await fs.promises.rename(temp, target) - } finally { - await fs.promises.rm(temp, { force: true }).catch((err) => { - console.error("[Kilo New] Failed to remove temporary model preferences:", err) - }) - } - }) - queue = op.catch((err) => { - console.error("[Kilo New] Failed to persist model selections:", err) - }) - return op -} - -/** - * Handle a model-state webview message. Returns true if handled. - */ -export async function handleMessage( - type: string, - message: Record, - client: KiloClient | null, - post: PostMessage, -): Promise { - if (type === "persistModelSelection") { - const preferred = selection(message) - const agent = message.agent - if (!preferred || typeof agent !== "string" || !agent) return true - await write( - client, - (data) => { - const model = validateModelSelections(data.model) - model[agent] = { providerID: preferred.providerID, modelID: preferred.modelID } - data.model = model - }, - { ...preferred, variant: preferred.variant ?? "" }, - ) - return true - } - if (type === "requestModelSelections") { - await queue - const dir = await resolve(client) - if (!dir) return true - const [data, prefs] = await Promise.all([ - read(path.join(dir, "model.json")), - read(path.join(dir, "vscode-model.json")), - ]) - const selections = validateModelSelections(data.model) - const preferred = selection(prefs.preferred) - post({ type: "modelSelectionsLoaded", selections, ...(preferred ? { preferred } : {}) }) - return true - } - return false -} - -export async function reset(client: KiloClient | null, post: PostMessage): Promise { - await write(client, (data) => { - data.model = {} - }) - post({ type: "modelSelectionsLoaded", selections: {} }) -} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 188fc17bb01..dbb5883d8a8 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -181,18 +181,6 @@ export function validateFavorites(raw: unknown): Array<{ providerID: string; mod return raw.filter(isModelSelection).map((r) => ({ providerID: r.providerID, modelID: r.modelID })) } -/** Validate and sanitize per-mode model selections from untrusted sources. */ -export function validateModelSelections(raw: unknown): Record { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} - const result: Record = {} - for (const [key, val] of Object.entries(raw as Record)) { - if (isModelSelection(val)) { - result[key] = { providerID: val.providerID, modelID: val.modelID } - } - } - return result -} - export function computeDefaultSelection( cachedConfig: { config?: { model?: string } } | null, vscodePID: string, diff --git a/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts b/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts deleted file mode 100644 index 58d6354cff0..00000000000 --- a/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts +++ /dev/null @@ -1,169 +0,0 @@ -import assert from "node:assert/strict" -import { jest, spyOn } from "bun:test" -import { createRoot, createSignal } from "solid-js" -import { createPreferenceLoader } from "../../webview-ui/src/context/session-preference-loader" - -function setup(online = true, loaded = false) { - return createRoot((dispose) => { - const [ready, hydrate] = createSignal(loaded) - const [connected, connect] = createSignal(online) - const sent: number[] = [] - const retry = createPreferenceLoader({ ready, connected, request: () => sent.push(Date.now()) }) - return { dispose, ready, hydrate, connect, sent, retry } - }) -} - -jest.useFakeTimers() -const warning = spyOn(console, "warn").mockImplementation(() => undefined) - -const cases: Record void> = { - exhaustion() { - const state = setup() - const start = Date.now() - assert.deepEqual(state.sent, [start]) - jest.advanceTimersByTime(2999) - assert.equal(state.sent.length, 1) - jest.advanceTimersByTime(6001) - assert.deepEqual(state.sent, [start, start + 3000, start + 6000, start + 9000]) - assert.equal(warning.mock.calls.length, 0, "the final request gets a response window") - jest.advanceTimersByTime(3000) - assert.equal(warning.mock.calls.length, 1) - assert.match(String(warning.mock.calls.at(0)?.at(0)), /\[Kilo New\].*preferences.*4 attempts/) - assert.equal(state.ready(), false, "exhaustion must not synthesize successful empty preferences") - assert.equal(jest.getTimerCount(), 0) - jest.advanceTimersByTime(60000) - assert.equal(state.sent.length, 4) - assert.equal(warning.mock.calls.length, 1) - state.hydrate(true) - assert.equal(state.ready(), true, "genuine late hydration remains authoritative") - state.retry() - assert.equal(state.sent.length, 4) - state.dispose() - }, - offline() { - const state = setup(false) - assert.equal(state.sent.length, 1, "request the host's cached disk path even while offline") - assert.equal(jest.getTimerCount(), 0) - jest.advanceTimersByTime(60000) - assert.equal(state.sent.length, 1) - assert.equal(warning.mock.calls.length, 0) - state.retry() - assert.equal(state.sent.length, 2, "extensionDataReady may also arrive before connected state") - assert.equal(jest.getTimerCount(), 0) - state.connect(true) - assert.equal(state.sent.length, 3) - assert.equal(jest.getTimerCount(), 1) - jest.advanceTimersByTime(3000) - assert.equal(state.sent.length, 4) - state.dispose() - }, - reconnect() { - const state = setup() - jest.advanceTimersByTime(3000) - assert.equal(state.sent.length, 2) - state.connect(false) - assert.equal(jest.getTimerCount(), 0) - jest.advanceTimersByTime(60000) - assert.equal(state.sent.length, 2) - assert.equal(warning.mock.calls.length, 0) - state.connect(true) - assert.equal(state.sent.length, 3) - jest.advanceTimersByTime(12000) - assert.equal(state.sent.length, 6) - assert.equal(warning.mock.calls.length, 1) - state.connect(true) - assert.equal(jest.getTimerCount(), 0, "unchanged connection does not restart an exhausted cycle") - state.connect(false) - state.connect(true) - assert.equal(state.sent.length, 7, "reconnect restarts even after exhaustion") - jest.advanceTimersByTime(12000) - assert.equal(state.sent.length, 10) - assert.equal(warning.mock.calls.length, 2, "warn once per exhausted connection cycle") - assert.equal(state.ready(), false) - state.dispose() - }, - retry() { - const state = setup() - jest.advanceTimersByTime(1000) - state.retry() - assert.equal(state.sent.length, 2) - assert.equal(jest.getTimerCount(), 1, "extensionDataReady replaces, not duplicates, the timer") - jest.advanceTimersByTime(2000) - assert.equal(state.sent.length, 2, "the original timer was cancelled") - jest.advanceTimersByTime(1000) - assert.equal(state.sent.length, 3) - jest.advanceTimersByTime(9000) - assert.equal(state.sent.length, 5) - assert.equal(warning.mock.calls.length, 1) - state.retry() - assert.equal(state.sent.length, 6, "extensionDataReady can recover an exhausted cycle") - jest.advanceTimersByTime(12000) - assert.equal(state.sent.length, 9) - assert.equal(warning.mock.calls.length, 2) - assert.equal(jest.getTimerCount(), 0) - state.dispose() - }, - ready() { - const state = setup() - jest.advanceTimersByTime(9000) - state.hydrate(true) - assert.equal(jest.getTimerCount(), 0, "readiness cancels the final response window") - state.connect(false) - state.connect(true) - state.retry() - jest.advanceTimersByTime(60000) - assert.equal(state.sent.length, 4) - assert.equal(warning.mock.calls.length, 0) - state.dispose() - }, - cleanup() { - const state = setup() - assert.equal(jest.getTimerCount(), 1) - state.dispose() - assert.equal(jest.getTimerCount(), 0) - state.retry() - state.connect(false) - state.connect(true) - jest.advanceTimersByTime(60000) - assert.equal(state.sent.length, 1, "disposed owners cannot restart requests") - assert.equal(warning.mock.calls.length, 0) - assert.equal(state.ready(), false) - }, - loaded() { - const state = setup(false, true) - state.connect(true) - state.retry() - assert.equal(state.sent.length, 0) - assert.equal(jest.getTimerCount(), 0) - state.dispose() - }, - synchronous() { - createRoot((dispose) => { - const [ready, hydrate] = createSignal(false) - let sent = 0 - const retry = createPreferenceLoader({ - ready, - connected: () => true, - request: () => { - sent++ - hydrate(true) - }, - }) - assert.equal(ready(), true) - assert.equal(jest.getTimerCount(), 0, "a cached synchronous response does not leave a timer") - retry() - assert.equal(sent, 1) - dispose() - }) - }, -} - -try { - const name = process.argv.at(2) - assert.ok(name && cases[name], `Unknown preference loader case: ${name}`) - cases[name]() - assert.equal(jest.getTimerCount(), 0, "each case disposes all retry timers") -} finally { - warning.mockRestore() - jest.useRealTimers() -} diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 23e5c97edee..8e1e94fe242 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -423,7 +423,7 @@ try { assert.equal(actual?.providerID, expected.providerID) assert.equal(actual?.modelID, expected.modelID) } - const writes = () => sent.filter((item) => item.type === "persistModelSelection" || item.type === "persistRecents") + const writes = () => sent.filter((item) => item.type === "persistRecents") const requests = () => sent.filter((item) => ["sendMessage", "sendCommand", "importAndSend", "compact"].includes(item.type)) const catalog = async ( @@ -567,34 +567,33 @@ try { choice(value.selected(), auto) value.selectModel(personal.providerID, personal.modelID) await settle() - assert.equal(writes().length, 2) + assert.equal(writes().length, 1) choice(value.selected(), personal) value.setSessionModel("selection", personal.providerID, personal.modelID) value.setCurrentSessionID("selection") const remembered = writes().slice() - await catalog("org-a", [first.modelID, recommended.modelID], recommended.modelID) - choice(value.selected(), recommended) - choice(value.selected("selection"), recommended) + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) + choice(value.selected(), personal) + choice(value.selected("selection"), personal) choice(value.modelForAgent("code"), recommended) await catalog(null, [auto.modelID, personal.modelID]) choice(value.selected(), personal) choice(value.modelForAgent("code"), personal) assert.deepEqual(writes(), remembered) - await emit({ type: "modelSelectionsLoaded", selections: {} }) await emit({ type: "recentsLoaded", recents: [auto] }) - choice(value.selected(), personal) - setSettings({ model: "kilo/personal" }) + setSettings({ agent: { code: { model: "kilo/personal" } } }) await settle() - setSettings({ model: "kilo/a-recommended" }) - await catalog("org-a", [first.modelID, recommended.modelID], recommended.modelID) - choice(value.selected(), recommended) + choice(value.modelForAgent("code"), personal) + choice(value.selected("fresh"), personal) + choice(value.selected(), personal) setSettings({}) - await catalog(null, [auto.modelID, personal.modelID]) + await settle() + choice(value.modelForAgent("code"), auto) choice(value.selected(), personal) assert.deepEqual(writes(), remembered) - await catalog("org-a", [first.modelID, recommended.modelID], recommended.modelID) + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) await emit({ type: "messagesLoaded", sessionID: "history", @@ -608,7 +607,7 @@ try { }, ], }) - choice(value.selected("history"), recommended) + choice(value.selected("history"), personal) await catalog(null, [auto.modelID, personal.modelID]) choice(value.selected("history"), personal) assert.deepEqual(writes(), remembered) @@ -734,23 +733,19 @@ try { assert.deepEqual(writes(), remembered) value.setCurrentSessionID(undefined) - await emit({ type: "modelSelectionsLoaded", selections: { code: personal } }) - choice(value.selected(), recommended) - await catalog(null, [auto.modelID, personal.modelID]) + // The composer keeps its own pick; config edits apply to scopes without picks. + await catalog(null, [auto.modelID, personal.modelID, first.modelID]) choice(value.selected(), personal) - await catalog("org-a", [auto.modelID, first.modelID, recommended.modelID], recommended.modelID) - await emit({ type: "modelSelectionsLoaded", selections: { code: auto } }) - choice(value.selected(), auto) setSettings({ agent: { code: { model: "kilo/z-first" } } }) await settle() - choice(value.modelForAgent("code"), auto) - choice(value.selected(), auto) + choice(value.selected(), personal) + choice(value.selected("fresh"), first) setSettings({}) - await emit({ type: "modelSelectionsLoaded", selections: {} }) - choice(value.selected(), recommended) + await settle() + choice(value.selected("fresh"), auto) assert.deepEqual(writes(), remembered) - // Mode changes preserve both explicit and inherited session choices, including Default. + // Mode switches re-resolve per agent; each agent keeps its own pick in the scope. await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) setSettings({ agent: { ask: { model: "kilo/z-first", variant: "low" } } }) for (const effort of ["high", ""]) { @@ -758,13 +753,14 @@ try { value.setSessionAgent(scope, "code") value.setSessionModel(scope, personal.providerID, personal.modelID) value.selectVariant(effort, scope) - const before = sent.length - for (const agent of ["ask", "code", "ask"]) { - value.selectAgent(agent, scope) - await settle() - assert.deepEqual(value.submission(scope), { model: personal, variant: effort, agent }) - } - assert.equal(sent.length, before, "Switching mode must not persist a different preference") + const before = writes().length + value.selectAgent("ask", scope) + await settle() + assert.deepEqual(value.submission(scope), { model: first, variant: "low", agent: "ask" }) + value.selectAgent("code", scope) + await settle() + assert.deepEqual(value.submission(scope), { model: personal, variant: effort, agent: "code" }) + assert.equal(writes().length, before, "Switching mode must not push recents") await emit({ type: "providersLoading" }) value.selectAgent("code", scope) await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) @@ -776,9 +772,9 @@ try { }) value.setSessionAgent("inherited-mode", "code") await emit({ type: "messagesLoaded", sessionID: "inherited-mode", messages: [] }) - const inherited = value.submission("inherited-mode") + assert.deepEqual(value.submission("inherited-mode"), { model: personal, variant: "high", agent: "code" }) value.selectAgent("ask", "inherited-mode") - assert.deepEqual(value.submission("inherited-mode"), { ...inherited, agent: "ask" }) + assert.deepEqual(value.submission("inherited-mode"), { model: first, variant: "low", agent: "ask" }) setSettings({}) const snapshot = (scope?: string) => @@ -801,9 +797,8 @@ try { cleared: value.userClearedSession(), }) await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) - await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) value.selectAgent("ask") - value.selectVariant("low") + value.selectVariant("high") for (const scope of [undefined, "ses_command", "ses_background-command", "command-draft"]) { value.setCurrentSessionID(undefined) value.selectAgent("code") @@ -854,9 +849,8 @@ try { } for (const configured of [false, true]) { - const scope = `ses_command-${configured ? "configured" : "preferred"}` + const scope = `ses_command-${configured ? "configured" : "recommended"}` setSettings(configured ? { agent: { ask: { model: "kilo/z-first", variant: "low" } } } : {}) - await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) value.setCurrentSessionID(scope) value.setSessionAgent(scope, "code") value.setSessionModel(scope, personal.providerID, personal.modelID) @@ -881,14 +875,13 @@ try { assert(request?.type === "sendCommand") assert.equal(request.sessionID, scope) assert.equal(request.agent, "ask") - assert.equal(request.modelID, personal.modelID) - assert.equal(request.variant, "high") + assert.equal(request.modelID, configured ? first.modelID : recommended.modelID) + assert.equal(request.variant, configured ? "low" : "high") assert.equal(value.selectedAgent(scope), "ask") - choice(value.selected(scope), personal) + choice(value.selected(scope), configured ? first : recommended) } setSettings({}) await catalog(null, [auto.modelID, personal.modelID, first.modelID, recommended.modelID]) - await emit({ type: "modelSelectionsLoaded", selections: {} }) value.setCurrentSessionID(undefined) value.selectAgent("ask") await settle() @@ -919,9 +912,9 @@ try { ) const configured = requests().at(-1) assert(configured?.type === "sendCommand") - assert.equal(configured.modelID, first.modelID) - assert.equal(configured.variant, "high") - assert.deepEqual(value.submission("ses_command-cached"), { model: first, variant: "high", agent: "ask" }) + assert.equal(configured.modelID, recommended.modelID) + assert.equal(configured.variant, "low") + assert.deepEqual(value.submission("ses_command-cached"), { model: recommended, variant: "low", agent: "ask" }) assert.equal( value.sendCommand( @@ -944,10 +937,10 @@ try { setSettings({}) await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) - await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) + await emit({ type: "variantsLoaded", variants: { "agent/ask/kilo/z-first": "high" } }) value.setCurrentSessionID(undefined) value.selectAgent("ask") - choice(value.selected(), first) + choice(value.selected(), recommended) assert.equal(value.variantForAgent("ask", first), "high") value.setCurrentSessionID("selection") assert.equal( @@ -969,10 +962,10 @@ try { assert(pending.draftID) assert.equal(pending.sessionID, undefined) assert.equal(pending.agent, "ask") - assert.equal(pending.modelID, first.modelID) + assert.equal(pending.modelID, recommended.modelID) assert.equal(pending.variant, "high") assert.equal(value.selectedAgent(pending.draftID), "ask") - choice(value.selected(pending.draftID), first) + choice(value.selected(pending.draftID), recommended) assert.equal(value.currentVariant(pending.draftID), "high") assert.equal(value.variantForAgent("ask", first), "high") const persisted = sent.length @@ -1002,10 +995,11 @@ try { choice(value.selected(accepted.draftID), personal) assert.equal(value.selectedAgent(accepted.draftID), "ask") assert.equal(value.currentVariant(accepted.draftID), "high") - choice(value.modelForAgent("ask"), first) + choice(value.modelForAgent("ask"), recommended) + // Explicit picks push recents but never persist a shared per-agent model. assert.equal( - sent.slice(persisted).some((message) => message.type === "persistModelSelection"), - false, + sent.slice(persisted).some((message) => message.type === "persistRecents"), + true, ) await emit({ type: "sessionCreated", session: info("ses_command-promoted"), draftID: accepted.draftID }) assert.equal(Object.hasOwn(value.allMessages(), accepted.draftID), false) @@ -1049,7 +1043,7 @@ try { value.clearCurrentSession() value.selectAgent("ask") assert.equal(value.selectedAgent(), "ask") - choice(value.selected(), first) + choice(value.selected(), recommended) const usage = JSON.stringify(value.modelUsageHistory()) const recent = JSON.stringify(value.recentModels()) const start = sent.length @@ -1075,7 +1069,7 @@ try { ) assert.equal(value.currentSessionID(), "ses_goal-draft") assert.equal(value.selectedAgent(), "ask") - choice(value.selected(), first) + choice(value.selected(), recommended) await emit({ type: "sessionCommandCompleted", messageID: command.messageID }) assert.equal(value.submitting(), false) assert.equal(JSON.stringify(value.modelUsageHistory()), usage) @@ -2166,9 +2160,8 @@ try { mode: "focus", }, ) - // An explicit next-session combo becomes the shared default, not a change to open sessions. + // Draft picks stay per scope and per agent; they never change other open scopes. await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) - await emit({ type: "modelSelectionsLoaded", selections: {} }) setSettings({ agent: { code: { model: "kilo/z-first", variant: "low" }, ask: { model: "kilo/a-recommended", variant: "low" } }, }) @@ -2176,22 +2169,27 @@ try { value.setSessionAgent("preference-active", "ask") await emit({ type: "messagesLoaded", sessionID: "preference-active", messages: [] }) const combo = value.submission("preference-active") + assert.deepEqual(combo, { model: recommended, variant: "low", agent: "ask" }) for (const scope of ["pending:preferred", "sidebar-pending:preferred"]) { value.setSessionAgent(scope, "code") value.selectModel(personal.providerID, personal.modelID, scope) value.selectVariant("high", scope) - assert.deepEqual(value.preferredSelection(), { ...personal, variant: "high" }) assert.deepEqual(value.submission("preference-active"), combo) - const saved = sent.findLast((item) => item.type === "persistModelSelection") - assert.deepEqual(saved, { type: "persistModelSelection", agent: "code", ...personal, variant: "high" }) - for (const agent of ["code", "ask"]) { - choice(value.modelForAgent(agent), personal) - assert.equal(value.variantForAgent(agent, personal), "high") - } + assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "code" }) + assert( + sent.some( + (message) => + message.type === "persistVariant" && message.key === "agent/code/kilo/personal" && message.value === "high", + ), + ) + choice(value.modelForAgent("code"), first) + choice(value.modelForAgent("ask"), recommended) value.selectAgent("ask", scope) - assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "ask" }) + assert.deepEqual(value.submission(scope), { model: recommended, variant: "low", agent: "ask" }) + value.selectAgent("code", scope) + assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "code" }) value.selectVariant(undefined, scope) - assert.deepEqual(value.preferredSelection(), { ...personal, variant: "" }) + assert.equal(value.currentVariant(scope), undefined) assert.equal(value.variantForAgent("code", personal), undefined) await emit({ type: "providersLoading" }) value.selectAgent("code", scope) @@ -2201,34 +2199,28 @@ try { assert.deepEqual(value.submission(scope), { model: personal, variant: "", agent: "code" }) assert.deepEqual(value.submission("preference-active"), combo) } - value.rememberSelection("ask", first, "high") - assert.deepEqual(value.submission("preference-active"), combo) - assert.deepEqual(value.submission("pending:preferred"), { model: personal, variant: "", agent: "code" }) - choice(value.modelForAgent("code"), first) - assert.equal(value.variantForAgent("code", first), "high") + value.setSessionAgent("comparison-only", "code") value.setSessionModel("comparison-only", personal.providerID, personal.modelID) value.setSessionVariant("comparison-only", personal.providerID, personal.modelID, "low") - assert.deepEqual(value.preferredSelection(), { ...first, variant: "high" }) + assert.deepEqual(value.submission("comparison-only"), { model: personal, variant: "low", agent: "code" }) - // Restore the persisted combo through the real host-message boundary. - await emit({ type: "modelSelectionsLoaded", selections: { ask: first }, preferred: { ...first, variant: "high" } }) + // The no-session composer keeps its own per-agent pick. value.setCurrentSessionID(undefined) value.selectAgent("code") choice(value.selected(), first) - assert.equal(value.currentVariant(), "high") value.selectModel(personal.providerID, personal.modelID) choice(value.selected(), personal) assert.equal(value.currentVariant(), "high") - assert.deepEqual(value.preferredSelection(), { ...personal, variant: "high" }) assert.deepEqual(value.submission("preference-active"), combo) - // Retention must not assign defaults to unopened or still-loading historical sessions. + // Retention must not assign picks to unopened or still-loading historical sessions. await emit({ type: "sessionsLoaded", sessions: [...unwrap(value.sessions()), info("unopened-preference"), info("loading-preference")], }) value.setCurrentSessionID("loading-preference") - value.rememberSelection("code", first, "low") + value.setSessionAgent("pending:retained", "code") + value.selectModel(personal.providerID, personal.modelID, "pending:retained") for (const id of ["unopened-preference", "loading-preference"]) { await emit({ type: "messagesLoaded", @@ -2247,38 +2239,18 @@ try { assert.deepEqual(value.submission(id), { model: personal, variant: "high", agent: "ask" }) } - // Pin raw inherited preferences, never a temporary catalog fallback or mapped effort. - value.rememberSelection("code", personal, "high") - for (const missing of ["model", "effort"]) { - const id = `pending:inherited-${missing}` + // Drafts resolve the remembered effort for their (agent, model) pair. + { + const id = "pending:effort" value.setSessionAgent(id, "code") - if (missing === "model") await catalog("org-b", [first.modelID], first.modelID) - if (missing === "effort") await catalog("org-a", [personal.modelID], personal.modelID, true, ["low"]) + value.selectVariant("high", id) + assert.equal(value.currentVariant(id), "high") value.selectAgent("ask", id) - await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) - assert.deepEqual(value.submission(id), { model: personal, variant: "high", agent: "ask" }) + assert.equal(value.currentVariant(id), "low") + value.selectAgent("code", id) + assert.equal(value.currentVariant(id), "high") } - // A delayed startup response cannot erase a choice made while preferences load. - setSharing(false) - await settle() - setSharing(true) - await settle() - const fresh = peer.value - assert(fresh) - assert.equal(fresh.preferencesReady(), false) - const loadingPreferences = sent.filter((item) => item.type === "requestModelSelections").length - await emit({ type: "extensionDataReady" }) - assert.equal(sent.filter((item) => item.type === "requestModelSelections").length, loadingPreferences + 1) - fresh.selectModel(personal.providerID, personal.modelID) - fresh.selectVariant("high") - await emit({ type: "modelSelectionsLoaded", selections: { code: first }, preferred: { ...first, variant: "low" } }) - await emit({ type: "variantsLoaded", variants: { "agent/code/kilo/personal": "low" } }) - choice(fresh.selected(), personal) - assert.equal(fresh.currentVariant(), "high") - assert.deepEqual(fresh.preferredSelection(), { ...personal, variant: "high" }) - assert.equal(fresh.preferencesReady(), true) - // Unset effort defers to the new model; only a real choice can override its preference. const outgoing = { providerID: "kilo", modelID: "unset-effort" } await catalog("org-a", [outgoing.modelID, first.modelID], first.modelID) @@ -2292,13 +2264,17 @@ try { const instance = peer.value assert(instance) instance.selectAgent("code") - setSettings(target === "configured" ? { agent: { code: { model: "kilo/z-first", variant: "high" } } } : {}) - await emit({ type: "modelSelectionsLoaded", selections: { code: outgoing } }) + setSettings(target === "configured" ? { agent: { code: { model: "kilo/unset-effort", variant: "high" } } } : {}) await emit({ type: "variantsLoaded", variants: target === "remembered" ? { "agent/code/kilo/z-first": "high" } : {}, }) - if (scope) instance.setSessionAgent(scope, "code") + if (scope) { + instance.setSessionAgent(scope, "code") + instance.setSessionModel(scope, outgoing.providerID, outgoing.modelID) + } else { + instance.selectModel(outgoing.providerID, outgoing.modelID) + } if (effort !== undefined) instance.selectVariant(effort, scope) choice(instance.selected(scope), outgoing) instance.selectModel(first.providerID, first.modelID, scope) @@ -2308,9 +2284,6 @@ try { { model: first, variant: expected, agent: "code" }, `${target}/${effort}/${scope}`, ) - if (!scope || scope.startsWith("pending:")) { - assert.deepEqual(instance.preferredSelection(), { ...first, variant: expected }) - } } } } @@ -2324,7 +2297,6 @@ try { const instance = peer.value assert(instance) setSettings({}) - await emit({ type: "modelSelectionsLoaded", selections: { code: first } }) await emit({ type: "variantsLoaded", variants: {} }) const untrack = instance.trackScopes(() => ["pending:default-one", "pending:default-two"]) assert.equal(instance.currentVariant("pending:default-one"), undefined) @@ -2335,7 +2307,7 @@ try { untrack() } - // Generated command drafts are known-new before mode overrides resolve their effort. + // Generated command drafts resolve the target agent's config before sending. { setSharing(false) await settle() @@ -2346,10 +2318,8 @@ try { setSettings({ agent: { code: { model: "kilo/z-first", variant: "high" }, ask: { model: "kilo/unset-effort", variant: "low" } }, }) - await emit({ type: "modelSelectionsLoaded", selections: {} }) await emit({ type: "variantsLoaded", variants: {} }) instance.selectAgent("code") - assert.equal(instance.preferredSelection(), undefined) assert.equal( instance.sendCommand( "review-test", @@ -2366,17 +2336,18 @@ try { ) const request = sent.findLast((item) => item.type === "sendCommand") assert(request) - assert.equal(request.modelID, first.modelID) - assert.equal(request.variant, "high") + assert.equal(request.modelID, outgoing.modelID) + assert.equal(request.variant, "low") assert.equal(request.agent, "ask") - choice(instance.selected(request.draftID), first) + choice(instance.selected(request.draftID), outgoing) } - // Real sidebar tab inventories include untouched background drafts and reclaim closed drafts. + // Real sidebar tab inventories keep per-tab picks; closed tabs are reclaimed. setSharing(false) await catalog("org-a", [personal.modelID, first.modelID, outgoing.modelID], first.modelID) value.clearCurrentSession() - value.rememberSelection("code", first, "high") + setSettings({}) + await settle() setTabbed(true) await settle() const sidebar = tabs.value @@ -2388,18 +2359,18 @@ try { value.selectVariant("low", edited) choice(value.selected(untouched), first) assert.equal(value.currentVariant(untouched), "high") + assert.equal(value.currentVariant(edited), "low") sidebar.close(untouched) sidebar.close(edited) await settle() - for (const model of [first, personal, first]) { - value.rememberSelection("code", model, "low") - choice(value.selected(untouched), model) - choice(value.selected(edited), model) - assert.equal(value.currentVariant(edited), "low") - } + choice(value.selected(edited), first) + const reopened = sidebar.add() + value.selectModel(personal.providerID, personal.modelID, reopened) + choice(value.selected(reopened), personal) + assert.equal(value.currentVariant(reopened), "high") - // Closing a sending draft retains its combo through promotion, or drops it after failure. - for (const accepted of [false, true]) { + // Closing a sending draft retains its combo through promotion. + { const id = sidebar.add() value.selectModel(first.providerID, first.modelID, id) value.selectVariant("high", id) @@ -2408,39 +2379,34 @@ try { assert(request) sidebar.close(id) await settle() - value.rememberSelection("code", personal, "low") assert.equal(value.isSubmitting(id), true) choice(value.selected(id), first) assert.equal(value.currentVariant(id), "high") - if (accepted) { - await emit({ type: "sessionCreated", session: info("promoted-closed-draft"), draftID: id }) - choice(value.selected("promoted-closed-draft"), first) - assert.equal(value.currentVariant("promoted-closed-draft"), "high") - await emit({ type: "sessionCommandCompleted", messageID: request.messageID }) - } - if (!accepted) await emit({ ...request, type: "sendMessageFailed", error: "Test rejection" }) - choice(value.selected(id), personal) - assert.equal(value.currentVariant(id), "low") + await emit({ type: "sessionCreated", session: info("promoted-closed-draft"), draftID: id }) + choice(value.selected("promoted-closed-draft"), first) + assert.equal(value.currentVariant("promoted-closed-draft"), "high") + await emit({ type: "sessionCommandCompleted", messageID: request.messageID }) } setTabbed(false) await settle() - // Agent Manager retains all projects, not historical cache entries or unknown unloaded sessions. + // Agent Manager retains all projects; tracked tabs keep their own picks and pruning forgets closed ones. { value.clearCurrentSession() - value.rememberSelection("code", first, "high") const projects = createProjectRegistry({ persisted: {}, activeId: () => "one" }) projects.ensure("one").tabs.set(["pending:project-one"]) projects.ensure("two").tabs.set(["pending:project-two", "background-empty", "unknown-unloaded"]) const untrack = value.trackScopes(projects.scopes) await emit({ type: "messagesLoaded", sessionID: "background-empty", messages: [] }) await emit({ type: "messagesLoaded", sessionID: "closed-history-cache", messages: [] }) - value.rememberSelection("code", personal, "low") - for (const id of ["pending:project-one", "pending:project-two", "background-empty"]) { - choice(value.selected(id), first) - assert.equal(value.currentVariant(id), "high") - } - choice(value.selected("closed-history-cache"), personal) + value.selectModel(first.providerID, first.modelID, "pending:project-one") + value.selectVariant("high", "pending:project-one") + choice(value.selected("pending:project-one"), first) + assert.equal(value.currentVariant("pending:project-one"), "high") + // Retain pins the other open tabs so this pick cannot change what they display. + choice(value.selected("pending:project-two"), first) + assert.equal(value.currentVariant("pending:project-two"), "high") + choice(value.selected("background-empty"), first) await emit({ type: "messagesLoaded", sessionID: "unknown-unloaded", @@ -2459,14 +2425,10 @@ try { assert.equal(value.currentVariant("unknown-unloaded"), "high") projects.prune(new Set(["one"])) await settle() - choice(value.selected("pending:project-two"), personal) - choice(value.selected("pending:project-one"), first) + choice(value.selected("pending:project-two"), first) untrack() await settle() - choice(value.selected("pending:project-one"), personal) - value.rememberSelection("code", outgoing, "low") - choice(value.selected("closed-history-cache"), outgoing) - choice(value.selected("background-empty"), first) + choice(value.selected("pending:project-one"), first) } // Repeated goal promotion removes empty draft caches without overwriting arriving session history. for (const [index, args] of ["pause", "do X"].entries()) { diff --git a/packages/kilo-vscode/tests/unit/model-selection.test.ts b/packages/kilo-vscode/tests/unit/model-selection.test.ts index 3fd9fe8d6e7..bdc9ca2ddb8 100644 --- a/packages/kilo-vscode/tests/unit/model-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selection.test.ts @@ -1,208 +1,110 @@ import { describe, expect, it } from "bun:test" import { resolveModelSelection } from "../../webview-ui/src/context/model-selection" -import { KILO_AUTO, parseModelString } from "../../src/shared/provider-model" import type { ModelSelection, Provider } from "../../webview-ui/src/types/messages" -function makeProvider(id: string, name: string, modelIds: string[]): Provider { - const models: Provider["models"] = {} - for (const modelID of modelIds) { - models[modelID] = { id: modelID, name: modelID } +function makeProvider(id: string, models: string[]): Provider { + const result: Provider = { id, name: id, models: {} } + for (const m of models) { + result.models[m] = { id: m, name: m } } - return { id, name, models } + return result } -const providers = { - kilo: makeProvider("kilo", "Kilo Gateway", ["kilo-auto/free"]), - anthropic: makeProvider("anthropic", "Anthropic", ["claude-sonnet-4"]), - openai: makeProvider("openai", "OpenAI", ["gpt-4.1"]), +const KILO_AUTO: ModelSelection = { providerID: "kilo", modelID: "kilo-auto/free" } + +const providers: Record = { + kilo: makeProvider("kilo", ["kilo-auto/free", "z-first", "z-recommended"]), + anthropic: makeProvider("anthropic", ["claude-sonnet-4"]), + openai: makeProvider("openai", ["gpt-4.1"]), } -describe("parseModelString", () => { - it("parses provider/model pairs", () => { - expect(parseModelString("anthropic/claude-sonnet-4")).toEqual({ - providerID: "anthropic", - modelID: "claude-sonnet-4", - }) - }) +const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } +const first: ModelSelection = { providerID: "kilo", modelID: "z-first" } +const recommended: ModelSelection = { providerID: "kilo", modelID: "z-recommended" } - it("keeps slashes inside kilo model ids", () => { - expect(parseModelString("kilo/kilo-auto/free")).toEqual({ - providerID: "kilo", - modelID: "kilo-auto/free", - }) - }) - - it("returns null for invalid values", () => { - expect(parseModelString(undefined)).toBeNull() - expect(parseModelString("claude-sonnet-4")).toBeNull() - }) -}) +function base() { + return { + providers, + connected: ["kilo", "anthropic", "openai"], + ready: true, + organizationId: null, + fallback: KILO_AUTO, + } +} describe("resolveModelSelection", () => { - it("prefers a valid override", () => { - const result = resolveModelSelection({ - providers, - connected: ["anthropic", "openai"], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + it("returns null when providers are pending", () => { + const result = resolveModelSelection({ ...base(), ready: false, session: first }) + expect(result).toBeNull() }) - it("falls back from an invalid override to the mode model", () => { - const result = resolveModelSelection({ - providers, - connected: ["anthropic"], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" }) + it("returns null when organization is pending", () => { + const result = resolveModelSelection({ ...base(), ready: true, organizationId: undefined, session: first }) + expect(result).toBeNull() }) - it("falls back from invalid config to the first valid recent model", () => { - const result = resolveModelSelection({ - providers, - connected: ["openai"], - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - recent: [ - { providerID: "anthropic", modelID: "claude-sonnet-4" }, - { providerID: "openai", modelID: "gpt-4.1" }, - ], - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + it("returns the session pick when set", () => { + const result = resolveModelSelection({ ...base(), session: claude, mode: gpt, global: first }) + expect(result).toEqual(claude) }) - it("uses kilo auto as the explicit final fallback", () => { + it("returns the mode model over the global model", () => { + const result = resolveModelSelection({ ...base(), mode: gpt, global: first }) + expect(result).toEqual(gpt) + }) + + it("returns the global model when no session or mode is set", () => { + const result = resolveModelSelection({ ...base(), global: gpt }) + expect(result).toEqual(gpt) + }) + + it("returns the org recommendation for org users", () => { + const result = resolveModelSelection({ ...base(), organizationId: "org-a", defaults: { kilo: "z-recommended" } }) + expect(result).toEqual(recommended) + }) + + it("skips recents for org users", () => { const result = resolveModelSelection({ - providers, - connected: [], - fallback: KILO_AUTO, + ...base(), + organizationId: "org-a", + defaults: { kilo: "z-recommended" }, + recent: [claude], }) + expect(result).toEqual(recommended) + }) + + it("returns the first kilo model for org users when no recommendation exists", () => { + const result = resolveModelSelection({ ...base(), organizationId: "org-a", recent: [claude] }) expect(result).toEqual(KILO_AUTO) }) - it("rejects a fallback missing from the loaded catalog", () => { + it("returns the first valid recent model for non-org users", () => { const result = resolveModelSelection({ - providers: { openai: providers.openai }, - connected: [], - fallback: KILO_AUTO, + ...base(), + recent: [{ providerID: "anthropic", modelID: "missing" }, claude, gpt], + }) + expect(result).toEqual(claude) + }) + + it("returns the fallback when nothing else matches", () => { + const result = resolveModelSelection({ ...base() }) + expect(result).toEqual(KILO_AUTO) + }) + + it("pending kilo models are hidden until the catalog is ready", () => { + const result = resolveModelSelection({ + ...base(), + ready: false, + organizationId: "org-a", + defaults: { kilo: "z-recommended" }, + session: recommended, }) expect(result).toBeNull() }) - it("does not treat an empty catalog as unvalidated preferences", () => { - const result = resolveModelSelection({ - providers: {}, - connected: [], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toBeNull() - }) -}) - -describe("organization model selection", () => { - const first = { providerID: "kilo", modelID: "z-first" } - const recommendation = { providerID: "kilo", modelID: "a-default" } - const recent = { providerID: "kilo", modelID: "older-recent" } - const external = { providerID: "openai", modelID: "gpt-4.1" } - const input = { - providers: { - ...providers, - kilo: makeProvider("kilo", "Kilo Gateway", [ - first.modelID, - recommendation.modelID, - recent.modelID, - "kilo-auto/free", - ]), - }, - connected: ["openai"], - ready: true, - organizationId: "org-a", - defaults: { kilo: recommendation.modelID }, - recent: [{ providerID: "kilo", modelID: "missing-recent" }, recent, external], - fallback: KILO_AUTO, - } - - it("uses the recommendation for fresh Org login instead of recents or the generic fallback", () => { - expect(resolveModelSelection(input)).toEqual(recommendation) - }) - - it.each([undefined, "", "unavailable"])("uses catalog order for an absent or invalid default %s", (model) => { - expect(resolveModelSelection({ ...input, defaults: model === undefined ? {} : { kilo: model } })).toEqual(first) - }) - - it.each(["session", "override", "mode", "global"] as const)( - "preserves a valid %s before the recommendation", - (key) => { - expect(resolveModelSelection({ ...input, [key]: KILO_AUTO })).toEqual(KILO_AUTO) - }, - ) - - it("validates session, manual, mode, and global preferences in order", () => { - const missing = { providerID: "kilo", modelID: "missing" } - const choices = { session: KILO_AUTO, override: recent, mode: first, global: external } - expect(resolveModelSelection({ ...input, ...choices })).toEqual(KILO_AUTO) - expect(resolveModelSelection({ ...input, ...choices, session: missing })).toEqual(recent) - expect(resolveModelSelection({ ...input, ...choices, session: missing, override: missing })).toEqual(first) - expect(resolveModelSelection({ ...input, ...choices, session: missing, override: missing, mode: missing })).toEqual( - external, - ) - expect( - resolveModelSelection({ ...input, session: missing, override: missing, mode: missing, global: missing }), - ).toEqual(recommendation) - }) - - it("preserves explicitly configured external providers only while connected", () => { - expect(resolveModelSelection({ ...input, override: external })).toEqual(external) - expect(resolveModelSelection({ ...input, connected: [], override: external })).toEqual(recommendation) - }) - - it.each([{}, { kilo: makeProvider("kilo", "Kilo Gateway", []) }, { openai: providers.openai }])( - "does not fall back to free models or external recents for an empty Org catalog", - (catalog) => { - expect(resolveModelSelection({ ...input, providers: catalog, override: KILO_AUTO })).toBeNull() - }, - ) - - it("keeps explicit external models available with an empty Org catalog", () => { - expect(resolveModelSelection({ ...input, providers: { openai: providers.openai }, override: external })).toEqual( - external, - ) - }) - - it("does not trust a retained Kilo catalog while refresh or auth context is pending", () => { - for (const pending of [{ ready: false }, { organizationId: undefined }]) { - expect(resolveModelSelection({ ...input, ...pending, override: KILO_AUTO })).toBeNull() - expect(resolveModelSelection({ ...input, ...pending, override: external })).toEqual(external) - } - }) - - it("keeps Personal recents ahead of defaults and validates its final fallback", () => { - expect(resolveModelSelection({ ...input, organizationId: null })).toEqual(recent) - expect(resolveModelSelection({ ...input, organizationId: null, recent: [] })).toEqual(KILO_AUTO) - expect( - resolveModelSelection({ ...input, organizationId: null, recent: [], fallback: external, connected: [] }), - ).toBeNull() - }) - - it("restores the same explicit choice through Personal, Org A, Org B, and Personal", () => { - const override: ModelSelection = { providerID: "kilo", modelID: "personal" } - const personal = { - ...input, - organizationId: null, - providers: { kilo: makeProvider("kilo", "Kilo", [override.modelID]) }, - } - expect(resolveModelSelection({ ...personal, override })).toEqual(override) - expect(resolveModelSelection({ ...input, override })).toEqual(recommendation) - expect( - resolveModelSelection({ ...input, organizationId: "org-b", defaults: { kilo: first.modelID }, override }), - ).toEqual(first) - expect(resolveModelSelection({ ...personal, override })).toEqual(override) - expect(override).toEqual({ providerID: "kilo", modelID: "personal" }) + it("falls through invalid candidates to the fallback", () => { + const resolved = resolveModelSelection({ ...base(), session: { providerID: "unknown", modelID: "m" } }) + expect(resolved).toEqual(KILO_AUTO) }) }) diff --git a/packages/kilo-vscode/tests/unit/model-state.test.ts b/packages/kilo-vscode/tests/unit/model-state.test.ts deleted file mode 100644 index f238dd06d2c..00000000000 --- a/packages/kilo-vscode/tests/unit/model-state.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test" -import { link, mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { PersistModelSelectionRequest } from "../../webview-ui/src/types/messages/webview-messages" - -const dirs: string[] = [] -const model = { providerID: "kilo", modelID: "model-a" } -const other = { providerID: "other", modelID: "model-b" } -const unrelated = { - recent: [other], - favorite: [model], - variant: { "kilo/model-a": "low" }, - custom: { nested: [1, "keep", null] }, -} - -function load(): Promise { - // Reload module-level path caching without replacing the persistence implementation. - return import(`../../src/kilo-provider/model-state.ts?${crypto.randomUUID()}`) -} - -async function fixture(data?: unknown, prefs?: unknown) { - const dir = await mkdtemp(join(tmpdir(), "kilo-model-state-")) - dirs.push(dir) - const file = join(dir, "model.json") - const preference = join(dir, "vscode-model.json") - if (data !== undefined) await Bun.write(file, JSON.stringify(data)) - if (prefs !== undefined) await Bun.write(preference, JSON.stringify(prefs)) - const client = { path: { get: async () => ({ data: { state: dir } }) } } as unknown as KiloClient - const host = await load() - const messages: unknown[] = [] - const post = (message: unknown) => { - messages.push(message) - } - return { - dir, - get file() { - return Bun.file(file) - }, - get preference() { - return Bun.file(preference) - }, - client, - host, - post, - messages, - save: (selection: Omit) => - host.handleMessage("persistModelSelection", selection, client, post), - request: async () => { - expect(await host.handleMessage("requestModelSelections", {}, client, post)).toBe(true) - return messages.at(-1) - }, - } -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - -describe("model-state", () => { - it("persists a preferred model and effort together while preserving other modes and unrelated fields", async () => { - const state = await fixture({ ...unrelated, model: { code: other, plan: other } }) - - expect(await state.save({ agent: "code", ...model, variant: "high" })).toBe(true) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { code: model, plan: other }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - expect(await state.request()).toEqual({ - type: "modelSelectionsLoaded", - selections: { code: model, plan: other }, - preferred: { ...model, variant: "high" }, - }) - }) - - it.each([undefined, ""])("saves an explicit default effort for variant %j", async (variant) => { - const state = await fixture({ model: { plan: other } }, { preferred: { ...other, variant: "high" } }) - - await state.save({ agent: "code", ...model, ...(variant === undefined ? {} : { variant }) }) - - expect(await state.file.json()).toEqual({ - model: { plan: other, code: model }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } }) - }) - - it("loads the saved combo after the host module reloads", async () => { - const state = await fixture() - await state.save({ agent: "code", ...model, variant: "high" }) - const host = await load() - - expect(await host.handleMessage("requestModelSelections", {}, state.client, state.post)).toBe(true) - - expect(state.messages).toEqual([ - { type: "modelSelectionsLoaded", selections: { code: model }, preferred: { ...model, variant: "high" } }, - ]) - }) - - it("atomically replaces the complete preferred combo without modifying the previous file", async () => { - const prefs = { preferred: { ...other, variant: "low" } } - const state = await fixture({ model: { plan: other } }, prefs) - const previous = join(state.dir, "previous.json") - await link(join(state.dir, "vscode-model.json"), previous) - - await state.save({ agent: "code", ...model, variant: "high" }) - - expect(await Bun.file(previous).json()).toEqual(prefs) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - }) - - it("loads legacy model memory without inventing a preferred combo", async () => { - const state = await fixture({ model: { code: model, bad: { providerID: 42, modelID: "invalid" } } }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it.each([undefined, "", "high"])( - "validates and sanitizes a loaded preferred combo with variant %j", - async (variant) => { - const preferred = { ...model, ...(variant === undefined ? {} : { variant }) } - const data = { model: { plan: other } } - const prefs = { preferred: { ...preferred, extra: "discard" } } - const state = await fixture(data, prefs) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { plan: other }, preferred }) - expect(await state.file.json()).toEqual(data) - expect(await state.preference.json()).toEqual(prefs) - }, - ) - - it.each( - [ - null, - true, - 42, - "invalid", - [], - {}, - { providerID: "kilo" }, - { modelID: "model-a" }, - { ...model, providerID: 42 }, - { ...model, providerID: "" }, - { ...model, modelID: null }, - { ...model, modelID: "" }, - { ...model, variant: null }, - { ...model, variant: 42 }, - { ...model, variant: false }, - { ...model, variant: {} }, - { ...model, variant: [] }, - ].map((preferred) => [preferred] as const), - )("omits invalid preferred data %j without discarding model memory", async (preferred) => { - const state = await fixture({ model: { code: model } }, { preferred }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it.each(["{", "null", "[]", '"invalid"'])("recovers from invalid model.json contents %s", async (raw) => { - const preferred = { ...model, variant: "high" } - const state = await fixture(undefined, { preferred }) - await Bun.write(state.file, raw) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {}, preferred }) - await state.save({ agent: "code", ...model, variant: "high" }) - expect(await state.file.json()).toEqual({ model: { code: model } }) - expect(await state.preference.json()).toEqual({ preferred }) - }) - - it.each(["{", "null", "[]", '"invalid"'])("ignores invalid vscode-model.json contents %s", async (raw) => { - const state = await fixture({ model: { code: model } }) - await Bun.write(state.preference, raw) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - await state.save({ agent: "code", ...model, variant: "high" }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - }) - - it.each([ - { agent: "", ...model }, - { agent: 42, ...model }, - { agent: "code", modelID: "model-a" }, - { agent: "code", ...model, modelID: null }, - { agent: "code", ...model, variant: null }, - { agent: "code", ...model, variant: 42 }, - ])("ignores invalid explicit selections %j without overwriting saved preferences", async (message) => { - const data = { ...unrelated, model: { plan: other } } - const prefs = { preferred: { ...other, variant: "high" } } - const state = await fixture(data, prefs) - - expect(await state.host.handleMessage("persistModelSelection", message, state.client, state.post)).toBe(true) - expect(await state.file.json()).toEqual(data) - expect(await state.preference.json()).toEqual(prefs) - }) - - it("returns empty memory for a missing file and creates it on an explicit save", async () => { - const state = await fixture() - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {} }) - expect(await state.file.exists()).toBe(false) - expect(await state.preference.exists()).toBe(false) - await state.save({ agent: "code", ...model }) - expect(await state.file.json()).toEqual({ model: { code: model } }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } }) - }) - - it("clears model memory and preferred together while retaining unrelated fields", async () => { - const state = await fixture({ ...unrelated, model: { code: model } }, { preferred: { ...model, variant: "high" } }) - - await state.host.reset(state.client, state.post) - - expect(await state.file.json()).toEqual({ ...unrelated, model: {} }) - expect(await state.preference.json()).toEqual({}) - expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: {} }]) - const host = await load() - await host.handleMessage("requestModelSelections", {}, state.client, state.post) - expect(state.messages.at(-1)).toEqual({ type: "modelSelectionsLoaded", selections: {} }) - }) - - it("preserves every model entry during concurrent saves and keeps the latest preferred combo", async () => { - const state = await fixture({ ...unrelated, model: { existing: other } }) - const choices = Array.from({ length: 12 }, (_, index) => ({ - agent: `mode-${index}`, - providerID: "kilo", - modelID: `model-${index}`, - variant: `effort-${index}`, - })) - - await Promise.all([ - ...choices.map((choice) => state.save(choice)), - state.save({ agent: "mode-0", ...other, variant: "" }), - ]) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { - existing: other, - ...Object.fromEntries( - choices.map((choice) => [choice.agent, { providerID: choice.providerID, modelID: choice.modelID }]), - ), - "mode-0": other, - }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } }) - }) - - it("waits for pending persistence before returning model selections", async () => { - const state = await fixture({ model: { plan: other } }) - - const saving = state.save({ agent: "code", ...model, variant: "high" }) - const loaded = await state.request() - await saving - - expect(loaded).toEqual({ - type: "modelSelectionsLoaded", - selections: { plan: other, code: model }, - preferred: { ...model, variant: "high" }, - }) - }) - - it("orders reset with pending saves without resurrecting cleared model memory", async () => { - const state = await fixture({ ...unrelated, model: { existing: other } }) - - await Promise.all([ - state.save({ agent: "code", ...model, variant: "high" }), - state.host.reset(state.client, state.post), - state.save({ agent: "plan", ...other }), - ]) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { plan: other }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } }) - }) - - it.each(["high", ""])("retains preferred effort %j after a TUI rewrite of shared model state", async (variant) => { - const state = await fixture() - const preferred = { ...model, variant } - await state.save({ agent: "code", ...preferred }) - const before = await state.preference.text() - const shared = { model: { plan: other }, recent: [other], favorite: [], variant: { "other/model-b": "low" } } - - await Bun.write(state.file, JSON.stringify(shared)) - const host = await load() - await host.handleMessage("requestModelSelections", {}, state.client, state.post) - - expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: { plan: other }, preferred }]) - expect(await state.preference.text()).toBe(before) - expect(await state.file.json()).toEqual(shared) - }) - - it("does not read preferred metadata from the shared TUI file", async () => { - const state = await fixture({ model: { code: model }, preferred: { ...other, variant: "high" } }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it("keeps saving to the resolved state directory during a temporary disconnect", async () => { - const state = await fixture() - await state.request() - await state.host.handleMessage( - "persistModelSelection", - { agent: "code", ...model, variant: "high" }, - null, - state.post, - ) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - await state.host.handleMessage("requestModelSelections", {}, null, state.post) - expect(state.messages.at(-1)).toEqual({ - type: "modelSelectionsLoaded", - selections: { code: model }, - preferred: { ...model, variant: "high" }, - }) - }) - - it("defers a cold-start request without a client until a ready-client retry", async () => { - const preferred = { ...model, variant: "high" } - const state = await fixture({ model: { code: model } }, { preferred }) - - expect(await state.host.handleMessage("requestModelSelections", {}, null, state.post)).toBe(true) - expect(state.messages).toEqual([]) - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred }) - }) - - it.each(["missing", "error"])("defers loading when the CLI state path is unavailable (%s)", async (reason) => { - const preferred = { ...model, variant: "high" } - const state = await fixture({ model: { code: model } }, { preferred }) - const client = { - path: { - get: async () => { - if (reason === "error") throw new Error("CLI is not ready") - return { data: {} } - }, - }, - } as unknown as KiloClient - - expect(await state.host.handleMessage("requestModelSelections", {}, client, state.post)).toBe(true) - expect(state.messages).toEqual([]) - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred }) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts index c4abe00da7a..8ed3e6969b9 100644 --- a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts +++ b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts @@ -93,8 +93,6 @@ function check(code: string) { function dialog(saved = {}, initial = { providers: catalog(x, y), fallback: y, alternate: y, ready: true, connected: [] }) { const result = createRoot((dispose) => { const [snapshot, refresh] = createSignal(initial) - const [compareMode, setCompareMode] = createSignal(false) - const preferences = [] const state = createDialogPreferences({ saved, agent: saved.agent ?? "code", @@ -103,16 +101,13 @@ function check(code: string) { variants: (value) => Object.keys(snapshot().providers[value.providerID]?.models[value.modelID]?.variants ?? {}), fallback: (name) => name === "code" ? snapshot().fallback : snapshot().alternate ?? null, effort: (name, model) => model ? snapshot().efforts?.[name + "/" + model.modelID] ?? snapshot().efforts?.[name] : undefined, - preferred: () => snapshot().preferred, - hydrated: () => snapshot().hydrated ?? true, - compare: compareMode, - remember: (...args) => preferences.push(args), }) return { - ...state, setCompareMode, preferences, + ...state, pick: state.selectModel, choose: state.selectVariant, clear: () => state.selectVariant(DEFAULT_VARIANT), + reset: state.clear, cached: state.saved, refresh: (update) => refresh((current) => ({ ...current, ...update })), dispose, } @@ -153,7 +148,6 @@ describe("NewWorktreeDialog models", () => { assert.deepEqual(state.model(), x) assert.deepEqual(state.cached(), { agent: "code", model: x, variant: "" }) assert.equal(state.selection.canSubmit(), true) - assert.deepEqual(state.preferences, [["code", x, ""]]) `) }) @@ -171,19 +165,37 @@ describe("NewWorktreeDialog models", () => { `) }) - it("keeps an explicit model and effort through real dialog mode switches", () => { + it("keeps an explicit model and effort per agent through mode switches", () => { check(` - const state = dialog({ model: x, variant: "high" }) + const state = dialog({ model: x, variant: "high" }, { + providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], + }) await Promise.resolve() + assert.deepEqual(state.model(), x) + assert.equal(state.effectiveVariant(), "high") state.selectAgent("plan") + assert.deepEqual(state.model(), y) + assert.equal(state.effectiveVariant(), undefined) + state.selectAgent("code") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") assert.deepEqual(state.cached().model, x) assert.equal(state.cached().variant, "high") - state.selectAgent("code") + `) + }) + + it("keeps picks per agent so switching modes and back restores the choice", () => { + check(` + const state = dialog({}, { + providers: catalog(x, y, z), fallback: x, alternate: z, ready: true, connected: [], + }) assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, []) + state.pick("kilo", "y") + state.selectAgent("plan") + assert.deepEqual(state.model(), z) + state.selectAgent("code") + assert.deepEqual(state.model(), y) + assert.deepEqual(state.cached().model, y) `) }) @@ -197,67 +209,60 @@ describe("NewWorktreeDialog models", () => { }) state.pick("kilo", "y") assert.equal(state.variant(), ${JSON.stringify(expected)}) - assert.deepEqual(state.preferences, [["code", y, ${JSON.stringify(expected)}]]) `) }) - it("pins the displayed inherited model and effort on mode switch without saving a model preference", () => { + it("switches to the target agent's default when it has no dialog pick", () => { check(` const state = dialog({}, { providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], - efforts: { code: "high", plan: undefined }, + efforts: { code: "high" }, }) await Promise.resolve() - state.selectAgent("plan") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") - state.refresh({ providers: catalog(y) }) + state.selectAgent("plan") assert.deepEqual(state.model(), y) + assert.equal(state.effectiveVariant(), undefined) state.selectAgent("code") - state.refresh({ providers: catalog(x, y) }) assert.deepEqual(state.model(), x) + assert.equal(state.effectiveVariant(), "high") assert.equal(state.selection.choice(), undefined) assert.equal(state.cached().model, undefined) - assert.deepEqual(state.preferences, []) `) }) - it.each([ - [undefined, "low"], - ["", undefined], - ["high", "high"], - ])("keeps outgoing effort %s distinct from an unset choice on mode switch", (value, expected) => { + it("resolves the target agent's configured effort when it has no pick", () => { check(` const providers = catalog(x) providers.kilo.models.x.variants = { low: {}, high: {} } - const state = dialog(${value === "" ? '{ variant: "" }' : "{}"}, { + const state = dialog({}, { providers, fallback: x, alternate: x, ready: true, connected: [], - efforts: { code: ${JSON.stringify(value)}, plan: "low" }, + efforts: { code: "high", plan: "low" }, }) await Promise.resolve() state.selectAgent("plan") - assert.equal(state.effectiveVariant(), ${JSON.stringify(expected)}) - assert.equal(state.variant(), ${JSON.stringify(value)}) - assert.deepEqual(state.preferences, []) + assert.equal(state.effectiveVariant(), "low") + assert.equal(state.variant(), undefined) + state.selectAgent("code") + assert.equal(state.effectiveVariant(), "high") + assert.equal(state.variant(), undefined) `) }) - it.each(["", "high"])("keeps inherited raw effort %s through catalog changes and a mode switch", (value) => { + it.each(["", "high"])("keeps inherited raw effort %s through catalog changes", (value) => { check(` const providers = catalog(x) providers.kilo.models.x.variants = { low: {} } const state = dialog({}, { providers, fallback: x, alternate: x, ready: true, connected: [], - efforts: { code: ${JSON.stringify(value)}, plan: "low" }, + efforts: { code: ${JSON.stringify(value)} }, }) await Promise.resolve() assert.equal(state.variant(), undefined) assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"low"'}) - state.selectAgent("plan") - assert.equal(state.variant(), ${JSON.stringify(value)}) state.refresh({ providers: catalog(x) }) assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"high"'}) - assert.deepEqual(state.preferences, []) `) }) @@ -275,7 +280,6 @@ describe("NewWorktreeDialog models", () => { reopened.refresh({ providers: catalog(x, y), ready: true }) assert.deepEqual(reopened.model(), x) assert.equal(reopened.effectiveVariant(), "high") - assert.deepEqual(reopened.preferences, []) `) }) @@ -291,9 +295,10 @@ describe("NewWorktreeDialog models", () => { assert.equal(state.cached().variant, "high") state.selectAgent("plan") state.refresh({ providers: catalog(x, y) }) + assert.deepEqual(state.model(), y) + state.selectAgent("code") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, []) `) }) @@ -309,139 +314,75 @@ describe("NewWorktreeDialog models", () => { state.pick("kilo", "z") assert.deepEqual(state.model(), z) assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, [["code", z, "high"]]) `) }) - it("shares only explicit single-model picks and keeps explicit Default across mode switches", () => { + it("keeps an explicit Default per agent across mode switches", () => { check(` const state = dialog({}, { providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], efforts: { code: "high", plan: "high" }, }) await Promise.resolve() - assert.deepEqual(state.preferences, []) - state.pick("kilo", "y") - assert.deepEqual(state.preferences, [["code", y, "high"]]) state.clear() - assert.deepEqual(state.preferences.at(-1), ["code", y, ""]) + assert.equal(state.variant(), "") + assert.deepEqual(state.selection.choice(), x) state.selectAgent("plan") + assert.equal(state.effectiveVariant(), "high") + state.selectAgent("code") assert.equal(state.effectiveVariant(), undefined) assert.equal(state.variant(), "") - assert.equal(state.preferences.length, 2) - state.choose("high") - assert.deepEqual(state.preferences.at(-1), ["plan", y, "high"]) const reopened = dialog(state.cached()) await Promise.resolve() - assert.deepEqual(reopened.model(), y) - assert.equal(reopened.effectiveVariant(), "high") - assert.deepEqual(reopened.preferences, []) + assert.deepEqual(reopened.model(), x) + assert.equal(reopened.effectiveVariant(), undefined) + assert.equal(reopened.variant(), "") `) }) - it("starts with the latest shared preference rather than stale dialog choices without following later updates", () => { + it("uses the agent default and variant preference when no pick is saved", () => { check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y), fallback: x, ready: true, connected: [], preferred: { ...y, variant: "" }, + const state = dialog({}, { + providers: catalog(x, y), fallback: x, ready: true, connected: [], efforts: { "code/y": "high" }, }) - await Promise.resolve() - assert.deepEqual(state.model(), y) + assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), undefined) - assert.equal(state.variant(), "") - state.refresh({ preferred: { ...x, variant: "high" } }) - assert.deepEqual(state.model(), y) - assert.equal(state.variant(), "") - assert.deepEqual(state.preferences, []) + const other = dialog({ agent: "plan" }, { + providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], efforts: { "plan/y": "high" }, + }) + assert.deepEqual(other.model(), y) + assert.equal(other.effectiveVariant(), "high") `) }) - it("adopts a delayed first hydrated preference once without following later shared updates", () => { + it("clears the saved model and effort after submit so reopening starts from config", () => { check(` - const providers = catalog(x, y, z) - providers.kilo.models.y.variants = { low: {}, high: {} } - const state = dialog({ model: x, variant: "high" }, { - providers, fallback: x, ready: true, connected: [], hydrated: false, - }) + const state = dialog() await Promise.resolve() - state.refresh({ preferred: { ...y, variant: "low" } }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - state.refresh({ hydrated: true }) - assert.deepEqual(state.model(), y) - assert.equal(state.effectiveVariant(), "low") - assert.deepEqual(state.cached().model, y) - assert.equal(state.cached().variant, "low") - state.refresh({ preferred: { ...z, variant: "high" } }) - assert.deepEqual(state.model(), y) - assert.equal(state.effectiveVariant(), "low") - assert.deepEqual(state.preferences, []) - `) - }) - - it("retains cached choices when first hydration has no preference and ignores a later preference", () => { - check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y), fallback: y, ready: true, connected: [], hydrated: false, - }) - await Promise.resolve() - state.refresh({ hydrated: true }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - state.refresh({ preferred: { ...y, variant: "" } }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") + state.pick("kilo", "x") assert.deepEqual(state.cached().model, x) - assert.equal(state.cached().variant, "high") - assert.deepEqual(state.preferences, []) + state.reset() + assert.deepEqual(state.cached(), { agent: "code", model: undefined, variant: undefined }) + assert.deepEqual(state.model(), y) `) }) - it.each(["model", "variant", "default", "mode"])( - "preserves a %s interaction before initial preferences arrive", - (action) => { - check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y, z), fallback: x, alternate: y, ready: true, connected: [], hydrated: false, - }) - const actions = { - model: () => state.pick("kilo", "z"), - variant: () => state.choose("high"), - default: state.clear, - mode: () => state.selectAgent("plan"), - } - actions[${JSON.stringify(action)}]() - await Promise.resolve() - const expected = state.model() - const effort = state.variant() - const writes = state.preferences.length - state.refresh({ hydrated: true, preferred: { ...y, variant: "" } }) - assert.deepEqual(state.model(), expected) - assert.equal(state.variant(), effort) - assert.deepEqual(state.cached().model, expected) - assert.equal(state.cached().variant, effort) - assert.equal(state.preferences.length, writes) - `) - }, - ) - - it("pins a model on explicit effort selection but never shares comparison choices", () => { + it("pins a model on explicit effort selection and keeps the choice through refreshes", () => { check(` const state = dialog() await Promise.resolve() state.choose("high") - assert.deepEqual(state.preferences, [["code", y, "high"]]) + assert.deepEqual(state.selection.choice(), y) state.refresh({ fallback: x }) assert.deepEqual(state.model(), y) - state.setCompareMode(true) const allocations = setAllocationVariant(toggleModel(new Map(), "kilo", "x", "X"), "kilo", "x", "high") assert.equal(state.selection.canSubmit(allocations), true) - state.choose(undefined) - assert.equal(state.preferences.length, 1) state.selectAgent("plan") state.refresh({ ready: false }) state.refresh({ ready: true }) - assert.deepEqual(state.preferences, [["code", y, "high"]]) + state.selectAgent("code") assert.deepEqual(state.selection.choice(), y) + assert.equal(state.variant(), "high") `) }) @@ -461,20 +402,17 @@ describe("NewWorktreeDialog models", () => { `) }) - it("never saves automatic initial, agent, or refreshed organization defaults", () => { + it("follows the target agent's default on mode switch without saving a choice", () => { check(` const { state, refresh, switchAgent, seen } = scene(undefined) assert.deepEqual(state.model(), y) assert.equal(state.choice(), undefined) refresh({ providers: catalog(y, z), alternate: z }) - batch(() => { - state.retain() - switchAgent("plan") - }) - assert.deepEqual(state.model(), y) + switchAgent("plan") + assert.deepEqual(state.model(), z) assert.equal(state.choice(), undefined) refresh({ providers: catalog(x), alternate: x }) - assert.deepEqual(seen, [y, x]) + assert.deepEqual(seen, [y, z, x]) assert.equal(state.choice(), undefined) `) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 8943bf3769b..fe37bb5016a 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -86,12 +86,14 @@ describe("sendCommand dismisses pending tool requests", () => { }) it("applies model, agent, and variant overrides when provided by a command", () => { - expect(body).toContain("if (overrides?.agent)") - expect(body).toContain("selectAgent(overrides.agent, scope)") - expect(body).toContain("if (overrides?.model)") - expect(body).toContain("selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)") - expect(body).toContain("if (overrides?.variant !== undefined)") - expect(body).toContain("selectVariant(overrides.variant, scope)") + const overrides = extractFunctionBody(source, "applyOverrides") + expect(overrides).toContain("if (overrides?.agent)") + expect(overrides).toContain("selectAgent(overrides.agent, scope)") + expect(overrides).toContain("if (overrides?.model)") + expect(overrides).toContain("selectModel(selection.providerID, selection.modelID, scope)") + expect(overrides).toContain("if (overrides?.variant !== undefined)") + expect(overrides).toContain("selectVariant(overrides.variant, scope)") + expect(body).toContain("if (effectiveSelection) applyOverrides(overrides, scope, effectiveSelection)") }) }) @@ -300,12 +302,13 @@ describe("sendMessage / sendCommand draft id contract", () => { // from ":pending:" to ":session:". The user loses the // typed message and the new session starts empty. const body = extractFunctionBody(source, "sendMessage") - expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toContain("const effectiveDraftID = mintDraft(sid, draftID, false)") }) it("sendCommand mints a draftID when there is no current session and none was supplied", () => { const body = extractFunctionBody(source, "sendCommand") - expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toContain("const effectiveDraftID = mintDraft(sid, draftID, true)") + expect(extractFunctionBody(source, "mintDraft")).toMatch(/if \(sid \|\| draftID\) return draftID/) }) it("sendMessage seeds the pending agent before resolving draft-scoped settings", () => { @@ -315,14 +318,15 @@ describe("sendMessage / sendCommand draft id contract", () => { // model with the default agent's system prompt. const body = extractFunctionBody(source, "sendMessage") expect(body).toMatch( - /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const settings = submission\(scope, selection\)/, + /const effectiveDraftID = mintDraft\(sid, draftID, false\)[\s\S]*const settings = submission\(scope, selection\)/, ) + expect(extractFunctionBody(source, "mintDraft")).toMatch(/agentDrafts\.seed\(id\)/) }) it("sendCommand seeds the pending agent before resolving draft-scoped settings", () => { const body = extractFunctionBody(source, "sendCommand") expect(body).toMatch( - /if \(!sid && !draftID && effectiveDraftID\) \{\s*agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/, + /const effectiveDraftID = mintDraft\(sid, draftID, true\)[\s\S]*submission\(scope, effectiveSelection\)/, ) }) diff --git a/packages/kilo-vscode/tests/unit/session-model-selector.test.ts b/packages/kilo-vscode/tests/unit/session-model-selector.test.ts index fb19dcc2c75..953511027df 100644 --- a/packages/kilo-vscode/tests/unit/session-model-selector.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-selector.test.ts @@ -4,7 +4,7 @@ import { createModelSelector } from "../../webview-ui/src/context/session-model- describe("model selector", () => { it("carries the active session variant for the selected model", () => { const selected = { providerID: "kilo", modelID: "old" } - const next: Array<{ id: string; selection: typeof selected }> = [] + const next: Array<{ id: string; agent: string; selection: typeof selected }> = [] const variants: Array<{ value: string | undefined; session: string | undefined }> = [] const hidden: string[] = [] const selector = createModelSelector({ @@ -12,7 +12,7 @@ describe("model selector", () => { agent: () => "code", selected: () => selected, variant: () => "high", - apply: (_agent, selection, id) => next.push({ id: id!, selection }), + apply: (agent, selection, id) => next.push({ id, agent, selection }), set: () => undefined, carry: (_selection, value, _agent, session) => variants.push({ value, session }), hide: (id) => hidden.push(id), @@ -21,22 +21,22 @@ describe("model selector", () => { selector.select("kilo", "new") const model = { providerID: "kilo", modelID: "new" } - expect(next).toEqual([{ id: "session", selection: model }]) + expect(next).toEqual([{ id: "session", agent: "code", selection: model }]) expect(variants).toEqual([{ value: "high", session: "session" }]) expect(hidden).toEqual(["session"]) }) - it("retains a session variant without persisting a global model selection", () => { + it("allocates a session model without persisting a shared pick", () => { const selected = { providerID: "kilo", modelID: "old" } - const models: Array<{ id: string; selection: typeof selected }> = [] + const models: Array<{ id: string; agent: string; selection: typeof selected }> = [] const variants: Array<{ value: string | undefined; session: string | undefined }> = [] const selector = createModelSelector({ - current: () => undefined, + current: () => "session", agent: () => "code", selected: () => selected, variant: () => "high", apply: () => undefined, - set: (id, selection) => models.push({ id, selection }), + set: (id, agent, selection) => models.push({ id, agent, selection }), carry: (_selection, value, _agent, session) => variants.push({ value, session }), hide: () => undefined, }) @@ -44,7 +44,7 @@ describe("model selector", () => { selector.session("session", "kilo", "new") const model = { providerID: "kilo", modelID: "new" } - expect(models).toEqual([{ id: "session", selection: model }]) + expect(models).toEqual([{ id: "session", agent: "code", selection: model }]) expect(variants).toEqual([{ value: "high", session: "session" }]) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index d8329d74dd7..d1d960abf79 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "bun:test" import { + COMPOSER, type ModelStore, type ResolveEnv, applyModel, getAgentModel, - getSessionModel, + getPick, getSelected, + getSessionModel, } from "../../webview-ui/src/context/session-model-store" import type { ModelSelection, Provider } from "../../webview-ui/src/types/messages" @@ -20,352 +22,181 @@ function makeProvider(id: string, models: string[]): Provider { const KILO_AUTO: ModelSelection = { providerID: "kilo", modelID: "kilo-auto/free" } const providers: Record = { - kilo: makeProvider("kilo", ["kilo-auto/free"]), + kilo: makeProvider("kilo", ["kilo-auto/free", "z-first", "z-personal", "z-recommended"]), anthropic: makeProvider("anthropic", ["claude-sonnet-4"]), openai: makeProvider("openai", ["gpt-4.1"]), } -function env(): ResolveEnv { +const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } +const first: ModelSelection = { providerID: "kilo", modelID: "z-first" } +const personal: ModelSelection = { providerID: "kilo", modelID: "z-personal" } +const recommended: ModelSelection = { providerID: "kilo", modelID: "z-recommended" } + +function env(overrides?: Partial): ResolveEnv { return { providers, connected: ["kilo", "anthropic", "openai"], + ready: true, + organizationId: null, fallback: KILO_AUTO, getModeModel: () => null, getGlobalModel: () => null, + ...overrides, } } function emptyStore(): ModelStore { return { - modelSelections: {}, sessionOverrides: {}, agentSelections: {}, recentModels: [], } } -const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } -const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } - -describe("per-session model selection", () => { - it("selecting a model in session A does not write per-mode globally", () => { +describe("per-scope per-agent model selection", () => { + it("picking a model in one session does not leak into other sessions", () => { const store = emptyStore() const e = env() - // User picks claude in session A - const after = applyModel(store, "code", claude, "session-a") - const updated: ModelStore = { ...store, ...after } + const updated: ModelStore = { ...store, ...applyModel(store, "code", claude, "session-a") } - // Session A should see claude (via session override) expect(getSessionModel(updated, e, "session-a", "code")).toEqual(claude) - - // Session B (no override) keeps the default model. - const sessionB = getSessionModel(updated, e, "session-b", "code") - expect(sessionB).toEqual(KILO_AUTO) + expect(getSessionModel(updated, e, "session-b", "code")).toEqual(KILO_AUTO) }) it("each session preserves its own model independently", () => { let store = emptyStore() const e = env() - // User picks claude in session A - const a = applyModel(store, "code", claude, "session-a") - store = { ...store, ...a } + store = { ...store, ...applyModel(store, "code", claude, "session-a") } + store = { ...store, ...applyModel(store, "code", gpt, "session-b") } - // User picks gpt in session B - const b = applyModel(store, "code", gpt, "session-b") - store = { ...store, ...b } - - // Both sessions should keep their own model expect(getSessionModel(store, e, "session-a", "code")).toEqual(claude) expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) }) - it("getSelected returns per-session override when session is active", () => { + it("picks are per agent within a scope, so switching agents re-resolves", () => { let store = emptyStore() - const e = env() + const e = env({ getModeModel: (agent) => (agent === "plan" ? gpt : null) }) - const a = applyModel(store, "code", claude, "session-a") - store = { ...store, ...a } + store = { ...store, ...applyModel(store, "code", claude, "session-a") } expect(getSelected(store, e, "session-a", "code")).toEqual(claude) + // No plan pick exists: the plan config applies, not the code pick. + expect(getSelected(store, e, "session-a", "plan")).toEqual(gpt) + // Switching back restores the code pick. + expect(getSelected(store, e, "session-a", "code")).toEqual(claude) }) - it("getSelected returns global model when no session is active", () => { + it("applyModel preserves the other agents' picks in the scope", () => { let store = emptyStore() - const e = env() + store = { ...store, ...applyModel(store, "code", claude, "session-a") } + store = { ...store, ...applyModel(store, "plan", gpt, "session-a") } - // Sidebar mode (no session) — writes globally - const result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } + expect(getPick(store, "session-a", "code")).toEqual(claude) + expect(getPick(store, "session-a", "plan")).toEqual(gpt) - expect(getSelected(store, e, undefined, "code")).toEqual(claude) + const updated: ModelStore = { ...store, ...applyModel(store, "code", personal, "session-a") } + expect(getPick(updated, "session-a", "code")).toEqual(personal) + expect(getPick(updated, "session-a", "plan")).toEqual(gpt) }) - it("sidebar model selection writes globally and is visible to new sessions without overrides", () => { - let store = emptyStore() - const e = env() + it("mode config beats stale remembered recents for scopes without picks", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [personal] } + const e = env({ getModeModel: () => first }) - // User picks claude in sidebar (no session) - const result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // A new session without an override should see the global model - expect(getSessionModel(store, e, "session-new", "code")).toEqual(claude) + expect(getSelected(store, e, "new-session", "code")).toEqual(first) + expect(getSessionModel(store, e, "new-session", "code")).toEqual(first) }) - it("setSessionModel (compare mode) only writes per-session override", () => { + it("recents apply when no config exists", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + + expect(getSelected(store, env(), "new-session", "code")).toEqual(claude) + }) + + it("global config applies when mode config is missing", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ getGlobalModel: () => gpt }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(gpt) + }) + + it("invalid configured models fall through to the next step", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ + getModeModel: () => ({ providerID: "anthropic", modelID: "missing-model" }), + getGlobalModel: () => gpt, + }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(gpt) + }) + + it("pending catalogs hide kilo models but keep explicit picks", () => { const store = emptyStore() + const e = env({ ready: false, organizationId: undefined }) - // Simulate setSessionModel — writes only to sessionOverrides - store.sessionOverrides["session-a"] = claude - store.sessionOverrides["session-b"] = gpt - - const e = env() - expect(getSessionModel(store, e, "session-a", "code")).toEqual(claude) - expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) + expect(getSelected(store, e, "session-a", "code")).toEqual(null) + expect(getAgentModel(store, e, "code")).toEqual(null) }) - it("switching sessions preserves model selection after multiple changes", () => { + it("org users skip recents and use the recommendation", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ organizationId: "org-a", defaults: { kilo: "z-recommended" } }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(recommended) + }) + + it("org users fall back to the first kilo model without a recommendation", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ organizationId: "org-a" }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(KILO_AUTO) + }) + + it("org users keep in-scope picks", () => { let store = emptyStore() - const e = env() + store = { ...store, ...applyModel(store, "code", claude, COMPOSER) } + const e = env({ organizationId: "org-a", defaults: { kilo: "z-recommended" } }) - // Simulate: user in session A picks claude - let result = applyModel(store, "code", claude, "session-a") - store = { ...store, ...result } + expect(getSelected(store, e, COMPOSER, "code")).toEqual(claude) + }) - // Switch to session B — picks gpt - result = applyModel(store, "code", gpt, "session-b") - store = { ...store, ...result } + it("getAgentModel resolves mode config, then global, then recents, then fallback", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } - // Switch back to session A — picks gpt this time - result = applyModel(store, "code", gpt, "session-a") - store = { ...store, ...result } + expect(getAgentModel(store, env({ getModeModel: () => first }), "code")).toEqual(first) + expect(getAgentModel(store, env({ getGlobalModel: () => gpt }), "code")).toEqual(gpt) + expect(getAgentModel(store, env(), "code")).toEqual(claude) + expect(getAgentModel(emptyStore(), env(), "code")).toEqual(KILO_AUTO) + }) + + it("getSessionModel resolves for the session's selected agent", () => { + let store = emptyStore() + store = { ...store, ...applyModel(store, "plan", gpt, "session-a") } + store = { ...store, agentSelections: { ...store.agentSelections, "session-a": "plan" } } + const e = env({ getModeModel: (agent) => (agent === "plan" ? personal : claude) }) - // Switch back to session B — should still have gpt - expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) - // Session A was updated to gpt expect(getSessionModel(store, e, "session-a", "code")).toEqual(gpt) + expect(getSessionModel({ ...store, agentSelections: {} }, e, "session-a", "code")).toEqual(claude) }) -}) -describe("per-mode model memory", () => { - it("uses remembered model selections for modes without configured models", () => { - const store = { ...emptyStore(), modelSelections: { ask: gpt } } - - expect(getAgentModel(store, env(), "ask")).toEqual(gpt) - }) - - it("keeps an explicit remembered model ahead of the configured mode default", () => { - const configured: ResolveEnv = { - ...env(), - getModeModel: (name) => (name === "code" ? claude : null), - } - const store = { ...emptyStore(), modelSelections: { code: gpt } } - - expect(getAgentModel(store, configured, "code", true)).toEqual(gpt) - }) - - it("applyModel in a session writes only to sessionOverrides", () => { - const store = emptyStore() - const result = applyModel(store, "code", claude, "session-a") - - expect(result.sessionOverrides["session-a"]).toEqual(claude) - expect(result.modelSelections["code"]).toBeUndefined() - }) - - it("keeps a session override when its selected mode changes", () => { - let store = emptyStore() - const e = env() - - // User picks claude for "code" mode in session A - const result = applyModel(store, "code", claude, "session-a") - store = { ...store, ...result } - - const switched = { ...store, agentSelections: { "session-a": "ask" } } - expect(getSessionModel(switched, e, "session-a", "code")).toEqual(claude) - }) - - it("different modes remember their own model independently", () => { - let store = emptyStore() - const e = env() - - // User picks claude for "code" globally - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // User switches to "ask" mode and picks gpt globally - result = applyModel(store, "ask", gpt, undefined) - store = { ...store, ...result } - - // Clear session overrides (simulating mode switch) - const cleared: ModelStore = { ...store, sessionOverrides: {} } - - // Each mode should have its own saved model - expect(getSelected(cleared, e, undefined, "code")).toEqual(claude) - expect(getSelected(cleared, e, undefined, "ask")).toEqual(gpt) - }) - - it("per-session override still takes priority over global modelSelections", () => { - let store = emptyStore() - const e = env() - - // User picks claude globally for "code" - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // Session A overrides with gpt - result = applyModel(store, "code", gpt, "session-a") - store = { ...store, ...result } - - // Session A sees gpt (its override), not the global claude - expect(getSelected(store, e, "session-a", "code")).toEqual(gpt) - // Global modelSelections stays at the sidebar/default choice. - expect(store.modelSelections["code"]).toEqual(claude) - }) - - it("applyModel without session only writes to modelSelections, not sessionOverrides", () => { - const store = emptyStore() - const result = applyModel(store, "code", claude, undefined) - - expect(result.modelSelections["code"]).toEqual(claude) - expect(Object.keys(result.sessionOverrides)).toHaveLength(0) - }) - - it("keeps a plan session's explicit model when switching to configured implementation mode", () => { + it("agent manager allocations keep the manual choice without touching other agents", () => { let store = emptyStore() - const configured: ResolveEnv = { - ...env(), - getModeModel: (name) => (name === "code" ? gpt : name === "plan" ? claude : null), - } - - // Old manual memory says implementation/code should use claude. - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // Current plan session is using its own model. - result = applyModel(store, "plan", claude, "session-a") - store = { ...store, ...result, agentSelections: { "session-a": "plan" } } - - const switched: ModelStore = { - ...store, - agentSelections: { "session-a": "code" }, - } - - expect(getSelected(switched, configured, "session-a", "code")).toEqual(claude) - }) -}) - -describe("organization model store", () => { - const first = { providerID: "kilo", modelID: "first" } - const recommendation = { providerID: "kilo", modelID: "org-default" } - const organization: ResolveEnv = { - ...env(), - ready: true, - organizationId: "org-a", - providers: { ...providers, kilo: makeProvider("kilo", [first.modelID, recommendation.modelID, KILO_AUTO.modelID]) }, - defaults: { kilo: recommendation.modelID }, - } - - it("ignores implicit mode memory and generic recents across every accessor", () => { - const store: ModelStore = { - ...emptyStore(), - modelSelections: { code: KILO_AUTO, ask: first }, - recentModels: [KILO_AUTO, gpt], - } - const before = structuredClone(store) - expect(getSelected(store, organization, undefined, "code")).toEqual(recommendation) - expect(getSelected(store, organization, "session-a", "code")).toEqual(recommendation) - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(recommendation) - expect(getAgentModel(store, organization, "ask")).toEqual(recommendation) - expect(store).toEqual(before) - }) - - it.each([undefined, "session-a"])("preserves explicit free selections in scope %s", (scope) => { - const store = emptyStore() - const updated = { ...store, ...applyModel(store, "code", KILO_AUTO, scope) } - expect(getSelected(updated, organization, scope, "code")).toEqual(KILO_AUTO) - expect(getSessionModel(updated, organization, "session-a", "code")).toEqual(KILO_AUTO) - if (!scope) expect(getAgentModel(updated, organization, "code")).toEqual(KILO_AUTO) - }) - - it.each([undefined, "session-a"])("restores explicit X through X to Y to X in scope %s without writes", (scope) => { - const store = emptyStore() - const updated = { ...store, ...applyModel(store, "code", KILO_AUTO, scope) } - const before = structuredClone(updated) - const restricted = { ...organization, providers: { kilo: makeProvider("kilo", [recommendation.modelID]) } } - expect(getSelected(updated, env(), scope, "code")).toEqual(KILO_AUTO) - expect(getSelected(updated, restricted, scope, "code")).toEqual(recommendation) - expect(getSessionModel(updated, restricted, "session-a", "code")).toEqual(recommendation) - expect(getSelected(updated, env(), scope, "code")).toEqual(KILO_AUTO) - expect(getSessionModel(updated, env(), "session-a", "code")).toEqual(KILO_AUTO) - if (!scope) { - expect(getAgentModel(updated, restricted, "code")).toEqual(recommendation) - expect(getAgentModel(updated, env(), "code")).toEqual(KILO_AUTO) - } - expect(updated).toEqual(before) - }) - - it("falls through an unavailable session override to a valid explicit manual choice", () => { - const store = { - ...emptyStore(), - modelSelections: { code: gpt }, - userSetAgents: { code: true }, - sessionOverrides: { "session-a": { providerID: "kilo", modelID: "missing" } }, - } - expect(getSelected(store, organization, "session-a", "code")).toEqual(gpt) - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(gpt) - }) - - it("validates history overrides without deleting them when the catalog is empty or pending", () => { - const store = { ...emptyStore(), sessionOverrides: { "session-a": KILO_AUTO } } - const before = structuredClone(store) - for (const pending of [{ ready: false }, { providers: {} }, { organizationId: undefined }]) { - expect(getSelected(store, { ...organization, ...pending }, "session-a", "code")).toBeNull() - expect(getSessionModel(store, { ...organization, ...pending }, "session-a", "code")).toBeNull() - } - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(KILO_AUTO) - expect(store).toEqual(before) - }) - - it("preserves connected external session choices while Kilo refreshes", () => { - const store = { ...emptyStore(), sessionOverrides: { "session-a": gpt } } - expect(getSessionModel(store, { ...organization, ready: false }, "session-a", "code")).toEqual(gpt) - expect(getSessionModel(store, { ...organization, connected: [] }, "session-a", "code")).toEqual(recommendation) - }) - - it("honors the same explicit choice in Agent Manager and the chat picker", () => { - const store = { ...emptyStore(), modelSelections: { code: KILO_AUTO }, userSetAgents: { code: true } } - const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => gpt } - expect(getAgentModel(store, configured, "code")).toEqual(KILO_AUTO) - expect(getSelected(store, configured, undefined, "code")).toEqual(KILO_AUTO) - expect(store.modelSelections.code).toEqual(KILO_AUTO) - expect(getAgentModel(store, organization, "code")).toEqual(KILO_AUTO) - }) + store = { ...store, ...applyModel(store, "code", claude, "allocated") } + store = { ...store, agentSelections: { ...store.agentSelections, allocated: "code" } } + const e = env({ getModeModel: (agent) => (agent === "code" ? first : null) }) - it("uses valid mode and global config before the recommendation when implicit memory is stale", () => { - const store = { ...emptyStore(), modelSelections: { code: KILO_AUTO } } - expect(getAgentModel(store, { ...organization, getModeModel: () => first }, "code")).toEqual(first) - expect(getSelected(store, { ...organization, getGlobalModel: () => gpt }, undefined, "code")).toEqual(gpt) + // The allocated pick beats the mode config for that session. + expect(getSessionModel(store, e, "allocated", "code")).toEqual(claude) + // New sessions use the mode config. + expect(getSessionModel(store, e, "fresh", "code")).toEqual(first) }) - it("uses the latest explicit default across modes without changing a session override", () => { - const store: ModelStore = { - ...emptyStore(), - preferred: gpt, - modelSelections: { code: KILO_AUTO, ask: first }, - userSetAgents: { code: true, ask: true }, - sessionOverrides: { active: claude }, - } - const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => KILO_AUTO } - for (const agent of ["code", "ask"]) { - expect(getAgentModel(store, configured, agent)).toEqual(gpt) - expect(getSelected(store, configured, undefined, agent)).toEqual(gpt) - expect(getSelected(store, configured, "active", agent)).toEqual(claude) - } - const unavailable = { ...configured, connected: ["kilo"] } - expect(getAgentModel(store, unavailable, "code")).toEqual(KILO_AUTO) - expect(getAgentModel(store, configured, "code")).toEqual(gpt) - expect(store.preferred).toEqual(gpt) + it("the composer scope key cannot collide with sessions, drafts, or pending tabs", () => { + expect(COMPOSER).not.toMatch(/^(?:sidebar-)?pending:/) + expect(COMPOSER).not.toMatch(/pending/) + expect(COMPOSER).not.toMatch(/^[0-9a-f]{8}-/) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts b/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts deleted file mode 100644 index 755fb605ba7..00000000000 --- a/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "bun:test" -import path from "node:path" - -describe("session preference loader", () => { - it.each(["exhaustion", "offline", "reconnect", "retry", "ready", "cleanup", "loaded", "synchronous"])( - "%s uses the production helper with browser Solid reactivity", - (name) => { - const child = Bun.spawnSync( - [ - process.execPath, - "--conditions=browser", - path.join(import.meta.dir, "../fixtures/session-preference-loader.ts"), - name, - ], - { cwd: path.join(import.meta.dir, "../../webview-ui"), stdout: "pipe", stderr: "pipe" }, - ) - expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) - }, - ) -}) diff --git a/packages/kilo-vscode/tests/unit/session-preferences.test.ts b/packages/kilo-vscode/tests/unit/session-preferences.test.ts index 4cdb88e5c3c..69754fb5046 100644 --- a/packages/kilo-vscode/tests/unit/session-preferences.test.ts +++ b/packages/kilo-vscode/tests/unit/session-preferences.test.ts @@ -15,7 +15,7 @@ function msg(input: Partial): Message { const agents = new Set(["code", "ask"]) describe("session preference recovery", () => { - it("recovers model, variant, and agent from the latest user message", () => { + it("recovers each agent's latest user message", () => { const prefs = resolveMessagePrefs( [ msg({ @@ -32,23 +32,23 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({ - agent: "code", - model: { providerID: "openai", modelID: "gpt-5.5" }, - variant: "medium", + expect(prefs.agent).toBe("code") + expect(prefs.picks).toEqual({ + ask: { model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, variant: "low", seq: 1 }, + code: { model: { providerID: "openai", modelID: "gpt-5.5" }, variant: "medium", seq: 0 }, }) }) it.each([undefined, ""])("restores model default %s instead of an older effort", (variant) => { const prefs = resolveMessagePrefs( [ - msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), - msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant } }), + msg({ agent: "code", model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), + msg({ agent: "code", model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant } }), ], agents, ) - expect(prefs.variant).toBe("") + expect(prefs.picks.code?.variant).toBe("") }) it("ignores assistant-only model data and invalid agents", () => { @@ -63,7 +63,22 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({}) + expect(prefs.agent).toBeUndefined() + expect(prefs.picks).toEqual({}) + expect(prefs.unattributed).toBeUndefined() + }) + + it("keeps agentless user messages for the session's current agent", () => { + const prefs = resolveMessagePrefs( + [msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } })], + agents, + ) + + expect(prefs.unattributed).toEqual({ + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: "high", + seq: 0, + }) }) it("can recover the latest valid agent separately from the latest user model", () => { @@ -75,10 +90,23 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({ - agent: "code", - model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - variant: "", + expect(prefs.agent).toBe("code") + expect(prefs.picks).toEqual({ + ask: { model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, variant: "", seq: 1 }, }) }) + + it("prefers the newer agentless message over an older attributed one", () => { + const prefs = resolveMessagePrefs( + [ + msg({ agent: "code", model: { providerID: "openai", modelID: "gpt-5.5" } }), + msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), + ], + agents, + ) + + expect(prefs.agent).toBe("code") + expect(prefs.picks.code?.seq).toBe(1) + expect(prefs.unattributed?.seq).toBe(0) + }) }) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts index dfa6afa7f3e..07c1c60b531 100644 --- a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -57,11 +57,11 @@ describe("per-session variant selection", () => { }) it.each(["anthropic/claude-sonnet-4", variantKey(model, "code")])( - "preserves the remembered preference %s above the configured variant", + "keeps the configured variant %s above the remembered preference", (key) => { const store = { [key]: "high" } - expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("high") - expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("high") + expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("max") + expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("max") }, ) diff --git a/packages/kilo-vscode/tests/unit/session-variants.test.ts b/packages/kilo-vscode/tests/unit/session-variants.test.ts index d83b5ba6b46..27c605a8bf0 100644 --- a/packages/kilo-vscode/tests/unit/session-variants.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variants.test.ts @@ -4,7 +4,7 @@ import type { ExtensionMessage, ModelSelection } from "../../webview-ui/src/type const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } -function setup(session?: string, configured?: string) { +function setup(session = "composer", configured?: string) { const config = { model: "anthropic/claude-sonnet-4", variant: configured } const selections: Record = {} const messages: Array<{ type: string; key?: string; value?: string }> = [] @@ -21,6 +21,7 @@ function setup(session?: string, configured?: string) { agent: () => "code", config: () => config, find: () => ({ variants: { low: {}, high: {}, max: {} } }), + draft: (id) => id === "composer" || /^(?:sidebar-)?pending:/.test(id), remember: (agent, model, variant) => remembered.push({ agent, model, variant }), post: (message) => { order.push("post") @@ -46,16 +47,16 @@ function setup(session?: string, configured?: string) { describe("session variants", () => { it("distinguishes an unset effort from an explicit Default selection", () => { const state = setup() - expect(state.variants.saved(model, "code")).toBeUndefined() + expect(state.variants.saved(model, "code", "composer")).toBeUndefined() expect(state.variants.choice()).toBeUndefined() expect(state.variants.request()).toBe("") state.variants.select("") - expect(state.variants.saved(model, "code")).toBe("") + expect(state.variants.saved(model, "code", "composer")).toBe("") expect(state.variants.choice()).toBe("") expect(state.variants.request()).toBe("") }) - it.each([undefined, "session-a"])("carries explicit Default rather than the target preference for %s", (id) => { + it.each(["composer", "session-a"])("carries explicit Default rather than the target preference for %s", (id) => { const state = setup(id, "max") state.selections["agent/code/anthropic/claude-sonnet-4"] = "high" state.variants.carry(model, "", "code", id) @@ -89,14 +90,14 @@ describe("session variants", () => { expect(state.variants.request()).toBe("max") }) - it("keeps remembered effort when configuration changes for new tabs", () => { + it("uses updated configuration ahead of remembered defaults for new tabs", () => { const state = setup("pending-new", "high") state.selections["agent/code/anthropic/claude-sonnet-4"] = "low" - expect(state.variants.current()).toBe("low") + expect(state.variants.current()).toBe("high") state.config.variant = "max" - expect(state.variants.current()).toBe("low") - expect(state.variants.request()).toBe("low") - expect(state.variants.agent("code", model)).toBe("low") + expect(state.variants.current()).toBe("max") + expect(state.variants.request()).toBe("max") + expect(state.variants.agent("code", model)).toBe("max") }) it("does not apply a configured variant to another model", () => { @@ -124,10 +125,11 @@ describe("session variants", () => { expect(state.messages).toEqual([]) }) - it("persists global selections but keeps session selections local", () => { - const global = setup() - global.variants.select("high") - expect(global.remembered).toEqual([{ agent: "code", model, variant: "high" }]) + it("keeps session selections local while drafts remember their effort", () => { + const draft = setup() + draft.variants.select("high") + expect(draft.selections["session/composer/anthropic/claude-sonnet-4"]).toBe("high") + expect(draft.remembered).toEqual([{ agent: "code", model, variant: "high" }]) const scoped = setup("session-a") scoped.variants.select("low") @@ -140,7 +142,7 @@ describe("session variants", () => { const state = setup() state.selections["agent/code/anthropic/claude-sonnet-4"] = "high" state.variants.select(undefined) - expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" }) + expect(state.selections["session/composer/anthropic/claude-sonnet-4"]).toBe("") expect(state.variants.current()).toBeUndefined() expect(state.remembered).toEqual([{ agent: "code", model, variant: "" }]) }) @@ -148,7 +150,7 @@ describe("session variants", () => { it("does not shadow a cached variant when carrying the model default", () => { const global = setup() global.selections["agent/code/anthropic/claude-sonnet-4"] = "high" - global.variants.carry(model, undefined, "code") + global.variants.carry(model, undefined, "code", "composer") expect(global.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" }) expect(global.messages).toEqual([]) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 96c043c932b..ded45ace58c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -207,13 +207,9 @@ export const NewWorktreeDialog: Component<{ agent: initialAgent, fallback: session.modelForAgent, effort: session.variantPreference, - preferred: session.preferredSelection, - hydrated: session.preferencesReady, ready: provider.ready, valid: provider.isModelValid, variants: (value) => Object.keys(provider.findModel(value)?.variants ?? {}), - compare: compareMode, - remember: session.rememberSelection, }) const { selection, model, agent, variants, effectiveVariant, selectAgent, selectModel, selectVariant } = preferences const [modelAllocations, setModelAllocations] = createSignal(new Map()) @@ -516,6 +512,8 @@ export const NewWorktreeDialog: Component<{ files: resolveFiles(payload.text), }) + // A submitted dialog starts fresh next time: keep only the agent and sandbox restore. + preferences.clear() persistPrompt("") persistImages([]) props.onClose() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts b/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts index 155a68d9040..b13a63a3a50 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts @@ -1,4 +1,4 @@ -import { batch, createEffect, createMemo, createSignal } from "solid-js" +import { batch, createMemo, createSignal } from "solid-js" import type { ModelSelection } from "../src/types/messages" import { DEFAULT_VARIANT, preserveVariant } from "../src/context/session-variant-store" import { type ModelAllocations, MAX_MULTI_VERSIONS, totalAllocations } from "./multi-model-utils" @@ -11,10 +11,9 @@ export function createDialogModels(opts: { variants: (model: ModelSelection) => string[] }) { const [choice, select] = createSignal(opts.saved) - const [held, hold] = createSignal(null) const valid = (value: ModelSelection) => (value.providerID !== "kilo" || opts.ready()) && opts.valid(value) const model = createMemo(() => { - const saved = choice() ?? held() + const saved = choice() if (saved && valid(saved)) return saved const fallback = opts.fallback() return fallback && valid(fallback) ? fallback : null @@ -31,11 +30,7 @@ export function createDialogModels(opts: { (entry.variant === undefined || opts.variants(entry).includes(entry.variant)), ) } - const retain = () => { - // Mode switches retain the displayed default without turning it into a saved user preference. - if (!choice() && !held()) hold(model()) - } - return { choice, select, model, canSubmit, retain } + return { choice, select, model, canSubmit } } export function createDialogPreferences(opts: { @@ -43,20 +38,17 @@ export function createDialogPreferences(opts: { agent: string fallback: (agent: string) => ModelSelection | null effort: (agent: string, model: ModelSelection | null) => string | undefined - preferred: () => (ModelSelection & { variant?: string }) | undefined - hydrated: () => boolean ready: () => boolean valid: (model: ModelSelection) => boolean variants: (model: ModelSelection) => string[] - compare: () => boolean - remember: (agent: string, model: ModelSelection, variant: string) => void }) { - const preferred = opts.preferred() - let pending = !opts.hydrated() + // Picks stay per agent so switching modes and back restores each agent's choice. + const picks: Record = {} + if (opts.saved.model) picks[opts.agent] = { model: opts.saved.model, variant: opts.saved.variant } const [agent, setAgent] = createSignal(opts.agent) - const [variant, setVariant] = createSignal(preferred ? preferred.variant : opts.saved.variant) + const [variant, setVariant] = createSignal(opts.saved.variant) const selection = createDialogModels({ - saved: preferred ? { providerID: preferred.providerID, modelID: preferred.modelID } : opts.saved.model, + saved: opts.saved.model, fallback: () => opts.fallback(agent()), ready: opts.ready, valid: opts.valid, @@ -72,47 +64,42 @@ export function createDialogPreferences(opts: { const effectiveVariant = createMemo(() => preserveVariant(current(), variants())) const selectAgent = (name: string) => { - pending = false - // Unset effort can inherit the next mode's default; an explicit Default must stay sticky. - const value = current() + const pick = picks[name] batch(() => { - selection.retain() - setVariant(value) + // Use the target agent's pick, otherwise its default. + selection.select(pick?.model) + setVariant(pick?.variant) setAgent(name) }) } const selectModel = (pid: string, mid: string) => { if (!pid || !mid) return - pending = false const next = { providerID: pid, modelID: mid } const effort = preserveVariant(current() ?? opts.effort(agent(), next), opts.variants(next)) ?? DEFAULT_VARIANT batch(() => { selection.select(next) setVariant(effort) - if (!opts.compare()) opts.remember(agent(), next, effort) + picks[agent()] = { model: next, variant: effort } }) } const selectVariant = (value: string | undefined) => { - pending = false const next = value ?? DEFAULT_VARIANT batch(() => { setVariant(next) const sel = model() - if (!sel || opts.compare()) return + if (!sel) return selection.select(sel) - opts.remember(agent(), sel, next) + picks[agent()] = { model: sel, variant: next } + }) + } + // Drop the model and effort after a successful submit so the persist effect + // stops writing them back; the agent and sandbox restore stays. + const clear = () => { + batch(() => { + selection.select(undefined) + setVariant(undefined) }) } - createEffect(() => { - if (!pending || !opts.hydrated()) return - // Initial host preferences may arrive after opening, but never replace an in-progress choice. - pending = false - const preferred = opts.preferred() - if (!preferred) return - selection.select({ providerID: preferred.providerID, modelID: preferred.modelID }) - setVariant(preferred.variant) - }) - return { selection, model, @@ -123,6 +110,7 @@ export function createDialogPreferences(opts: { selectAgent, selectModel, selectVariant, + clear, saved: () => ({ agent: agent(), model: selection.choice(), variant: variant() }), } } diff --git a/packages/kilo-vscode/webview-ui/src/context/model-selection.ts b/packages/kilo-vscode/webview-ui/src/context/model-selection.ts index 2ef584e1ef0..543a41bbabc 100644 --- a/packages/kilo-vscode/webview-ui/src/context/model-selection.ts +++ b/packages/kilo-vscode/webview-ui/src/context/model-selection.ts @@ -8,8 +8,6 @@ export function resolveModelSelection(input: { organizationId?: string | null defaults?: Record session?: ModelSelection | null - preferred?: ModelSelection | null - override?: ModelSelection | null mode?: ModelSelection | null global?: ModelSelection | null recent?: ModelSelection[] @@ -20,12 +18,7 @@ export function resolveModelSelection(input: { if (!selection || (pending && selection.providerID === "kilo")) return null return isModelValid(input.providers, input.connected, selection) ? selection : null } - const preference = - validate(input.session) ?? - validate(input.preferred) ?? - validate(input.override) ?? - validate(input.mode) ?? - validate(input.global) + const preference = validate(input.session) ?? validate(input.mode) ?? validate(input.global) if (preference) return preference if (pending) return null if (input.organizationId) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts b/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts index 128c3d6c7af..cd1e5200edb 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts @@ -1,28 +1,24 @@ -import { batch, createEffect, createSignal, on, type Accessor } from "solid-js" -import type { ModelSelection, WebviewMessage } from "../types/messages" +import { createEffect, createSignal, on, type Accessor } from "solid-js" +import type { ModelSelection } from "../types/messages" import { DEFAULT_VARIANT, sessionVariantKeys, variantKey } from "./session-variant-store" interface Store { - modelSelections: Record - sessionOverrides: Record + sessionOverrides: Record> agentSelections: Record variantSelections: Record } export function createModelPreferences(options: { store: Store - model: (scope: "modelSelections" | "sessionOverrides", id: string, model: ModelSelection) => void + model: (id: string, agent: string, model: ModelSelection) => void set: (key: string, variant: string) => void clear: (update: (store: Store) => void) => void scopes: () => (string | undefined)[] initialized: (id: string) => boolean selected: (id: string) => ModelSelection | null - defaults: (agent: string) => ModelSelection | null | undefined agent: (id: string) => string variant: (id: string, model: ModelSelection) => string | undefined recent: (model: ModelSelection) => void - update: (agent: string, model: ModelSelection, variant: string) => void - post: (message: WebviewMessage) => void }) { const inventories = new Set>() const [version, setVersion] = createSignal(0) @@ -58,50 +54,30 @@ export function createModelPreferences(options: { } function pin(id: string, freeze = false) { - if (!options.store.sessionOverrides[id] && !options.initialized(id)) return - const model = options.store.sessionOverrides[id] ?? options.defaults(options.agent(id)) ?? options.selected(id) + const agent = options.agent(id) + if (!options.store.sessionOverrides[id]?.[agent] && !options.initialized(id)) return + const model = options.store.sessionOverrides[id]?.[agent] ?? options.selected(id) if (!model) return - const key = variantKey(model, options.agent(id), id) + const key = variantKey(model, agent, id) // Freeze this draft's displayed Default before another scope changes shared preferences. // Otherwise leave unset effort available for mode defaults, rather than inventing a choice. const value = options.variant(id, model) ?? (freeze ? DEFAULT_VARIANT : undefined) if (options.store.variantSelections[key] === undefined && value !== undefined) options.set(key, value) // Copy inherited models so updates to a mode's store cannot mutate the session. - if (!options.store.sessionOverrides[id]) options.model("sessionOverrides", id, { ...model }) + if (!options.store.sessionOverrides[id]?.[agent]) options.model(id, agent, { ...model }) } function retain() { for (const id of sync(scopes())) pin(id, true) } - function apply(agent: string, model: ModelSelection, id?: string) { - if (id) { - if (!/^(?:sidebar-)?pending:/.test(id)) options.recent(model) - options.model("sessionOverrides", id, model) - return - } + function apply(agent: string, model: ModelSelection, id: string) { + // Pin every other open scope first so pushing this pick to recents cannot + // change what they display. retain() - options.model("modelSelections", agent, model) + options.recent(model) + options.model(id, agent, model) } - function remember(agent: string, model: ModelSelection, variant = "") { - batch(() => { - // New-session defaults must not change other open sessions or drafts. - retain() - options.recent(model) - options.model("modelSelections", agent, { ...model }) - options.set(variantKey(model, agent), variant) - options.update(agent, model, variant) - }) - options.post({ - type: "persistModelSelection", - agent, - providerID: model.providerID, - modelID: model.modelID, - variant, - }) - options.post({ type: "persistVariant", key: variantKey(model, agent), value: variant }) - } - - return { apply, pin, remember, track, forget } + return { apply, pin, retain, track, forget } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-model-selector.ts b/packages/kilo-vscode/webview-ui/src/context/session-model-selector.ts index b014df54a21..da70094e833 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-model-selector.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-model-selector.ts @@ -1,36 +1,33 @@ import type { ModelSelection } from "../types/messages" -type Deps = { - current: () => string | undefined - agent: (session?: string) => string - selected: (session?: string) => ModelSelection | null - variant: (session?: string) => string | undefined - apply: (agent: string, selection: ModelSelection, session?: string) => void - set: (session: string, selection: ModelSelection) => void - carry: (selection: ModelSelection, value: string | undefined, agent: string, session?: string) => void - hide: (session: string) => void -} - -export function createModelSelector(deps: Deps) { +export function createModelSelector(deps: { + current: () => string + agent: (sessionID?: string) => string + selected: (sessionID?: string) => ModelSelection | null + variant: (sessionID?: string) => string | undefined + apply: (agent: string, selection: ModelSelection, id: string) => void + set: (id: string, agent: string, selection: ModelSelection) => void + carry: (selection: ModelSelection, value: string | undefined, agent: string, sessionID?: string) => void + hide: (sessionID?: string) => void +}) { const select = (providerID: string, modelID: string, sessionID?: string) => { const session = sessionID ?? deps.current() const agent = deps.agent(session) - const current = deps.selected(session) - const value = current ? deps.variant(session) : undefined + const value = deps.variant(session) const selection = { providerID, modelID } deps.apply(agent, selection, session) deps.carry(selection, value, agent, session) if (session) deps.hide(session) } + const session = (sessionID: string, providerID: string, modelID: string) => { - // Session overrides must not mutate the per-mode selection while a new - // Agent Manager session is still being assigned its agent. const agent = deps.agent(sessionID) - const current = deps.selected(sessionID) - const value = current ? deps.variant(sessionID) : undefined + const value = deps.variant(sessionID) const selection = { providerID, modelID } - deps.set(sessionID, selection) + // Session allocations must not mutate per-mode picks or push recents. + deps.set(sessionID, agent, selection) deps.carry(selection, value, agent, sessionID) } + return { select, session } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-model-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-model-store.ts index 3b27eb61991..82070051f0c 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-model-store.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-model-store.ts @@ -2,22 +2,21 @@ import type { ModelSelection, Provider } from "../types/messages" import { resolveModelSelection } from "./model-selection" /** - * Pure-logic helpers for per-session and global model selection. + * Pure-logic helpers for per-session and per-agent model selection. * * The SessionProvider delegates to these so the core state transitions * can be tested without SolidJS reactivity. */ +/** Scope key for the no-session composer. Cannot collide with `ses_*` IDs, UUID drafts, or `pending:` tabs. */ +export const COMPOSER = "composer" + export interface ModelStore { - /** agentName -> model (global, extension-lifetime) */ - modelSelections: Record - /** sessionID -> per-session model override */ - sessionOverrides: Record + /** scope -> agent -> explicit model pick */ + sessionOverrides: Record> /** sessionID -> agent name */ agentSelections: Record recentModels: ModelSelection[] - userSetAgents?: Record - preferred?: ModelSelection } export interface ResolveEnv { @@ -34,10 +33,8 @@ export interface ResolveEnv { function resolveModel( env: ResolveEnv, agentName: string, - override?: ModelSelection | null, - recents?: ModelSelection[], session?: ModelSelection, - preferred?: ModelSelection, + recents?: ModelSelection[], ): ModelSelection | null { return resolveModelSelection({ providers: env.providers, @@ -46,8 +43,6 @@ function resolveModel( organizationId: env.organizationId, defaults: env.defaults, session, - preferred: preferred && { providerID: preferred.providerID, modelID: preferred.modelID }, - override, mode: env.getModeModel(agentName), global: env.getGlobalModel(), recent: recents, @@ -55,10 +50,15 @@ function resolveModel( }) } +/** Pick(scope, agent): the model the user explicitly chose in that scope while on that agent. */ +export function getPick(store: ModelStore, scope: string | undefined, agent: string): ModelSelection | undefined { + return scope ? store.sessionOverrides[scope]?.[agent] : undefined +} + /** - * Returns the model for a specific session, honoring per-session overrides. + * Returns the model for a specific session, honoring its per-agent picks. * - * Precedence: sessionOverride > global modelSelections[agent] > config/default. + * Precedence: Pick(session, agent) > agent config > global config > org/recents > fallback. */ export function getSessionModel( store: ModelStore, @@ -73,7 +73,7 @@ export function getSessionModel( /** * Returns the model for the "current" view (model picker display). * - * Precedence: sessionOverride[sid] > global modelSelections[agent] > config/default. + * Precedence: Pick(scope, agent) > agent config > global config > org/recents > fallback. */ export function getSelected( store: ModelStore, @@ -81,56 +81,27 @@ export function getSelected( sessionID: string | undefined, agentName: string, ): ModelSelection | null { - const override = env.organizationId && !store.userSetAgents?.[agentName] ? null : store.modelSelections[agentName] - return resolveModel( - env, - agentName, - override, - store.recentModels, - sessionID ? store.sessionOverrides[sessionID] : undefined, - store.preferred, - ) + return resolveModel(env, agentName, getPick(store, sessionID, agentName), store.recentModels) } -/** Returns the effective model for a mode outside a session scope. */ -export function getAgentModel( - store: ModelStore, - env: ResolveEnv, - agentName: string, - userSet = store.userSetAgents?.[agentName] === true, -): ModelSelection | null { - const override = env.organizationId && !userSet ? null : store.modelSelections[agentName] - return resolveModel(env, agentName, override, store.recentModels, undefined, store.preferred) +/** Returns the effective model for an agent outside any pick scope. */ +export function getAgentModel(store: ModelStore, env: ResolveEnv, agentName: string): ModelSelection | null { + return resolveModel(env, agentName, undefined, store.recentModels) } export interface ApplyResult { - modelSelections: Record - sessionOverrides: Record - userSetAgents: Record + sessionOverrides: Record> } /** - * Apply a user-initiated model selection. - * - * Session-scoped selections write only to the per-session override. - * No-session selections write to the global modelSelections map so sidebar - * default picks still mirror CLI TUI's model.json behavior. + * Apply a user-initiated model selection: write Pick(scope, agent). */ export function applyModel( store: ModelStore, agentName: string, selection: ModelSelection, - sessionID: string | undefined, + sessionID: string, ): ApplyResult { - const modelSelections = sessionID - ? { ...store.modelSelections } - : { ...store.modelSelections, [agentName]: selection } - const sessionOverrides = { ...store.sessionOverrides } - - if (sessionID) { - sessionOverrides[sessionID] = selection - } - - const userSetAgents = sessionID ? { ...store.userSetAgents } : { ...store.userSetAgents, [agentName]: true } - return { modelSelections, sessionOverrides, userSetAgents } + const agents = { ...store.sessionOverrides[sessionID], [agentName]: selection } + return { sessionOverrides: { ...store.sessionOverrides, [sessionID]: agents } } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts b/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts deleted file mode 100644 index 7adcdeb1cd4..00000000000 --- a/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createComputed, on, onCleanup, type Accessor } from "solid-js" - -export function createPreferenceLoader(opts: { - ready: Accessor - connected: Accessor - request: () => void -}): () => void { - let timer: ReturnType | undefined - let attempts = 0 - let disposed = false - - function cancel() { - clearTimeout(timer) - timer = undefined - } - - function schedule() { - if (disposed || opts.ready() || !opts.connected()) return - timer = setTimeout(() => { - timer = undefined - if (disposed || opts.ready() || !opts.connected()) return - if (attempts === 4) { - // Exhaustion is not hydration: a late saved preference must still apply. - console.warn("[Kilo New] Model preferences did not load after 4 attempts; waiting for a later retry") - return - } - attempts++ - opts.request() - schedule() - }, 3000) - } - - function retry() { - if (disposed || opts.ready()) return - cancel() - attempts = 1 - opts.request() - schedule() - } - - onCleanup(() => { - disposed = true - cancel() - }) - - createComputed( - on([opts.ready, opts.connected], ([ready, connected], previous) => { - cancel() - // The host may already know the disk path before the backend connects. - if (!ready && (connected || !previous)) retry() - }), - ) - - return retry -} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-preferences.ts b/packages/kilo-vscode/webview-ui/src/context/session-preferences.ts index 744489cfd51..d5ab61b261c 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-preferences.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-preferences.ts @@ -3,24 +3,34 @@ import { DEFAULT_VARIANT } from "./session-variant-store" export interface MessagePrefs { agent?: string - model?: ModelSelection - variant?: string + /** agent -> latest user message model + variant for that agent */ + picks: Record + /** Latest user message that names no valid agent, attributed to the session's current agent */ + unattributed?: { model: ModelSelection; variant: string; seq: number } } +/** + * Derives per-agent model picks from message history, walking backwards so + * each agent keeps the model of its own latest user message. + */ export function resolveMessagePrefs(messages: Message[], names: Set): MessagePrefs { - const prefs: MessagePrefs = {} - for (let i = messages.length - 1; i >= 0; i--) { + const picks: Record = {} + let unattributed: MessagePrefs["unattributed"] + let agent: string | undefined + for (let i = messages.length - 1, seq = 0; i >= 0; i--, seq++) { const msg = messages[i] if (!msg) continue - if (!prefs.agent) { - const agent = msg.agent?.trim() - if (agent && names.has(agent)) prefs.agent = agent + const name = msg.agent?.trim() + const valid = name && names.has(name) ? name : undefined + if (!agent && valid) agent = valid + if (msg.role !== "user" || !msg.model?.providerID || !msg.model.modelID) continue + const model = { providerID: msg.model.providerID, modelID: msg.model.modelID } + const variant = msg.model.variant ?? DEFAULT_VARIANT + if (valid) { + if (!picks[valid]) picks[valid] = { model, variant, seq } + continue } - if (!prefs.model && msg.role === "user" && msg.model?.providerID && msg.model.modelID) { - prefs.model = { providerID: msg.model.providerID, modelID: msg.model.modelID } - prefs.variant = msg.model.variant ?? DEFAULT_VARIANT - } - if (prefs.agent && prefs.model) break + if (!unattributed) unattributed = { model, variant, seq } } - return prefs + return { agent, picks, unattributed } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts index a1a6fddc69c..3e3b6a9e2ba 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -108,13 +108,10 @@ export interface SessionContextValue { scopedQuestions: (sessionID: string | undefined) => QuestionRequest[] scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[] - // Model selection (global, extension-lifetime) + // Model selection (per-scope picks over per-agent config) selected: (sessionID?: string) => ModelSelection | null modelForAgent: (agent: string) => ModelSelection | null selectModel: (providerID: string, modelID: string, sessionID?: string) => void - preferredSelection: Accessor<(ModelSelection & { variant?: string }) | undefined> - preferencesReady: Accessor - rememberSelection: (agent: string, model: ModelSelection, variant?: string) => void trackScopes: (ids: Accessor) => () => void // Cost and context usage for the current session diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts index d2ede4e2aeb..1b11641008a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts @@ -40,12 +40,11 @@ export function getVariant( agent: string, session?: string, configured?: string, - preferred?: string, ) { if (variants.length === 0) return undefined const scoped = session ? store[variantKey(sel, agent, session)] : undefined const preset = configured && variants.includes(configured) ? configured : undefined - const stored = scoped ?? preferred ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)] ?? preset + const stored = scoped ?? preset ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)] ?? preset if (stored === undefined || stored === DEFAULT_VARIANT) return undefined return preserveVariant(stored, variants) } @@ -56,10 +55,9 @@ export function getAgentVariant( model: { variants?: Record } | undefined, agent: string, configured?: string, - preferred?: string, ) { if (!model?.variants) return undefined - return getVariant(store, sel, Object.keys(model.variants), agent, undefined, configured, preferred) + return getVariant(store, sel, Object.keys(model.variants), agent, undefined, configured) } /** diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts index a0ab331b5a5..0c3b4e421e7 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts @@ -19,13 +19,13 @@ interface Options { selections: Accessor> set: (key: string, value: string) => void selected: (sessionID?: string) => ModelSelection | null - session: Accessor + session: Accessor agent: (sessionID?: string) => string config: (agent: string) => Pick | undefined find: (selection: ModelSelection) => Model | undefined post: (message: Message) => void listen: (handler: (message: ExtensionMessage) => void) => () => void - preferred?: Accessor<(ModelSelection & { variant?: string }) | undefined> + draft: (id: string) => boolean remember: (agent: string, model: ModelSelection, variant: string) => void } @@ -42,22 +42,9 @@ export function createSessionVariants(options: Options) { return config.variant ?? undefined } - const preferred = (selection: ModelSelection) => { - const value = options.preferred?.() - if (value?.providerID !== selection.providerID || value.modelID !== selection.modelID) return undefined - return value.variant ?? DEFAULT_VARIANT - } - const agent = (name: string, selection: ModelSelection | null) => { if (!selection) return undefined - return getAgentVariant( - options.selections(), - selection, - options.find(selection), - name, - configured(name, selection), - preferred(selection), - ) + return getAgentVariant(options.selections(), selection, options.find(selection), name, configured(name, selection)) } const current = (sessionID?: string) => { @@ -67,15 +54,7 @@ export function createSessionVariants(options: Options) { const variants = list(sid) if (variants.length === 0) return undefined const name = options.agent(sid) - return getVariant( - options.selections(), - selection, - variants, - name, - sid, - configured(name, selection), - preferred(selection), - ) + return getVariant(options.selections(), selection, variants, name, sid, configured(name, selection)) } const request = (sessionID?: string) => @@ -83,7 +62,6 @@ export function createSessionVariants(options: Options) { const saved = (selection: ModelSelection, name: string, sessionID?: string) => (sessionID ? options.selections()[variantKey(selection, name, sessionID)] : undefined) ?? - preferred(selection) ?? options.selections()[variantKey(selection, name)] ?? options.selections()[legacyVariantKey(selection)] ?? configured(name, selection) @@ -98,12 +76,9 @@ export function createSessionVariants(options: Options) { const sid = sessionID ?? options.session() const selection = options.selected(sid) if (!selection) return - const key = variantKey(selection, options.agent(sid), sid) const next = value ?? DEFAULT_VARIANT - options.set(key, next) - if (!sid || /^(?:sidebar-)?pending:/.test(sid)) { - options.remember(options.agent(sid), selection, next) - } + options.set(variantKey(selection, options.agent(sid), sid), next) + if (options.draft(sid)) options.remember(options.agent(sid), selection, next) } const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => { @@ -112,9 +87,7 @@ export function createSessionVariants(options: Options) { // Undefined leaves the target's effort intact; an explicit Default must be carried. const next = value === DEFAULT_VARIANT ? DEFAULT_VARIANT : preserveVariant(value, list) if (next === undefined) return - const key = variantKey(selection, name, sessionID) - options.set(key, next) - if (!sessionID) options.post({ type: "persistVariant", key, value: next }) + options.set(variantKey(selection, name, sessionID), next) } const load = () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 868e37c66fb..4f5f29e424e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -81,7 +81,8 @@ import { } from "./session-utils" import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" -import { getAgentModel, getSelected, getSessionModel } from "./session-model-store" +import { COMPOSER, getAgentModel, getSelected, getSessionModel } from "./session-model-store" +import { isModelValid } from "./provider-utils" import { resolveMessagePrefs } from "./session-preferences" import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" @@ -106,7 +107,6 @@ import { isSameSessionTree } from "./model-usage" import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent" import { createModelSelector } from "./session-model-selector" import { createModelPreferences } from "./session-model-preferences" -import { createPreferenceLoader } from "./session-preference-loader" import { activities, blockedSessionIds, type Activity } from "../utils/session-activity" import { createTiming, running, type Timing } from "./session-timing" import type { SessionContextValue } from "./session-types" @@ -120,8 +120,7 @@ interface SessionStore { parts: Record // messageID -> parts toolParts: Record // sessionID -> compact per-session tool index todos: Record // sessionID -> todos - modelSelections: Record // agentName -> model (global, extension-lifetime) - sessionOverrides: Record // sessionID -> per-session model override (compare mode) + sessionOverrides: Record> // scope -> agent -> explicit model pick agentSelections: Record // sessionID -> agent name variantSelections: Record // session/agent scoped variant key -> variant name recentModels: ModelSelection[] @@ -258,13 +257,6 @@ export const SessionProvider: ParentComponent = (props) => { const [suggestionErrors, setSuggestionErrors] = createSignal>(new Set()) const [respondingSuggestions, setRespondingSuggestions] = createSignal>(new Set()) - // Tracks whether the user has explicitly set a model override per agent (to - // prevent the default-sync effect from overwriting it). - const [userSetAgents, setUserSetAgents] = createSignal>({}) - const [preferredSelection, setPreferredSelection] = createSignal() - const [preferencesReady, setPreferencesReady] = createSignal(false) - let remembered = false - // Agents (modes) loaded from the CLI backend const [agents, setAgents] = createSignal([]) const [allAgents, setAllAgents] = createSignal([]) @@ -400,7 +392,6 @@ export const SessionProvider: ParentComponent = (props) => { parts: {}, toolParts: {}, todos: {}, - modelSelections: {}, sessionOverrides: {}, agentSelections: {}, variantSelections: {}, @@ -439,9 +430,13 @@ export const SessionProvider: ParentComponent = (props) => { }) function agentForScope(sessionID?: string) { - if (sessionID) return store.agentSelections[sessionID] ?? defaultAgent() - return selectedAgentName() + if (sessionID && sessionID !== COMPOSER) return store.agentSelections[sessionID] ?? defaultAgent() + // The composer scope follows the pending agent, even when a session is open + // elsewhere; forced-fresh-draft commands resolve through it. + return pendingAgentSelection() ?? defaultAgent() } + // Draft scopes keep per-agent picks that transfer on promotion and remember effort. + const isDraftScope = (id: string) => id === COMPOSER || /^(?:sidebar-)?pending:/.test(id) const agentDrafts = createDraftAgentSeed({ selections: () => store.agentSelections, pending: pendingAgentSelection, @@ -482,36 +477,37 @@ export const SessionProvider: ParentComponent = (props) => { function preferences() { return { - modelSelections: store.modelSelections, sessionOverrides: store.sessionOverrides, agentSelections: store.agentSelections, recentModels: store.recentModels, - userSetAgents: userSetAgents(), - preferred: preferredSelection(), } } - // Keep automatic defaults in sync until the user chooses a model. + // Warn once per (agent, model) when a configured model is unavailable, so a + // config edit that silently falls through to defaults stays visible. + const warnedModels = new Set() createEffect(() => { - const agentName = selectedAgentName() - if (userSetAgents()[agentName]) return - const sel = resolveModelSelection({ - ...environment(), - mode: getModeModel(agentName), - global: getGlobalModel(), - recent: store.recentModels, - }) - setStore("modelSelections", agentName, sel && { ...sel }) + const env = environment() + if (env.ready !== true) return + for (const agent of agents()) { + const model = env.getModeModel(agent.name) + if (!model) continue + const key = `${agent.name}/${model.providerID}/${model.modelID}` + if (warnedModels.has(key)) continue + if (isModelValid(env.providers, env.connected, model)) continue + warnedModels.add(key) + console.warn(`[Kilo New] Configured model for ${agent.name} is unavailable: ${model.providerID}/${model.modelID}`) + } }) - const currentSelected = createMemo(() => - getSelected(preferences(), environment(), currentSessionID(), selectedAgentName()), - ) + const currentSelected = createMemo(() => selected()) - // Precedence: scoped override > per-agent global/default > config/default. + // Precedence: Pick(scope, agent) > agent config > global config > org/recents > fallback. + // The no-session composer keeps its own picks under the reserved COMPOSER scope. function selected(sessionID?: string): ModelSelection | null { - if (!sessionID) return currentSelected() - return getSessionModel(preferences(), environment(), sessionID, defaultAgent()) + const id = sessionID ?? currentSessionID() ?? COMPOSER + if (id === COMPOSER) return getSelected(preferences(), environment(), COMPOSER, selectedAgentName()) + return getSessionModel(preferences(), environment(), id, defaultAgent()) } function pushRecent(selection: ModelSelection) { @@ -530,66 +526,72 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "recordModelUsage", providerID, modelID }) } + // Pick(scope, agent) writer: sessionOverrides[scope] may not exist yet. + function setPick(id: string, agent: string, model: ModelSelection) { + setStore( + produce((store) => { + store.sessionOverrides[id] = { ...store.sessionOverrides[id], [agent]: model } + }), + ) + } + const memory = createModelPreferences({ store, - model: (scope, id, model) => setStore(scope, id, model), + model: setPick, set: (key, value) => setStore("variantSelections", key, value), clear: (update) => setStore(produce((store) => update(store))), scopes: () => [currentSessionID(), draftSessionID(), ...Object.keys(submissionMap)], initialized: (id) => /^(?:sidebar-)?pending:/.test(id) || isSubmitting(id) || store.messages[id] !== undefined, selected, - defaults: (agent) => - preferredSelection() ?? - (userSetAgents()[agent] ? store.modelSelections[agent] : undefined) ?? - getModeModel(agent) ?? - getGlobalModel(), agent: agentForScope, - variant: (id, model) => variants.saved(model, agentForScope(id), id), + variant: (id) => variants.current(id), recent: pushRecent, - update: (agent, model, variant) => { - remembered = true - setUserSetAgents((prev) => ({ ...prev, [agent]: true })) - setPreferredSelection({ ...model, variant }) - }, - post: vscode.postMessage, }) - const rememberSelection = memory.remember + + // Draft and no-session picks remember their effort for (agent, model) so the + // next draft starts where the last one left off. + function rememberEffort(agent: string, model: ModelSelection, variant: string) { + memory.retain() + const key = variantKey(model, agent) + setStore("variantSelections", key, variant) + vscode.postMessage({ type: "persistVariant", key, value: variant }) + } const variants = createSessionVariants({ selections: () => store.variantSelections, set: (key, value) => setStore("variantSelections", key, value), selected, - session: currentSessionID, + session: () => currentSessionID() ?? COMPOSER, agent: agentForScope, config: (agent) => config().agent?.[agent], find: provider.findModel, post: vscode.postMessage, listen: vscode.onMessage, - preferred: preferredSelection, - remember: rememberSelection, + draft: isDraftScope, + remember: rememberEffort, }) const { carry: carryVariant, list: variantList, agent: variantForAgent, current: currentVariant } = variants const selectVariant = variants.select const models = createModelSelector({ - current: currentSessionID, + current: () => currentSessionID() ?? COMPOSER, agent: agentForScope, selected, variant: variants.choice, apply: memory.apply, - set: (id, selection) => setStore("sessionOverrides", id, selection), + set: setPick, carry: carryVariant, hide: hideErrors, }) function selectModel(providerID: string, modelID: string, sessionID?: string) { - const id = sessionID ?? currentSessionID() + const id = sessionID ?? currentSessionID() ?? COMPOSER batch(() => { models.select(providerID, modelID, id) - if (!id || /^(?:sidebar-)?pending:/.test(id)) { + if (isDraftScope(id)) { const model = { providerID, modelID } const agent = agentForScope(id) const value = store.variantSelections[variantKey(model, agent, id)] ?? variantForAgent(agent, model) const list = Object.keys(provider.findModel(model)?.variants ?? {}) - rememberSelection(agent, model, value === "" ? "" : preserveVariant(value, list)) + rememberEffort(agent, model, value === "" ? "" : (preserveVariant(value, list) ?? "")) } }) } @@ -637,8 +639,8 @@ export const SessionProvider: ParentComponent = (props) => { } } - function hideErrors(sid: string) { - const ids = errorIDs(store.messages[sid] ?? []) + function hideErrors(sid?: string) { + const ids = errorIDs(store.messages[sid ?? ""] ?? []) if (ids.length === 0) return setHiddenErrors((prev) => { const next = new Set(prev) @@ -758,7 +760,6 @@ export const SessionProvider: ParentComponent = (props) => { if (message.type !== "extensionDataReady") return unsubReady() clearTimeout(fallback) - retryPreferences() if (agents().length === 0) vscode.postMessage({ type: "requestAgents" }) if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" }) }) @@ -773,33 +774,6 @@ export const SessionProvider: ParentComponent = (props) => { onCleanup(variants.load()) - // Load persisted per-mode model selections from model.json via extension host. - // Uses replace semantics so an empty payload clears old entries. - const unsubSelections = vscode.onMessage((message: ExtensionMessage) => { - if (message.type !== "modelSelectionsLoaded") return - batch(() => { - const local = - !preferencesReady() && remembered - ? Object.fromEntries(Object.entries(store.modelSelections).filter(([name]) => userSetAgents()[name])) - : {} - const selections = { ...message.selections, ...local } - setStore("modelSelections", reconcile(selections)) - const flags: Record = {} - for (const name of Object.keys(selections)) { - flags[name] = true - } - setUserSetAgents(flags) - if (preferencesReady() || !remembered) setPreferredSelection(message.preferred) - setPreferencesReady(true) - }) - }) - const retryPreferences = createPreferenceLoader({ - ready: preferencesReady, - connected: server.isConnected, - request: () => vscode.postMessage({ type: "requestModelSelections" }), - }) - onCleanup(unsubSelections) - // Load persisted recent models from extension globalState const unsubRecents = vscode.onMessage((message: ExtensionMessage) => { if (message.type !== "recentsLoaded") return @@ -1177,7 +1151,7 @@ export const SessionProvider: ParentComponent = (props) => { if (!store.toolParts[session.id]) setStore("toolParts", session.id, []) const pendingAgent = draftID ? store.agentSelections[draftID] : pendingAgentSelection() - const pendingModel = draftID ? store.sessionOverrides[draftID] : undefined + const pendingPicks = draftID ? store.sessionOverrides[draftID] : undefined if (draftID) { // Goal commands have no optimistic messages, but their empty draft cache must also be removed. setStore( @@ -1192,12 +1166,14 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "persistVariant", key, value }) } if (pendingAgent) setStore("agentSelections", session.id, pendingAgent) - if (pendingModel) setStore("sessionOverrides", session.id, pendingModel) + // Move the draft's whole per-agent pick map, so each agent keeps its model. + if (pendingPicks) setStore("sessionOverrides", session.id, pendingPicks) memory.forget(draftID) agentDrafts.promote(draftID) } else if (pendingAgent && !store.agentSelections[session.id]) { setStore("agentSelections", session.id, pendingAgent) setPendingAgentSelection(null) + memory.forget(COMPOSER) } const active = currentSessionID() @@ -1219,13 +1195,18 @@ export const SessionProvider: ParentComponent = (props) => { if (prefs.agent && !store.agentSelections[sessionID]) { setStore("agentSelections", sessionID, prefs.agent) } - if (prefs.model && !store.sessionOverrides[sessionID]) { - setStore("sessionOverrides", sessionID, prefs.model) + const entries = new Map(Object.entries(prefs.picks)) + if (prefs.unattributed) { + const owner = prefs.agent ?? store.agentSelections[sessionID] ?? defaultAgent() + const existing = entries.get(owner) + if (!existing || prefs.unattributed.seq < existing.seq) entries.set(owner, prefs.unattributed) } - if (prefs.model && prefs.variant !== undefined) { - const agent = prefs.agent ?? store.agentSelections[sessionID] ?? defaultAgent() - const key = variantKey(prefs.model, agent, sessionID) - if (store.variantSelections[key] === undefined) setStore("variantSelections", key, prefs.variant) + for (const [agent, pick] of entries) { + if (!store.sessionOverrides[sessionID]?.[agent]) { + setPick(sessionID, agent, pick.model) + } + const key = variantKey(pick.model, agent, sessionID) + if (store.variantSelections[key] === undefined) setStore("variantSelections", key, pick.variant) } } @@ -2125,19 +2106,53 @@ export const SessionProvider: ParentComponent = (props) => { // Actions function selectAgent(name: string, sessionID?: string) { const id = sessionID ?? currentSessionID() - if (id) { - memory.pin(id) + if (id && id !== COMPOSER) { setStore("agentSelections", id, name) return } - const agent = selectedAgentName() - const model = store.modelSelections[agent] - if (!preferredSelection() && userSetAgents()[agent] && model) { - setPreferredSelection({ ...model, variant: variants.saved(model, agent) }) - } setPendingAgentSelection(name) } + // A fresh draft continues the no-session composer's picks and effort. + function adoptComposer(draft: string) { + const picks = store.sessionOverrides[COMPOSER] + if (picks) setStore("sessionOverrides", draft, picks) + for (const [key, value] of Object.entries(transferVariants(store.variantSelections, COMPOSER, draft))) { + setStore("variantSelections", key, value) + } + memory.forget(COMPOSER) + } + + // A no-session send mints a known-new draft and continues the composer in it. + // Goal commands pass init so their empty draft cache exists before promotion. + function mintDraft(sid: string | undefined, draftID: string | undefined, init: boolean) { + if (sid || draftID) return draftID + const id = crypto.randomUUID() + agentDrafts.seed(id) + adoptComposer(id) + if (init) setStore("messages", id, []) + return id + } + + // Switch the agent first so a mode-only command resolves the target agent's + // config model and effort instead of carrying the outgoing one. + function applyOverrides( + overrides: { agent?: string; model?: string; variant?: string } | undefined, + scope: string | undefined, + selection: ModelSelection, + ) { + if (overrides?.agent) { + selectAgent(overrides.agent, scope) + } + if (overrides?.model) { + selectModel(selection.providerID, selection.modelID, scope) + } + if (overrides?.variant !== undefined) { + selectVariant(overrides.variant, scope) + } + recordModelUsage(selection.providerID, selection.modelID) + } + /** Create an optimistic user message + parts in the store so the UI updates instantly. */ function addOptimistic( sid: string, @@ -2189,7 +2204,7 @@ export const SessionProvider: ParentComponent = (props) => { } function available(selection: ModelSelection | null): selection is ModelSelection { - const resolved = resolveModelSelection({ ...environment(), override: selection }) + const resolved = resolveModelSelection({ ...environment(), session: selection }) if (selection && resolved?.providerID === selection.providerID && resolved.modelID === selection.modelID) return true showToast({ variant: "error", title: language.t("dialog.model.select.title") }) @@ -2246,9 +2261,8 @@ export const SessionProvider: ParentComponent = (props) => { dismiss(sid) - const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID + const effectiveDraftID = mintDraft(sid, draftID, false) const scope = effectiveDraftID ?? sid - if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID) if (scope) { if (!sid && (!draftID || draftSessionID() === scope)) { setUserClearedSession(false) @@ -2295,38 +2309,22 @@ export const SessionProvider: ParentComponent = (props) => { const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) const control = goalControl(command, args) + // Resolve against the scope the user currently sees; the fresh-UUID draft + // below does not exist yet, so the no-session composer stands in for it. + const view = draftID ?? sid ?? COMPOSER + const target = overrides?.agent ?? agentForScope(view) const effectiveSelection = (() => { if (control) return null if (overrides?.model) return parseModelString(overrides.model) - const scope = draftID ?? sid - const model = scope - ? selected(scope) - : getSelected(preferences(), environment(), undefined, pendingAgentSelection() ?? defaultAgent()) + const model = getSelected(preferences(), environment(), view, target) return model ?? (providerID && modelID ? { providerID, modelID } : null) })() if (!control && !available(effectiveSelection)) return false - const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID + const effectiveDraftID = mintDraft(sid, draftID, true) const scope = effectiveDraftID ?? sid - if (!sid && !draftID && effectiveDraftID) { - agentDrafts.seed(effectiveDraftID) - // This UUID is a known-new draft, not unopened history. Initialize it so a mode-only - // command retains the outgoing model/effort together instead of mixing two modes. - setStore("messages", effectiveDraftID, []) - } - if (effectiveSelection) { - if (overrides?.agent) { - selectAgent(overrides.agent, scope) - } - if (overrides?.model) { - selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope) - } - if (overrides?.variant !== undefined) { - selectVariant(overrides.variant, scope) - } - recordModelUsage(effectiveSelection.providerID, effectiveSelection.modelID) - } + if (effectiveSelection) applyOverrides(overrides, scope, effectiveSelection) const settings = (() => { if (!effectiveSelection) return @@ -2565,6 +2563,7 @@ export const SessionProvider: ParentComponent = (props) => { // Clear the pending agent so the picker shows the default and send omits it agentDrafts.prune(draftSessionID()) setPendingAgentSelection(null) + memory.forget(COMPOSER) vscode.postMessage({ type: "createSession" }) } @@ -2576,6 +2575,7 @@ export const SessionProvider: ParentComponent = (props) => { setCloudPreviewId(null) setLoading(false) setPendingAgentSelection(null) + memory.forget(COMPOSER) vscode.postMessage({ type: "clearSession" }) } @@ -2991,9 +2991,6 @@ export const SessionProvider: ParentComponent = (props) => { selected, modelForAgent, selectModel, - preferredSelection, - preferencesReady, - rememberSelection, trackScopes: memory.track, costBreakdown, contextUsage, diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 6a8c18e2533..f3e4b01c7a7 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -237,9 +237,6 @@ export function mockSessionValue(overrides?: { selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), modelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), selectModel: noop, - preferredSelection: () => undefined, - preferencesReady: () => true, - rememberSelection: noop, trackScopes: () => noop, costBreakdown: () => [], contextUsage: () => undefined, diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index bbb56450720..9c21a0c23c7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -1183,13 +1183,6 @@ export interface FavoritesLoadedMessage { favorites: ModelSelection[] } -// Preferred and per-mode model selections loaded from persisted state (extension → webview) -export interface ModelSelectionsLoadedMessage { - type: "modelSelectionsLoaded" - selections: Record - preferred?: ModelSelection & { variant?: string } -} - export interface AgentManagerBranchesMessage { type: "agentManager.branches" projectId?: string @@ -1882,7 +1875,6 @@ export type ExtensionMessage = | RecentsLoadedMessage | ModelSelectorExpandedLoadedMessage | FavoritesLoadedMessage - | ModelSelectionsLoadedMessage | LanguageChangedMessage | ContinueInWorktreeProgressMessage | WorktreeStatsLoadedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 123748fcb1f..b7ea2a6810b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -1551,19 +1551,6 @@ export interface RequestFavoritesMessage { type: "requestFavorites" } -// Explicit preferred and per-mode model selection persistence (webview → extension) -export interface PersistModelSelectionRequest { - type: "persistModelSelection" - agent: string - providerID: string - modelID: string - variant?: string -} - -export interface RequestModelSelectionsMessage { - type: "requestModelSelections" -} - // Continue in Worktree: transfer sidebar session + git state to an isolated worktree export interface ContinueInWorktreeRequest { type: "continueInWorktree" @@ -1882,8 +1869,6 @@ export type WebviewMessage = | RequestModelSelectorExpandedMessage | ToggleFavoriteRequest | RequestFavoritesMessage - | PersistModelSelectionRequest - | RequestModelSelectionsMessage | ToggleRemoteMessage | ToggleCaffeinationMessage | SetRemoteEnabledMessage diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 88952c0cefe..501a89f7e03 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -325,7 +325,6 @@ export namespace PlanFollowup { labelKey: "plan.followup.answer.newSession", description: "Implement in a fresh session with a clean context", descriptionKey: "plan.followup.answer.newSession.description", - mode: "code", }, { label: ANSWER_CONTINUE, diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 71e213bc9d3..452a94a9abb 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -364,7 +364,7 @@ describe("plan follow-up", () => { expect(refineOpt?.mode).toBe("plan") const newOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_NEW_SESSION) - expect(newOpt?.mode).toBe("code") + expect(newOpt?.mode).toBeUndefined() await question.reject(item.id) await expect(pending).resolves.toBe("break") From 61410176127b2b15a571a6aafe9ae8c2841d9250 Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 15:34:39 -0400 Subject: [PATCH 068/353] feat(tui): show which subagent the prompt is steering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subagent-view prompt takes the subagent's color, replaces the primary agent/model row with "Steering · ", and uses a "Steer the subagent..." placeholder, so it no longer looks like the parent's prompt. Also reconcile the steering prompt with #14701's subagent-view keys: Esc belongs to the footer's subagent interrupt, an open autocomplete list outranks it, and a typed steer keeps ctrl+c (clear) and ctrl+d (delete) instead of arming the double-press exit. --- .../tui/src/component/prompt/autocomplete.tsx | 1 + packages/tui/src/component/prompt/index.tsx | 19 ++++-- packages/tui/src/kilocode/steer-label.tsx | 56 +++++++++++++++++ packages/tui/src/kilocode/steer.ts | 24 ++++++- packages/tui/src/routes/session/index.tsx | 5 +- packages/tui/test/kilocode/steer.test.ts | 32 ++++++++-- .../tui/test/kilocode/subagent-view.test.tsx | 62 ++++++++++++++++++- 7 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 packages/tui/src/kilocode/steer-label.tsx diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 5c94cf95c2a..f37aed7522a 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -612,6 +612,7 @@ export function Autocomplete(props: { useBindings(() => ({ target: props.input, enabled: () => Boolean(store.visible), + priority: 2, // kilocode_change - an open list outranks priority-1 subagent-view keys (Esc interrupt) commands: [ { name: "prompt.autocomplete.prev", diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 6806e9c20af..b742e7849fd 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -60,6 +60,7 @@ import { createCostAlertController } from "@/kilocode/cli/cmd/tui/cost-alert" import { MemoryPrompt } from "@/kilocode/cli/cmd/tui/component/memory-prompt" import { GoalPrompt } from "@/kilocode/cli/cmd/tui/component/goal" import { KiloSteer } from "../../kilocode/steer" +import { SteerLabel, useSubagent } from "../../kilocode/steer-label" // kilocode_change end import { KILO_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../config" @@ -236,12 +237,15 @@ export function Prompt(props: PromptProps) { bumpCursor: () => setCursorVersion((value) => value + 1), cursorVersion: () => cursorVersion(), }) - // A subagent view only sends steering prompts: no shell mode, no slash commands, no interrupt. - const steer = createMemo(() => KiloSteer.steering(sync.session.get(props.sessionID ?? ""))) + // A subagent view only sends steering prompts: no shell mode and no slash commands. The prompt + // also shows whose input it is (color, label, placeholder) so it does not look like the parent's. + const subagent = useSubagent(() => props.sessionID) + const steer = createMemo(() => !!subagent()) createEffect(() => { if (steer() && store.mode === "shell") setStore("mode", "normal") }) - // Aborting the child from its own view would fail the parent's pending task call. + // In a subagent view Esc belongs to the session route's `subagent.interrupt`, which stops the + // subagent tree and shows its hint in the subagent footer; the prompt's own interrupt stays off. const interruptible = createMemo( () => !steer() && @@ -1454,6 +1458,7 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary + if (subagent()) return subagent()?.color ?? theme.border // kilocode_change - the subagent's color, as in its footer const agent = local.agent.current() if (!agent) return theme.border return local.agent.color(agent.name ?? "") // kilocode_change @@ -1476,6 +1481,7 @@ export function Prompt(props: PromptProps) { const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined + if (subagent()) return `Steer the ${subagent()?.label} subagent...` // kilocode_change if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] @@ -1619,7 +1625,10 @@ export function Prompt(props: PromptProps) { /> - }> + {/* kilocode_change start - a steer runs as the subagent, so show it instead of the primary agent/model */} + {(item) => } + {/* kilocode_change end */} + }> {(agent) => ( <> @@ -1786,7 +1795,7 @@ export function Prompt(props: PromptProps) { })()} - {/* kilocode_change start - subagent views cannot interrupt, so hide the hint */} + {/* kilocode_change start - the subagent footer shows the interrupt hint in subagent views */} 0 ? theme.primary : theme.text}> esc{" "} diff --git a/packages/tui/src/kilocode/steer-label.tsx b/packages/tui/src/kilocode/steer-label.tsx new file mode 100644 index 00000000000..9afaaee6ea4 --- /dev/null +++ b/packages/tui/src/kilocode/steer-label.tsx @@ -0,0 +1,56 @@ +// Identity of the subagent a prompt is steering, so a subagent view does not look like the +// parent's prompt: the prompt takes the subagent's color (matching the subagent footer) and +// replaces the primary agent/model row with "Steering · ". +import type { RGBA } from "@opentui/core" +import { createMemo, Show, type Accessor } from "solid-js" +import { useLocal } from "../context/local" +import { useSync } from "../context/sync" +import { useTheme } from "../context/theme" +import { Locale } from "../util/locale" +import { KiloSteer } from "./steer" + +export type Subagent = { + label: string + color: RGBA + model?: { name: string; provider: string } +} + +/** The subagent a prompt for session `id` would steer, or undefined for a primary session. */ +export function useSubagent(id: Accessor) { + const sync = useSync() + const local = useLocal() + return createMemo((): Subagent | undefined => { + const session = sync.session.get(id() ?? "") + if (!KiloSteer.steering(session)) return undefined + const last = (sync.data.message[id() ?? ""] ?? []).findLast((item) => item.role === "assistant")?.agent + const name = KiloSteer.agent(session, last) ?? "subagent" + const info = sync.data.agent.find((item) => item.name === name) + return { + label: info?.displayName ?? Locale.titlecase(name), + color: local.agent.color(name), + model: KiloSteer.model(session?.model, sync.data.provider), + } + }) +} + +export function SteerLabel(props: { subagent: Subagent }) { + const { theme } = useTheme() + return ( + + + Steering {props.subagent.label} + + + {(item) => ( + <> + · + + {item().name} + + {item().provider} + + )} + + + ) +} diff --git a/packages/tui/src/kilocode/steer.ts b/packages/tui/src/kilocode/steer.ts index 81a219a305b..3b352903030 100644 --- a/packages/tui/src/kilocode/steer.ts +++ b/packages/tui/src/kilocode/steer.ts @@ -20,6 +20,23 @@ function child(session: Target) { return session?.parentID ? session : undefined } +/** + * The steered subagent's agent name: recorded on the session, else its latest reply, else the + * task-tool title (`inspect bug (@general subagent)`), the same title the subagent footer reads. + */ +export function agent(session: Pick | undefined, last?: string) { + return session?.agent ?? last ?? session?.title.match(/@(\w+) subagent/)?.[1] +} + +type Provider = { id: string; name: string; models: Record } + +/** Display names for the steered subagent's model, falling back to raw ids. */ +export function model(ref: Session["model"], providers: readonly Provider[]) { + if (!ref) return undefined + const provider = providers.find((item) => item.id === ref.providerID) + return { name: provider?.models[ref.id]?.name ?? ref.id, provider: provider?.name ?? ref.providerID } +} + /** Whether `session` is a subagent that the TUI prompt would steer. */ export function steering(session: Target) { return !!child(session) @@ -30,8 +47,11 @@ export function open(session: Target, status: string | undefined) { return !child(session) || running(status ?? "idle") } -/** Subagent navigation keys yield to the prompt once the user starts typing a steer. */ -export function nav(ref: Ref) { +/** + * Whether subagent-view keys (arrow navigation, double-press exit) act on the view: true + * unless the user is typing a steer, so those keys keep their editing meaning in the prompt. + */ +export function idle(ref: Ref) { return !ref?.focused || ref.current.input === "" } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4a035830c63..afc9caa53e2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1273,7 +1273,7 @@ export function Session() { useBindings(() => ({ mode: KILO_BASE_MODE, priority: 1, - enabled: () => KiloSteer.nav(prompt), + enabled: () => KiloSteer.idle(prompt), bindings: tuiConfig.keybinds.gather("session.nav", sessionNavBindingCommands), })) // kilocode_change end @@ -1303,9 +1303,10 @@ export function Session() { bindings: tuiConfig.keybinds.get("subagent.interrupt"), })) + // A typed steer keeps the prompt's own ctrl+c (clear) and ctrl+d (delete), as in the main view. useBindings(() => ({ mode: KILO_BASE_MODE, - enabled: subagentKeys(), + enabled: () => subagentKeys() && KiloSteer.idle(prompt), priority: 1, bindings: tuiConfig.keybinds.get("app.exit").map((binding) => ({ ...binding, diff --git a/packages/tui/test/kilocode/steer.test.ts b/packages/tui/test/kilocode/steer.test.ts index 2619460ba92..2c9e6acec6a 100644 --- a/packages/tui/test/kilocode/steer.test.ts +++ b/packages/tui/test/kilocode/steer.test.ts @@ -46,9 +46,31 @@ test("subagent views accept input only while the child is running", () => { expect(KiloSteer.open(root, "idle")).toBe(true) }) -test("navigation keys yield only once the focused prompt has text", () => { - expect(KiloSteer.nav(undefined)).toBe(true) - expect(KiloSteer.nav({ focused: false, current: { input: "draft" } })).toBe(true) - expect(KiloSteer.nav({ focused: true, current: { input: "" } })).toBe(true) - expect(KiloSteer.nav({ focused: true, current: { input: "draft" } })).toBe(false) +test("subagent-view keys yield only once the focused prompt has text", () => { + expect(KiloSteer.idle(undefined)).toBe(true) + expect(KiloSteer.idle({ focused: false, current: { input: "draft" } })).toBe(true) + expect(KiloSteer.idle({ focused: true, current: { input: "" } })).toBe(true) + expect(KiloSteer.idle({ focused: true, current: { input: "draft" } })).toBe(false) +}) + +test("the steered agent comes from the session, then its latest reply, then the task title", () => { + const title = "inspect bug (@explore subagent)" + expect(KiloSteer.agent({ agent: "general", title }, "build")).toBe("general") + expect(KiloSteer.agent({ title }, "build")).toBe("build") + expect(KiloSteer.agent({ title })).toBe("explore") + expect(KiloSteer.agent({ title: "untitled" })).toBeUndefined() + expect(KiloSteer.agent(undefined)).toBeUndefined() +}) + +test("the steered model shows display names and falls back to raw ids", () => { + const providers = [{ id: "sub", name: "Sub Provider", models: { "sub-model": { name: "Sub Model" } } }] + expect(KiloSteer.model({ id: "sub-model", providerID: "sub" }, providers)).toEqual({ + name: "Sub Model", + provider: "Sub Provider", + }) + expect(KiloSteer.model({ id: "gone", providerID: "missing" }, providers)).toEqual({ + name: "gone", + provider: "missing", + }) + expect(KiloSteer.model(undefined, providers)).toBeUndefined() }) diff --git a/packages/tui/test/kilocode/subagent-view.test.tsx b/packages/tui/test/kilocode/subagent-view.test.tsx index 14cc758019e..94d991cddfb 100644 --- a/packages/tui/test/kilocode/subagent-view.test.tsx +++ b/packages/tui/test/kilocode/subagent-view.test.tsx @@ -248,11 +248,12 @@ for (const width of [80, 120]) { test(`footer stays on one row at ${width} columns in every key-hint state`, async () => { await using tmp = await tmpdir() using scene = await mount(tmp.path, width) + // footer content only: the steering prompt below it also draws "┃" rows with text const rows = () => scene .frame() .split("\n") - .filter((row) => row.includes("┃") && row.trim() !== "┃") + .filter((row) => /General \(1 of 1\)|Interrupt esc|again to exit|Parent up|Prev left|Next right/.test(row)) const states = [] as string[][] states.push(rows()) await scene.press("\x1b") @@ -266,3 +267,62 @@ for (const width of [80, 120]) { for (const item of states) expect(item[0]).toContain("Next right") }) } + +// The subagent view also mounts the steering prompt: its keys must coexist with the footer's. +// The prompt keeps an unmounted draft for its next mount, so each test clears what it typed. +test("the steering prompt names the subagent it steers instead of the parent's agent", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + expect(scene.frame()).toContain("Steer the General subagent...") + expect(scene.frame()).toContain("Steering General") +}) + +test("running subagent view shows the steering prompt without a second interrupt hint", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + await scene.press("steer") + expect(scene.frame()).toContain("steer") + expect(scene.frame()).toContain("Interrupt esc") + expect(scene.frame()).not.toContain("esc interrupt") + await scene.press("\x03") +}) + +test("ctrl+c clears a typed steer before it arms exit", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + await scene.press("redirect the worker") + expect(scene.frame()).toContain("redirect the worker") + await scene.press("\x03") + expect(scene.frame()).not.toContain("redirect the worker") + expect(scene.frame()).not.toContain("again to exit") + await scene.press("\x03") + expect(scene.frame()).toContain("again to exit") + expect(scene.exits).toHaveLength(0) +}) + +test("double Esc still stops the subagent while a steer is typed", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + await scene.press("half typed") + await scene.press("\x1b") + await scene.press("\x1b") + await wait(() => scene.aborts.length === 1) + expect(scene.aborts[0]?.searchParams.get("scope")).toBe("tree") + expect(scene.frame()).toContain("half typed") + await scene.press("\x03") +}) + +test("Esc closes the mention list before it arms the interrupt", async () => { + await using tmp = await tmpdir() + using scene = await mount(tmp.path) + const before = scene.frame() + await scene.press("@") + expect(scene.frame()).not.toBe(before) + await scene.press("\x1b") + expect(scene.frame()).not.toContain("esc again") + expect(scene.aborts).toHaveLength(0) + await scene.press("\x1b") + await scene.press("\x1b") + await wait(() => scene.aborts.length === 1) + await scene.press("\x03") +}) From f6caba9f7b106933709a699cb6074def21a362b6 Mon Sep 17 00:00:00 2001 From: webreflection Date: Thu, 1 Oct 2026 21:46:52 +0200 Subject: [PATCH 069/353] fix(vscode): restore model selection validation contract --- packages/kilo-vscode/src/provider-actions.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index dbb5883d8a8..188fc17bb01 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -181,6 +181,18 @@ export function validateFavorites(raw: unknown): Array<{ providerID: string; mod return raw.filter(isModelSelection).map((r) => ({ providerID: r.providerID, modelID: r.modelID })) } +/** Validate and sanitize per-mode model selections from untrusted sources. */ +export function validateModelSelections(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + const result: Record = {} + for (const [key, val] of Object.entries(raw as Record)) { + if (isModelSelection(val)) { + result[key] = { providerID: val.providerID, modelID: val.modelID } + } + } + return result +} + export function computeDefaultSelection( cachedConfig: { config?: { model?: string } } | null, vscodePID: string, From 257814ff63afce1c9b750583083334b6811fca2e Mon Sep 17 00:00:00 2001 From: webreflection Date: Thu, 1 Oct 2026 22:02:16 +0200 Subject: [PATCH 070/353] refactor(vscode): remove dead model selection code --- .../kilo-vscode/webview-ui/src/context/session-variant-store.ts | 2 +- packages/kilo-vscode/webview-ui/src/context/session.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts index 1b11641008a..757e0f63e6d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts @@ -44,7 +44,7 @@ export function getVariant( if (variants.length === 0) return undefined const scoped = session ? store[variantKey(sel, agent, session)] : undefined const preset = configured && variants.includes(configured) ? configured : undefined - const stored = scoped ?? preset ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)] ?? preset + const stored = scoped ?? preset ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)] if (stored === undefined || stored === DEFAULT_VARIANT) return undefined return preserveVariant(stored, variants) } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 4f5f29e424e..3c9186c39be 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -500,8 +500,6 @@ export const SessionProvider: ParentComponent = (props) => { } }) - const currentSelected = createMemo(() => selected()) - // Precedence: Pick(scope, agent) > agent config > global config > org/recents > fallback. // The no-session composer keeps its own picks under the reserved COMPOSER scope. function selected(sessionID?: string): ModelSelection | null { From 40c0ba21e236e97221ba0e43f745cdba6907695d Mon Sep 17 00:00:00 2001 From: Caleb Norman Date: Thu, 1 Oct 2026 17:00:59 -0400 Subject: [PATCH 071/353] refactor(tui): guard shell mode at history recall instead of an effect The subagent-view prompt reset shell mode from a createEffect. The only way into shell mode there is recalling a shared history entry (the `!` key is already off, and each session mounts a fresh prompt), so guard the two history-recall setters directly and drop the effect. --- packages/tui/src/component/prompt/index.tsx | 7 +-- .../tui/test/kilocode/subagent-view.test.tsx | 47 ++++++++++++++++++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index b742e7849fd..3dda21285b4 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -241,9 +241,6 @@ export function Prompt(props: PromptProps) { // also shows whose input it is (color, label, placeholder) so it does not look like the parent's. const subagent = useSubagent(() => props.sessionID) const steer = createMemo(() => !!subagent()) - createEffect(() => { - if (steer() && store.mode === "shell") setStore("mode", "normal") - }) // In a subagent view Esc belongs to the session route's `subagent.interrupt`, which stops the // subagent tree and shows its hint in the subagent footer; the prompt's own interrupt stays off. const interruptible = createMemo( @@ -989,7 +986,7 @@ export function Prompt(props: PromptProps) { if (!item) return false input.setText(item.input) setStore("prompt", item) - setStore("mode", item.mode ?? "normal") + setStore("mode", steer() ? "normal" : (item.mode ?? "normal")) // kilocode_change - subagent views have no shell mode restoreExtmarksFromParts(item.parts) vim.resetVim() // kilocode_change - recalled history starts in insert mode input.cursorOffset = 0 @@ -1026,7 +1023,7 @@ export function Prompt(props: PromptProps) { if (!item) return false input.setText(item.input) setStore("prompt", item) - setStore("mode", item.mode ?? "normal") + setStore("mode", steer() ? "normal" : (item.mode ?? "normal")) // kilocode_change - subagent views have no shell mode restoreExtmarksFromParts(item.parts) vim.resetVim() // kilocode_change - recalled history starts in insert mode input.cursorOffset = input.plainText.length diff --git a/packages/tui/test/kilocode/subagent-view.test.tsx b/packages/tui/test/kilocode/subagent-view.test.tsx index 94d991cddfb..ddda0f9ff3e 100644 --- a/packages/tui/test/kilocode/subagent-view.test.tsx +++ b/packages/tui/test/kilocode/subagent-view.test.tsx @@ -70,11 +70,17 @@ const reply = { parts: [], } -async function mount(root: string, width = 100) { +async function mount( + root: string, + width = 100, + agents: object[] = [], + options: Parameters[0] = {}, +) { await Bun.write(`${root}/kv.json`, JSON.stringify({ animations_enabled: false, sidebar: "hide", vim_enabled: false })) const aborts: URL[] = [] const exits: unknown[] = [] const calls = createFetch((url) => { + if (url.pathname === "/agent") return json(agents) if (url.pathname === "/session") return json([parent, child]) if (url.pathname === `/session/${child.id}`) return json(child) if (url.pathname === `/session/${parent.id}`) return json(parent) @@ -89,7 +95,7 @@ async function mount(root: string, width = 100) { if (url.pathname.startsWith("/background-process/")) return json(true) return undefined }) - const config = createTuiResolvedConfig() + const config = createTuiResolvedConfig(options) const refs: { sync?: ReturnType } = {} function Ready() { @@ -194,6 +200,7 @@ async function mount(root: string, width = 100) { aborts, exits, frame, + spans: () => app.captureSpans(), async press(sequence: string) { app.renderer.stdin.emit("data", Buffer.from(sequence)) // a lone ESC is disambiguated from escape sequences after a short delay @@ -326,3 +333,39 @@ test("Esc closes the mention list before it arms the interrupt", async () => { await wait(() => scene.aborts.length === 1) await scene.press("\x03") }) + +test("recalling a shell history entry in a subagent view keeps it a plain steer", async () => { + await using tmp = await tmpdir() + await Bun.write( + `${tmp.path}/prompt-history.jsonl`, + JSON.stringify({ input: "ls -la", parts: [], mode: "shell" }) + "\n", + ) + // third agent: the palette gives it a color distinct from the shell-mode color + const agents = [ + { name: "build", mode: "primary", permission: [], options: {} }, + { name: "plan", mode: "primary", permission: [], options: {} }, + { name: "general", mode: "subagent", permission: [], options: {} }, + ] + // with default keys, up on an empty subagent prompt goes to the parent; a user who unbinds that + // reaches prompt history, which is shared across sessions and can hold a shell entry + using scene = await mount(tmp.path, 100, agents, { keybinds: { session_parent: "none" } }) + const color = (row: string, text: string) => { + const line = scene.spans().lines.find((item) => + item.spans + .map((span) => span.text) + .join("") + .includes(row), + ) + const span = line?.spans.find((item) => item.text.includes(text)) + return span ? [span.fg.r, span.fg.g, span.fg.b, span.fg.a] : undefined + } + const steered = color("Steering General", "┃") + expect(steered).toBeDefined() + // the steering label is drawn in the subagent's color, and so is the prompt border + expect(steered).toEqual(color("Steering General", "Steering")) + await scene.press("\x1b[A") + expect(scene.frame()).toContain("ls -la") + // shell mode would switch the prompt border to the shell color + expect(color("ls -la", "┃")).toEqual(steered) + await scene.press("\x03") +}) From 72fa65fb323a8da067694dbc3460ce8f5860af3e Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 1 Oct 2026 17:29:52 -0400 Subject: [PATCH 072/353] feat(vscode): add MCP OAuth sign-in Port the JetBrains MCP OAuth sign-in UX to the VS Code extension, adapted for the webview-based Marketplace and settings surfaces. - New McpAuthService (src/services/mcp-auth/) owns sign-in state for the whole extension: single-flight per directory+server, a 6-minute client timeout kept above the CLI's 5-minute OAuth callback timeout, cancel via credential deletion, and a status normalizer that recognizes recoverable `failed` errors (expired tokens, HTTP 401/403) as `needs_auth` so Settings, the prompt, and Marketplace agree on which servers need sign-in. - Chat prompt toolbar shows a warning icon with a per-server action menu (Sign In / Open in Settings) when any MCP server needs sign-in, deep-linking into Settings > Agent Behaviour > MCP Servers with the matching row expanded and scrolled into view. - Marketplace install flow shows an inline sign-in step after installing an OAuth-protected MCP server, with progress, cancel, and outcome handling. - Settings MCP rows gained Sign In/Cancel and Reset sign-in actions, a tooltip with the underlying failure reason, and OAuth client configuration (automatic/disabled/custom) in the MCP edit view. - Removing a companion skill that belongs to a Marketplace MCP bundle now offers to remove the whole bundle, matching the existing MCP-side removal behavior. - Fixed a pre-existing bug where editing a project-scoped MCP server's URL/command silently relocated it to the global config. --- .changeset/vscode-mcp-oauth-sign-in.md | 5 + .../1790860128815-vscode-mcp-oauth-signin.md | 513 ++++++++++++++++++ packages/kilo-i18n/src/ar.ts | 7 + packages/kilo-i18n/src/br.ts | 7 + packages/kilo-i18n/src/bs.ts | 7 + packages/kilo-i18n/src/da.ts | 7 + packages/kilo-i18n/src/de.ts | 7 + packages/kilo-i18n/src/en.ts | 7 + packages/kilo-i18n/src/es.ts | 7 + packages/kilo-i18n/src/fr.ts | 7 + packages/kilo-i18n/src/it.ts | 7 + packages/kilo-i18n/src/ja.ts | 7 + packages/kilo-i18n/src/ko.ts | 7 + packages/kilo-i18n/src/nl.ts | 7 + packages/kilo-i18n/src/no.ts | 7 + packages/kilo-i18n/src/pl.ts | 7 + packages/kilo-i18n/src/ru.ts | 7 + packages/kilo-i18n/src/th.ts | 7 + packages/kilo-i18n/src/tr.ts | 7 + packages/kilo-i18n/src/uk.ts | 7 + packages/kilo-i18n/src/zh.ts | 7 + packages/kilo-i18n/src/zht.ts | 7 + packages/kilo-vscode/src/KiloProvider.ts | 160 ++++-- .../src/MarketplacePanelProvider.ts | 46 +- .../kilo-vscode/src/SettingsEditorProvider.ts | 18 +- packages/kilo-vscode/src/extension.ts | 11 + .../src/kilo-provider/mcp-oauth.ts | 100 ++-- packages/kilo-vscode/src/services/i18n/ar.ts | 2 + packages/kilo-vscode/src/services/i18n/br.ts | 2 + packages/kilo-vscode/src/services/i18n/bs.ts | 2 + packages/kilo-vscode/src/services/i18n/da.ts | 2 + packages/kilo-vscode/src/services/i18n/de.ts | 2 + packages/kilo-vscode/src/services/i18n/en.ts | 2 + packages/kilo-vscode/src/services/i18n/es.ts | 2 + packages/kilo-vscode/src/services/i18n/fa.ts | 2 + packages/kilo-vscode/src/services/i18n/fr.ts | 2 + packages/kilo-vscode/src/services/i18n/it.ts | 2 + packages/kilo-vscode/src/services/i18n/ja.ts | 2 + packages/kilo-vscode/src/services/i18n/ko.ts | 2 + .../kilo-vscode/src/services/i18n/mcp/ar.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/br.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/bs.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/da.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/de.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/en.ts | 16 + .../kilo-vscode/src/services/i18n/mcp/es.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/fa.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/fr.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/it.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/ja.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/ko.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/nl.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/no.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/pl.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/ru.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/th.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/tr.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/uk.ts | 12 + .../kilo-vscode/src/services/i18n/mcp/zh.ts | 11 + .../kilo-vscode/src/services/i18n/mcp/zht.ts | 11 + packages/kilo-vscode/src/services/i18n/nl.ts | 2 + packages/kilo-vscode/src/services/i18n/no.ts | 2 + packages/kilo-vscode/src/services/i18n/pl.ts | 2 + packages/kilo-vscode/src/services/i18n/ru.ts | 2 + packages/kilo-vscode/src/services/i18n/th.ts | 2 + packages/kilo-vscode/src/services/i18n/tr.ts | 2 + packages/kilo-vscode/src/services/i18n/uk.ts | 2 + packages/kilo-vscode/src/services/i18n/zh.ts | 2 + packages/kilo-vscode/src/services/i18n/zht.ts | 2 + .../src/services/marketplace/bundles.ts | 94 ++++ .../src/services/mcp-auth/index.ts | 16 + .../src/services/mcp-auth/service.ts | 306 +++++++++++ .../src/services/mcp-auth/status.ts | 67 +++ .../fixtures/marketplace-install-modal.tsx | 29 + .../tests/fixtures/session-issues.tsx | 86 +++ .../unit/kilo-provider-mcp-messages.test.ts | 87 +++ .../tests/unit/marketplace-bundles.test.ts | 188 +++++++ .../tests/unit/mcp-auth-service.test.ts | 284 ++++++++++ .../tests/unit/mcp-auth-status.test.ts | 94 ++++ .../tests/unit/mcp-oauth-config.test.ts | 96 ++++ .../tests/unit/session-issues-render.test.ts | 8 + .../tests/unit/session-issues.test.ts | 52 ++ packages/kilo-vscode/webview-ui/src/App.tsx | 6 + .../src/components/chat/PromptInput.tsx | 14 + .../src/components/chat/SessionIssues.tsx | 59 ++ .../src/components/chat/session-issues.ts | 58 ++ .../components/marketplace/InstallModal.tsx | 97 +++- .../components/marketplace/marketplace.css | 15 + .../components/settings/AgentBehaviourTab.tsx | 198 +++++-- .../src/components/settings/McpEditView.tsx | 150 ++++- .../src/components/settings/Settings.tsx | 4 +- .../components/settings/mcp-oauth-config.ts | 84 +++ .../src/context/marketplace-session.tsx | 58 +- .../webview-ui/src/context/mcp-auth.ts | 74 +++ .../webview-ui/src/context/session-types.ts | 14 +- .../webview-ui/src/context/session.tsx | 77 ++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 29 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 29 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 30 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 29 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 29 + .../src/stories/settings.stories.tsx | 27 + .../webview-ui/src/styles/prompt-input.css | 15 +- .../webview-ui/src/types/messages/config.ts | 11 + .../src/types/messages/extension-messages.ts | 44 ++ .../src/types/messages/webview-messages.ts | 32 +- 122 files changed, 4154 insertions(+), 205 deletions(-) create mode 100644 .changeset/vscode-mcp-oauth-sign-in.md create mode 100644 .kilo/plans/1790860128815-vscode-mcp-oauth-signin.md create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/ar.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/br.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/bs.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/da.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/de.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/en.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/es.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/fa.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/fr.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/it.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/ja.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/ko.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/nl.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/no.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/pl.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/ru.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/th.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/tr.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/uk.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/zh.ts create mode 100644 packages/kilo-vscode/src/services/i18n/mcp/zht.ts create mode 100644 packages/kilo-vscode/src/services/marketplace/bundles.ts create mode 100644 packages/kilo-vscode/src/services/mcp-auth/index.ts create mode 100644 packages/kilo-vscode/src/services/mcp-auth/service.ts create mode 100644 packages/kilo-vscode/src/services/mcp-auth/status.ts create mode 100644 packages/kilo-vscode/tests/fixtures/session-issues.tsx create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-mcp-messages.test.ts create mode 100644 packages/kilo-vscode/tests/unit/marketplace-bundles.test.ts create mode 100644 packages/kilo-vscode/tests/unit/mcp-auth-service.test.ts create mode 100644 packages/kilo-vscode/tests/unit/mcp-auth-status.test.ts create mode 100644 packages/kilo-vscode/tests/unit/mcp-oauth-config.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-issues-render.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-issues.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/SessionIssues.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/session-issues.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/mcp-oauth-config.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/mcp-auth.ts diff --git a/.changeset/vscode-mcp-oauth-sign-in.md b/.changeset/vscode-mcp-oauth-sign-in.md new file mode 100644 index 00000000000..f5f71974c9a --- /dev/null +++ b/.changeset/vscode-mcp-oauth-sign-in.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support OAuth sign-in for MCP servers: prompt to sign in after installing an OAuth-protected server from the Marketplace, surface servers that need sign-in from the chat prompt with recovery actions, and configure a pre-registered OAuth client when a server requires one. diff --git a/.kilo/plans/1790860128815-vscode-mcp-oauth-signin.md b/.kilo/plans/1790860128815-vscode-mcp-oauth-signin.md new file mode 100644 index 00000000000..a07d892e804 --- /dev/null +++ b/.kilo/plans/1790860128815-vscode-mcp-oauth-signin.md @@ -0,0 +1,513 @@ +# VS Code: MCP OAuth sign-in UX + +Port of [PR #14667](https://github.com/Kilo-Org/kilocode/pull/14667) (`feat(jetbrains): add MCP OAuth sign-in`) to `packages/kilo-vscode/`. +Reference implementation is checked out locally at `/Users/kirillk/work/kilocode/.kilo/worktrees/lively-zephyr` (branch `lively-zephyr`) — read Kotlin sources there when a detail below is ambiguous. + +**All edits go in the current worktree (`.kilo/worktrees/frosty-acorn`). Never modify `lively-zephyr` or the main checkout.** + +## Goal + +VS Code users can complete MCP OAuth sign-in without hand-editing config, discover which MCP servers need sign-in from the chat prompt, and configure a pre-registered OAuth client. Behaviour of the auth flow and the prompt-area issue indicator matches JetBrains; the marketplace surface is adapted to VS Code's webview panel + install modal. + +## Already done — do not rebuild + +- **No CLI changes needed.** Every endpoint and event exists. `packages/sdk/js/src/v2/gen/` already exposes `client.mcp.status`, `client.mcp.auth.authenticate`, `client.mcp.auth.remove`, `client.mcp.connect`, `client.mcp.disconnect`, `client.config.overlayUpdate`, and `McpOAuthConfig` **including `callbackPort` and `redirectUri`** (`v2/gen/types.gen.ts:2462-2488`). **No SDK regen.** +- **MCP + companion-skill removal already works** in the MCP→skills direction: `removeMarketplaceItem` / `removeMarketplaceItemFromAllScopes` (`src/services/marketplace/actions.ts:68-110`) call `POST /kilocode/marketplace/remove`, and `RemoveDialog.tsx:30-32` already discloses it. Do **not** port the JetBrains MCP-side bundle logic. Only the **skill→MCP** direction is missing (Task 9). +- A basic sign-in button already exists at `AgentBehaviourTab.tsx:689-700`, and `src/kilo-provider/mcp-oauth.ts` already calls `authenticate`. Both get replaced by the new service. +- Config writes already go through `PATCH /config/overlay` with `set`/`unset` and optimistic revisions (`KiloProvider.handleUpdateConfig`, `src/KiloProvider.ts:3836-3929`). Do **not** add a second write path. + +## Locked decisions + +| Decision | Choice | +|---|---| +| Scope | Auth service + prompt issues + marketplace inline prompt + browser fallback + Settings row rework + OAuth client config in `McpEditView` + skill→MCP bundle removal | +| Marketplace sign-in | Inline step in the `InstallModal` result pane | +| Issues menu shape | `DropdownMenu.Sub` per issue (literal JetBrains parity) | +| Settings deep link | Full: `tab` + `subtab` + focus token that expands and scrolls the MCP row | +| Outcome reporting | `signInMcp` carries `notify?: boolean`. Prompt/Settings omit it → host shows a native notification. Marketplace passes `notify: false` → inline only. `cancelled` is always silent. | +| Browser-open failure | Native `showInformationMessage(msg, "Open in Browser", "Copy URL")`, replacing today's auto-retry. Keep the 4 s same-URL dedupe, add the server name. | +| Warning icon placement | First child of `.prompt-input-hint-actions`, reusing `prompt-input.css:568-607` tone/dot CSS | + +## Critical behaviours to preserve from JetBrains + +1. **`failed` → `needs_auth` normalization is load-bearing.** The CLI only emits `needs_auth` on `UnauthorizedError`; real-world recoverable failures (notably Anaconda's `SSE error: Non-200 status code (403)`) arrive as `failed`. Without normalization no Sign In affordance ever appears. +2. **Client timeout must stay above the CLI's 5 min callback timeout.** Use 6 min so the CLI names the failure first. +3. **Cancel == `DELETE /mcp/{name}/auth`.** There is no cancel endpoint; deleting credentials is what rejects the CLI's pending callback. +4. **`mcp.browser.open.failed` fires while `authenticate` is still pending.** Any surface must be non-blocking. +5. **Never drop `headers` / `oauth` when editing a remote server.** The CLI's `mergeDeep` clears them when `type`/`url` changes unless the patch re-states them. + +--- + +## Task 1 — Pure status normalization + +New file `src/services/mcp-auth/status.ts`. No `vscode` import, no I/O. + +```ts +import type { McpStatus } from "@kilocode/sdk/v2/client" + +const MARKERS = [ + "unauthorized", "authentication required", "needs authentication", "not authenticated", + "browser authorization", "token exchange failed", "invalid_token", "invalid_grant", +] +const WORDS = [/\boauth\b/] +const HTTP = /\b(?:http|status)\D{0,10}40[13]\b/ + +/** True when an MCP `failed` error is recoverable by signing in again. */ +export function authFailure(error: string | undefined): boolean +/** Rewrite recoverable `failed` entries to `needs_auth`, preserving `error`. */ +export function normalize(status: Record): Record +``` + +- Match against `error.toLowerCase()`. +- Keep the regexes **anchored** — `\b` on `oauth` and the `http|status` prefix on `40[13]` are what stop false positives. +- `normalize` must keep the `error` field on the rewritten entry (the Settings tooltip shows it). + +**Tests** — `tests/unit/mcp-auth-status.test.ts`. Port the JetBrains corpus from `backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt` verbatim: + +- → `needs_auth`: `Unauthorized: authentication required`, `Browser authorization failed: Authorization cancelled`, `Browser authorization was rejected: ... replaced by another authorization attempt`, `Token exchange failed: invalid client`, `Error POSTing to endpoint (HTTP 401): missing bearer token`, `Error POSTing to endpoint (HTTP 403): Forbidden`, `SSE error: Non-200 status code (403)`, `SSE error: Non-200 status code (401)`, `OAuth discovery failed`, `... invalid_grant`, `... invalid_token` +- stays `failed`: `Connection refused`, `Connection closed`, `Failed to get tools`, `spawn npx ENOENT`, `Invalid MCP URL for "broken"`, `Error POSTing to endpoint (HTTP 500)`, `SSE error: Non-200 status code (404)`, `(500)` +- **anti-false-positive** (must stay `failed`): `connect ECONNREFUSED 127.0.0.1:40123`, `connect ECONNREFUSED 127.0.0.1:40323`, `Invalid MCP URL for "broken401"`, `Invalid MCP URL for "broken403"`, `Invalid MCP URL for "oauthserver"`, `Failed to connect to myoauthapp.internal`, `Request failed after 403 ms` + +--- + +## Task 2 — Extension-host auth service + +New `src/services/mcp-auth/service.ts` + `src/services/mcp-auth/index.ts`. + +```ts +export type McpAuthStatus = + | "connected" | "failed" | "cancelled" | "timeout" + | "unsupported" | "not_found" | "disabled" | "needs_client_registration" + +export interface McpAuthResult { status: McpAuthStatus; error?: string } + +export interface McpAuthOpts { + timeout?: number // default 6 * 60 * 1000 + dedupe?: number // default 4000 + onUrl?: (name: string, url: string) => void +} + +export class McpAuthService { + constructor(connection: KiloConnectionService, opts?: McpAuthOpts) + /** Sorted `needs_auth` server names per directory. */ + needsAuth(dir: string): string[] + /** Sorted server names with an in-flight sign-in, per directory. */ + busy(dir: string): string[] + onChange(listener: (dir: string) => void): () => void + refresh(dir: string): Promise + signIn(dir: string, name: string): Promise + cancel(dir: string, name: string): Promise + reset(dir: string, name: string): Promise + dispose(): void +} +``` + +`index.ts` memoizes one instance per connection so no constructor threading is needed: + +```ts +const instances = new WeakMap() +export function mcpAuth(connection: KiloConnectionService): McpAuthService +``` + +Semantics — mirror `frontend/.../app/KiloMcpAuthService.kt` exactly: + +- **`refresh(dir)`**: blank `dir` → `[]`, no HTTP call. Otherwise `client.mcp.status({ directory: dir })` → `normalize()` → filter `needs_auth` → store sorted. Bound the directory map to **64** entries, evicting oldest insertion first. Fire `onChange(dir)` only when the value actually changed. +- **`signIn(dir, name)`**: single-flight per `` `${dir}\0${name}` `` via a `Map` token. A duplicate call returns `{ status: "failed" }` immediately with no HTTP request. Race `client.mcp.auth.authenticate({ name, directory: dir })` against a 6 min timer: + - resolved and token is in the `cancelled` set → return `{ status: "cancelled" }` (silent) + - resolved → map the response: `200` → the `McpStatus.status` tag + `error`; `400` → `unsupported`; `404` → `not_found`; other → `failed` with `error ?? message ?? "HTTP "` + - timed out → `client.mcp.auth.remove(...)` for cleanup, return `{ status: "timeout" }` + - `finally`: drop the token, clear the cancel mark, update `busy`, and `await refresh(dir)` +- **`cancel(dir, name)`**: mark the in-flight token cancelled, `client.mcp.auth.remove(...)`; roll the mark back if the delete failed. +- **`reset(dir, name)`**: mark cancelled → `auth.remove` → `disconnect` → `connect` → `refresh`. If the reconnect failed, still mark the server `needs_auth` so the UI stays honest. +- **Browser fallback**: subscribe once (lazily, idempotent) to `connection.onEvent` for `mcp.browser.open.failed`, dedupe the **same URL within 4 s**, then call `opts.onUrl(properties.mcpName, properties.url)`. +- Wrap every SDK call so failures log and fall back instead of throwing. Never swallow a disposal/abort. + +**Tests** — `tests/unit/mcp-auth-service.test.ts`, driven by a fake client (short `timeout`/`dedupe`, captured `onUrl`). Port all 13 cases from `KiloMcpAuthServiceTest.kt`, in particular: + +- `refresh` collects only `needs_auth`; blank directory issues no call; 65 refreshes leave 64 entries with the oldest evicted +- concurrent `signIn` is single-flight (second returns `failed`, exactly one HTTP call) +- timeout path calls `auth.remove` and returns `timeout` +- `reset` calls `auth.remove` → `disconnect` → `connect` and leaves the server `needs_auth`; keeps `needs_auth` when `connect` throws +- cancelling an in-flight sign-in yields `cancelled` **and a subsequent sign-in still succeeds** (token cleanup) +- same URL twice inside the dedupe window → one `onUrl`; different URLs → two + +--- + +## Task 3 — Host wiring, messages, notifications + +### 3a. `src/kilo-provider/mcp-oauth.ts` + +- Delete `authenticateMcpServer` (replaced by the service) and the auto-retry in `openMcpOAuthUrlOnce`. +- Replace with `showAuthUrl(name: string, url: string)`: `vscode.window.showInformationMessage(t("mcp.auth.browserFailed", { name }), t("mcp.auth.browserFailed.open"), t("mcp.auth.browserFailed.copy"))` → `vscode.env.openExternal` / `vscode.env.clipboard.writeText`. Non-modal. +- The 4 s dedupe now lives in `McpAuthService`; pass `showAuthUrl` in as `opts.onUrl`. +- Keep `connectMcpServer` / `disconnectMcpServer`. + +### 3b. New message contract + +`webview-ui/src/types/messages/webview-messages.ts` — add to the interfaces **and** the `WebviewMessage` union: + +```ts +export interface RequestMcpAuthStateMessage { type: "requestMcpAuthState" } +export interface SignInMcpMessage { type: "signInMcp"; name: string; notify?: boolean } +export interface CancelMcpSignInMessage { type: "cancelMcpSignIn"; name: string } +export interface ResetMcpAuthMessage { type: "resetMcpAuth"; name: string } +export interface RequestMcpBundlesMessage { type: "requestMcpBundles" } +``` + +Change `OpenSettingsTabRequest` (`:616-619`) to `{ type: "openSettingsTab"; tab: string; subtab?: string; focus?: string }`. + +**Remove** `AuthenticateMcpMessage` (`:371-393` block, union member ~`:1720`) and its `KiloProvider` case — one code path only, and `knip` will flag the orphaned export. + +`webview-ui/src/types/messages/extension-messages.ts` — add to the interfaces **and** the `ExtensionMessage` union: + +```ts +export interface McpAuthStateMessage { + type: "mcpAuthState" + directory: string + needsAuth: string[] + busy: string[] +} +export interface McpAuthResultMessage { + type: "mcpAuthResult" + name: string + status: McpAuthStatus + error?: string +} +export interface McpBundle { id: string; scope: "project" | "global"; skills: string[] } +export interface McpBundlesMessage { type: "mcpBundles"; bundles: McpBundle[] } +``` + +Extend `NavigateMessage` (`:463-468`) with `subtab?: string` and `focus?: string`. + +### 3c. `src/KiloProvider.ts` + +- Apply `normalize()` inside `fetchAndSendMcpStatus` (`:3073-3092`) before caching/posting, so Settings, prompt, and marketplace all agree on `needs_auth`. +- New message cases next to the existing MCP block (`:1448-1480`): `requestMcpAuthState`, `signInMcp`, `cancelMcpSignIn`, `resetMcpAuth`, `requestMcpBundles`. +- Add `private auth = mcpAuth(this.connectionService)` and subscribe to `auth.onChange(dir)`; when `dir === this.getWorkspaceDirectory()`, post `mcpAuthState`. Cache the last payload as `cachedMcpAuthStateMessage` and replay it on reconnect/webview-ready, following the `cachedMcpStatusMessage` pattern. Dispose the subscription with the provider. +- `signInMcp` handler: `await auth.signIn(dir, name)`, then `postMessage({ type: "mcpAuthResult", ... })`. If `message.notify !== false` **and** `status !== "cancelled"`, show a native notification: `connected` → info `mcp.signIn.success`; `timeout` → error `mcp.signIn.timeout`; `unsupported` → error `mcp.signIn.unsupported`; `not_found` → error `mcp.signIn.notFound`; anything else → error `mcp.signIn.failed` with `result.error` appended when present. +- `resetMcpAuth`: on failure show `mcp.auth.resetFailed`. +- Extend the `openSettingsTab` case (`:1544-1548`) so `tab === "agentBehaviour"` runs a new command `kilo-code.new.openMcpSettings` with `message.focus`. +- `KiloProvider.ts` is already 6183 lines. Put all non-trivial logic in `src/services/mcp-auth/` and `src/kilo-provider/mcp-oauth.ts`; the provider should only dispatch and post. + +### 3d. `src/MarketplacePanelProvider.ts` + +- `tests/unit/marketplace-panel-arch.test.ts:11-26` forbids *marketplace* message cases in `KiloProvider`. MCP-auth cases are not marketplace cases, but the panel still needs its own: add `requestMcpAuthState`, `signInMcp`, `cancelMcpSignIn` to `handle()` (`:214-246`) using the same `mcpAuth(this.connectionService)` instance, and post `mcpAuthState` / `mcpAuthResult` through `post()` (`:359-364`). +- In `install()` (`:295-305`), after a successful `item.type === "mcp"` install: `const needs = await auth.refresh(dir)`, and include `needsAuth: needs.includes(item.id)` on the `marketplaceInstallResult` message. **Use `item.id`** (the config key), not `item.name`. +- Keep the existing native "Successfully installed" notification. + +### 3e. Host i18n + +New namespace `src/services/i18n/mcp/{en,ar,br,bs,da,de,es,fa,fr,it,ja,ko,nl,no,pl,ru,th,tr,uk,zh,zht}.ts`, spread into `src/services/i18n/en.ts` (and each locale root) alongside `autocompleteDict` / `attentionDict`. + +``` +"mcp.signIn.success": "Signed in to {{name}}." +"mcp.signIn.failed": "Sign-in to {{name}} failed." +"mcp.signIn.timeout": "Sign-in to {{name}} timed out." +"mcp.signIn.unsupported": "{{name}} does not support OAuth sign-in." +"mcp.signIn.notFound": "MCP server {{name}} was not found." +"mcp.auth.browserFailed": "Kilo could not open a browser for {{name}}. Open the authorization URL on the machine running Kilo to finish signing in." +"mcp.auth.browserFailed.open": "Open in Browser" +"mcp.auth.browserFailed.copy": "Copy URL" +"mcp.auth.resetFailed": "Could not clear the stored sign-in for {{name}}." +``` + +Check `tests/unit/i18n-keys.test.ts:364-380` — if it hardcodes the `autocomplete`/`attention` namespaces for the host pool, extend it to include `mcp`. + +--- + +## Task 4 — Webview session context + +`webview-ui/src/context/session.tsx` (+ `session-types.ts`): + +- Add `const [mcpAuth, setMcpAuth] = createSignal<{ needsAuth: string[]; busy: string[] }>({ needsAuth: [], busy: [] })`, fed by `mcpAuthState` in the existing `vscode.onMessage` block alongside `mcpStatusLoaded` (`:741-747`). +- Request `requestMcpAuthState` next to the `requestMcpStatus` call (`:750`) and reuse the same 3 s / `extensionDataReady` retries (`:752-764`). +- Replace `authenticateMcp` (`:323-328`) with `signInMcp(name, notify?)`, `cancelMcpSignIn(name)`, `resetMcpAuth(name)`. +- **Remove the global `mcpLoading` gate for auth actions.** Per-server busy now comes from `mcpAuth().busy`. Keep `mcpLoading` for `connectMcp`/`disconnectMcp`. +- Add `mcpBundles` signal + `refreshMcpBundles()` fed by `mcpBundles` (Task 9). +- Export everything on the context object (`:3008-3012`) and in `session-types.ts:137-141`. + +The `MarketplaceSessionProvider` (`webview-ui/src/context/marketplace-session.tsx`, 41 lines) is a separate lightweight provider. Add the same `mcpAuth` signal + `signInMcp`/`cancelMcpSignIn` there — do **not** pull the full session provider into the marketplace bundle. + +--- + +## Task 5 — Prompt-area session issues + +### 5a. Generic issue model + +New `webview-ui/src/components/chat/session-issues.ts` (pure, no JSX): + +```ts +export interface SessionIssueAction { + title: string + description?: string + enabled?: boolean + run: () => void +} +export interface SessionIssue { id: string; title: string; actions: SessionIssueAction[] } +``` + +Plus a pure derivation so it is unit-testable without rendering: + +```ts +export function mcpAuthIssues( + needsAuth: string[], + busy: string[], + t: (key: string, params?: Record) => string, + handlers: { signIn: (name: string) => void; openSettings: (name: string) => void }, +): SessionIssue[] +``` + +Per server, sorted by name: +- `id: \`mcp-auth:${name}\`` +- `title: t("prompt.mcp.provider", { name: capitalize(name) })` → `"Anaconda MCP"` +- actions: `[{ title: busy ? t("prompt.mcp.signIn.busy") : t("common.signIn"), enabled: !busy, run: signIn }, { title: t("prompt.mcp.openSettings"), run: openSettings }]` + +Deliberately **no severity field** — one visual severity today, so the icon is fixed at the component level. Keep the model generic for future non-MCP issue providers. + +### 5b. Indicator component + +New `webview-ui/src/components/chat/SessionIssues.tsx`: + +- Returns `null` when `issues.length === 0` — that is the only hide condition, and there is no dismiss. +- `DropdownMenu` → `Trigger` wrapping `IconButton icon="warning" variant="ghost" size="small"` with `class="prompt-issues-button"`, `aria-label={t("prompt.issues.title")}`, wrapped in `Tooltip value={t("prompt.issues.title")} placement="top"`. +- `DropdownMenu.Portal` → `Content placement="top"` → one `DropdownMenu.Sub` per issue: `SubTrigger` = `issue.title`, `Portal` → `SubContent` → `DropdownMenu.Item` per action with `disabled={action.enabled === false}` and `onSelect={action.run}`. +- `warning` exists in the upstream icon registry (`packages/ui/src/components/icon.tsx:108`). `alert-triangle` does **not** — do not use it. +- The nested `DropdownMenu.Portal` inside `Sub` is required; see `packages/kilo-ui/src/stories/dropdown-menu.stories.tsx:83-106` (the only existing submenu usage in the repo). + +### 5c. Mount it + +`webview-ui/src/components/chat/PromptInput.tsx` — insert `` as the **first** child of `.prompt-input-hint-actions` (`:2207`), before the indexing `` at `:2208`. + +- `signIn` → `session.signInMcp(name)` (notify defaults on) +- `openSettings` → `vscode.postMessage({ type: "openSettingsTab", tab: "agentBehaviour", subtab: "mcpServers", focus: name })` + +`webview-ui/src/styles/prompt-input.css` — add `.prompt-issues-button { position: relative; color: var(--vscode-charts-yellow, #cca700); }` and add the selector to the shared status-dot rule at `:576-578` with the top-right offset from `:588-591`. + +### 5d. Tests + +- `tests/unit/session-issues.test.ts` — pure: empty in → empty out; one `needs_auth` → one issue with `" MCP"` title and `["Sign in", "Open in Settings"]`; busy → first action disabled with the `Signing in…` label; two servers → two issues in sorted order. +- Component fixture `tests/fixtures/session-issues.tsx` + `tests/unit/session-issues-render.test.ts`, following `tests/fixtures/marketplace-install-modal.tsx` and `tests/fixtures/run.ts`: nothing rendered with zero issues; trigger present with the right `aria-label` when there is one; `openSettingsTab` with `subtab: "mcpServers"` and `focus: ""` is posted when the action is selected. + +--- + +## Task 6 — Settings deep link to the MCP row + +1. **Command** — `src/extension.ts` next to `openIndexingSettings` (`:604-606`): + ```ts + vscode.commands.registerCommand("kilo-code.new.openMcpSettings", (focus?: string) => + settingsEditorProvider.openPanel("settings", "agentBehaviour", undefined, "mcpServers", focus), + ) + ``` + Register it in `package.json` `contributes.commands` with the `kilo-code.new.` prefix. +2. **`src/SettingsEditorProvider.ts`** — extend `openPanel(view, tab?, projectId?, subtab?, focus?)` (`:60-77`). Store `subtab`/`focus` next to the existing `tabs` map and include them in the `navigate` posts at `:70-76` **and** the `webviewReady` replay at `:130-142`. Clear `focus` after it is sent once. +3. **`webview-ui/src/App.tsx`** — in the `navigate` handler (`:348-354`) add `settingsSubtab` and a token-bearing focus signal (same trick as `MarketplaceFocus`, `MarketplaceListView.tsx:26-30`, so repeat requests for the same server are observable): + ```ts + setSettingsFocus((prev) => ({ token: (prev?.token ?? 0) + 1, name: message.focus! })) + ``` + Pass both into `` (`:455-463`). +4. **`webview-ui/src/components/settings/Settings.tsx`** — accept `subtab` / `focus` props, mirror the existing `createEffect(on(() => props.tab, ...))` at `:293-300`, and pass them to `` (`:444`), which currently takes no props. +5. **`webview-ui/src/components/settings/AgentBehaviourTab.tsx`** — + - Add `Props { subtab?: string; focus?: { token: number; name: string } }`. + - `createEffect(on(() => props.subtab, ...))` setting `activeSubtab` (`:59`) when it is a valid `SubtabId`. + - **Lift `expanded` out of `renderMcpSubtab()`** (`:557`) to component scope so the focus effect can reach it. + - On a new focus token: set `activeSubtab("mcpServers")`, expand that row, and `scrollIntoView({ block: "nearest" })` via a `ref` captured per row keyed by server name. + +--- + +## Task 7 — Settings MCP row rework + +`webview-ui/src/components/settings/AgentBehaviourTab.tsx`, `renderMcpSubtab()` (`:555-831`). Match `McpSettingsUi` cell rules exactly: + +| Status | Controls (in order) | +|---|---| +| `needs_auth` | **Sign in**, edit, remove. **No enable/disable switch** — you cannot connect your way out of an auth problem. | +| `connected` + remote | switch (on), **Reset sign-in**, edit, remove | +| `connected` + local | switch (on), edit, remove | +| anything else | switch (off), edit, remove | + +- Status pill carries the failure reason: wrap the label in `Tooltip` with `"