From 4e6f366a75c71b6c5a2e3499e116b61c21355fbe Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 27 May 2026 13:49:03 +0200 Subject: [PATCH 01/19] fix(vscode): add context handoff for forked sessions --- .changeset/forked-session-context.md | 5 ++ .../src/agent-manager/AgentManagerProvider.ts | 1 + .../src/agent-manager/continue-in-worktree.ts | 4 + .../src/agent-manager/fork-handoff.ts | 41 +++++++++ .../src/agent-manager/fork-session.ts | 10 ++- .../src/kilo-provider/fork-session.ts | 1 + .../tests/unit/continue-in-worktree.test.ts | 17 +++- .../tests/unit/fork-handoff.test.ts | 42 +++++++++ .../tests/unit/fork-session.test.ts | 87 +++++++++++++++++++ 9 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 .changeset/forked-session-context.md create mode 100644 packages/kilo-vscode/src/agent-manager/fork-handoff.ts create mode 100644 packages/kilo-vscode/tests/unit/fork-handoff.test.ts create mode 100644 packages/kilo-vscode/tests/unit/fork-session.test.ts diff --git a/.changeset/forked-session-context.md b/.changeset/forked-session-context.md new file mode 100644 index 00000000000..b3061ce95e7 --- /dev/null +++ b/.changeset/forked-session-context.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve prior context in forked sessions while recognizing the selected direction and current worktree context. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d14dcbaf8ec..dd16ecc6098 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1152,6 +1152,7 @@ export class AgentManagerProvider implements Disposable { { getClient: () => this.connectionService.getClient(), state: this.getStateManager(), + directory: this.getRoot(), postError: (msg) => this.postToWebview({ type: "error", message: msg }), registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), pushState: () => this.pushState(), diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index e25cb355588..cd49f2d2064 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -4,6 +4,7 @@ import type { WorktreeStateManager } from "./WorktreeStateManager" import { capture as captureGitState, apply as applyGitState, type GitSnapshot } from "./git-transfer" import { getErrorMessage } from "../kilo-provider-utils" import { PLATFORM } from "./constants" +import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string @@ -82,6 +83,9 @@ export async function forkSession(ctx: ContinueContext, sessionId: string, dir: } try { const { data } = await client.session.fork({ sessionID: sessionId, directory: dir }, { throwOnError: true }) + await recordForkHandoff({ client, sessionId: data.id, directory: dir }).catch((err) => { + ctx.log("Failed to record fork handoff:", getErrorMessage(err)) + }) return { ok: true, value: data } } catch (err) { return { ok: false, error: `Failed to fork session: ${getErrorMessage(err)}` } diff --git a/packages/kilo-vscode/src/agent-manager/fork-handoff.ts b/packages/kilo-vscode/src/agent-manager/fork-handoff.ts new file mode 100644 index 00000000000..efbf36361c9 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/fork-handoff.ts @@ -0,0 +1,41 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export interface ForkHandoffInput { + client: KiloClient + sessionId: string + directory?: string +} + +export function forkText(input: Pick): string { + return [ + "", + "This session was forked from an existing session in the current repository or worktree.", + ...(input.directory + ? [ + `Use this as the current working directory: ${input.directory}`, + "For this fork, this location supersedes any earlier repository or worktree location retained in the copied context.", + ] + : []), + "The prior conversation context was retained intentionally.", + "The user may continue the same task, explore an alternative approach, or provide new instructions.", + "Follow the user's next instruction as the direction for this fork, using retained context when relevant.", + "", + ].join("\n") +} + +export async function recordForkHandoff(input: ForkHandoffInput): Promise { + const payload = { + sessionID: input.sessionId, + ...(input.directory ? { directory: input.directory } : {}), + noReply: true, + parts: [ + { + type: "text", + text: forkText(input), + synthetic: true, + }, + ], + } as Parameters[0] + + await input.client.session.promptAsync(payload, { throwOnError: true }) +} diff --git a/packages/kilo-vscode/src/agent-manager/fork-session.ts b/packages/kilo-vscode/src/agent-manager/fork-session.ts index c78e8086b14..d6d4118c6aa 100644 --- a/packages/kilo-vscode/src/agent-manager/fork-session.ts +++ b/packages/kilo-vscode/src/agent-manager/fork-session.ts @@ -3,10 +3,12 @@ import { getErrorMessage } from "../kilo-provider-utils" import { TelemetryProxy, TelemetryEventName } from "../services/telemetry" import type { WorktreeStateManager } from "./WorktreeStateManager" import { PLATFORM } from "./constants" +import { recordForkHandoff } from "./fork-handoff" export interface ForkContext { getClient: () => KiloClient state: WorktreeStateManager | undefined + directory: string | undefined postError: (message: string) => void registerWorktreeSession: (sessionId: string, directory: string) => void pushState: () => void @@ -37,8 +39,8 @@ export async function forkSession( } const directory = (() => { - if (!worktreeId || !ctx.state) return undefined - return ctx.state.getWorktree(worktreeId)?.path + if (!worktreeId || !ctx.state) return ctx.directory + return ctx.state.getWorktree(worktreeId)?.path ?? ctx.directory })() let forked: Session @@ -63,6 +65,10 @@ export async function forkSession( if (directory) ctx.registerWorktreeSession(forked.id, directory) } + await recordForkHandoff({ client, sessionId: forked.id, directory }).catch((err) => { + ctx.log("forkSession: failed to record fork handoff:", getErrorMessage(err)) + }) + ctx.pushState() ctx.notifyForked(forked, sessionId, worktreeId) ctx.registerSession(forked) diff --git a/packages/kilo-vscode/src/kilo-provider/fork-session.ts b/packages/kilo-vscode/src/kilo-provider/fork-session.ts index 1416320581c..d97fc213a0b 100644 --- a/packages/kilo-vscode/src/kilo-provider/fork-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/fork-session.ts @@ -32,6 +32,7 @@ export async function handleForkSession(ctx: ForkContext, sessionId: string, mes { getClient: () => ctx.connection.getClient(), state: undefined, + directory: ctx.directory(sessionId), postError: (message) => ctx.post({ type: "error", message }), registerWorktreeSession: () => {}, pushState: () => {}, diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index c2dab4a182a..4b72acdcac0 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, mock } from "bun:test" import { abortSession, captureState, @@ -8,6 +8,7 @@ import { type StepResult, } from "../../src/agent-manager/continue-in-worktree" import type { CreateWorktreeResult } from "../../src/agent-manager/WorktreeManager" +import { forkText } from "../../src/agent-manager/fork-handoff" import type { Session } from "@kilocode/sdk/v2/client" const noop = () => {} @@ -100,17 +101,27 @@ describe("continue-in-worktree steps", () => { if (!res.ok) expect(res.error).toContain("fork failed") }) - it("returns forked session on success", async () => { + it("records handoff instructions for the forked worktree session", async () => { const forked = session("forked-1") + const promptAsync = mock(async () => ({})) const c = ctx({ getClient: () => ({ - session: { fork: () => Promise.resolve({ data: forked }) }, + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, }) as never, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) if (res.ok) expect(res.value.id).toBe("forked-1") + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "forked-1", + directory: "/tmp/wt", + noReply: true, + parts: [{ type: "text", text: forkText({ directory: "/tmp/wt" }), synthetic: true }], + }, + { throwOnError: true }, + ) }) }) diff --git a/packages/kilo-vscode/tests/unit/fork-handoff.test.ts b/packages/kilo-vscode/tests/unit/fork-handoff.test.ts new file mode 100644 index 00000000000..9fb775cd220 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/fork-handoff.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, mock } from "bun:test" +import { forkText, recordForkHandoff } from "../../src/agent-manager/fork-handoff" + +describe("fork handoff", () => { + it("describes retained context without assuming a new task", () => { + const text = forkText({ directory: "/repo/.kilo/worktrees/feature" }) + + expect(text).toContain("This session was forked from an existing session in the current repository or worktree.") + expect(text).toContain("Use this as the current working directory: /repo/.kilo/worktrees/feature") + expect(text).toContain("this location supersedes any earlier repository or worktree location") + expect(text).toContain("The prior conversation context was retained intentionally.") + expect(text).toContain("continue the same task, explore an alternative approach, or provide new instructions") + expect(text).toContain("Follow the user's next instruction as the direction for this fork") + }) + + it("records a hidden no-reply handoff in the forked session", async () => { + const promptAsync = mock(async () => ({})) + const client = { session: { promptAsync } } + + await recordForkHandoff({ + client: client as never, + sessionId: "session-fork", + directory: "/repo/.kilo/worktrees/feature", + }) + + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "session-fork", + directory: "/repo/.kilo/worktrees/feature", + noReply: true, + parts: [ + { + type: "text", + text: forkText({ directory: "/repo/.kilo/worktrees/feature" }), + synthetic: true, + }, + ], + }, + { throwOnError: true }, + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/fork-session.test.ts b/packages/kilo-vscode/tests/unit/fork-session.test.ts new file mode 100644 index 00000000000..b5526c2688c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/fork-session.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, mock } from "bun:test" +import type { Session } from "@kilocode/sdk/v2/client" +import { forkText } from "../../src/agent-manager/fork-handoff" +import { forkSession, type ForkContext } from "../../src/agent-manager/fork-session" + +const noop = () => {} + +function session(id: string): Session { + return { id, title: id, createdAt: "", updatedAt: "" } as Session +} + +function ctx(client: unknown, overrides: Partial = {}): ForkContext { + return { + getClient: () => client as never, + state: undefined, + directory: "/repo", + postError: noop, + registerWorktreeSession: noop, + pushState: noop, + notifyForked: noop, + registerSession: noop, + log: noop, + ...overrides, + } +} + +describe("agent manager fork session", () => { + it("records the hidden handoff in the current repository", async () => { + const fork = mock(async () => ({ data: session("forked") })) + const promptAsync = mock(async () => ({})) + const client = { session: { fork, promptAsync } } + + await forkSession(ctx(client), "source", undefined, "message") + + expect(fork).toHaveBeenCalledWith( + { sessionID: "source", directory: "/repo", messageID: "message" }, + { throwOnError: true }, + ) + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "forked", + directory: "/repo", + noReply: true, + parts: [{ type: "text", text: forkText({ directory: "/repo" }), synthetic: true }], + }, + { throwOnError: true }, + ) + }) + + it("uses the selected worktree directory for the handoff", async () => { + const fork = mock(async () => ({ data: session("forked") })) + const promptAsync = mock(async () => ({})) + const client = { session: { fork, promptAsync } } + const state = { + getWorktree: () => ({ path: "/repo/.kilo/worktrees/feature" }), + addSession: mock(() => undefined), + } + + await forkSession(ctx(client, { state: state as never }), "source", "worktree") + + expect(fork).toHaveBeenCalledWith( + { sessionID: "source", directory: "/repo/.kilo/worktrees/feature" }, + { throwOnError: true }, + ) + expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ directory: "/repo/.kilo/worktrees/feature" }), { + throwOnError: true, + }) + }) + + it("still exposes the fork when recording the handoff fails", async () => { + const notify = mock(() => undefined) + const log = mock(() => undefined) + const client = { + session: { + fork: mock(async () => ({ data: session("forked") })), + promptAsync: mock(async () => { + throw new Error("handoff failed") + }), + }, + } + + await forkSession(ctx(client, { notifyForked: notify, log }), "source") + + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ id: "forked" }), "source", undefined) + expect(log).toHaveBeenCalledWith("forkSession: failed to record fork handoff:", "handoff failed") + }) +}) From ef2390d7a4ffafc379d1e15db94d3a2cd6dcce9b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 15:59:11 +0200 Subject: [PATCH 02/19] feat: remove semantic indexing experimental gate --- .changeset/visible-semantic-indexing.md | 6 +++ .../customize/context/codebase-indexing.md | 46 +++---------------- packages/kilo-vscode/src/features.ts | 3 +- .../tests/unit/indexing-utils.test.ts | 40 +++++----------- .../components/settings/ExperimentalTab.tsx | 13 ------ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 -- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 -- .../webview-ui/src/stories/StoryProviders.tsx | 2 +- .../src/stories/settings.stories.tsx | 9 ---- .../webview-ui/src/types/messages/config.ts | 1 - packages/opencode/src/config/config.ts | 5 +- .../opencode/src/kilocode/config/config.ts | 8 ++++ .../opencode/src/kilocode/indexing-feature.ts | 3 +- packages/opencode/src/kilocode/indexing.ts | 9 ---- .../test/kilocode/config/config.test.ts | 19 ++++++-- .../test/kilocode/indexing-feature.test.ts | 8 +--- .../test/kilocode/indexing-startup.test.ts | 29 ++---------- .../test/kilocode/indexing-worktree.test.ts | 3 -- packages/sdk/js/src/v2/gen/types.gen.ts | 1 - packages/sdk/openapi.json | 3 -- 37 files changed, 59 insertions(+), 206 deletions(-) create mode 100644 .changeset/visible-semantic-indexing.md diff --git a/.changeset/visible-semantic-indexing.md b/.changeset/visible-semantic-indexing.md new file mode 100644 index 00000000000..b7e258038a6 --- /dev/null +++ b/.changeset/visible-semantic-indexing.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Access semantic indexing without an experimental feature toggle while keeping indexing disabled until enabled globally or for a project. diff --git a/packages/kilo-docs/pages/customize/context/codebase-indexing.md b/packages/kilo-docs/pages/customize/context/codebase-indexing.md index f577678670b..819ae399d7b 100644 --- a/packages/kilo-docs/pages/customize/context/codebase-indexing.md +++ b/packages/kilo-docs/pages/customize/context/codebase-indexing.md @@ -7,8 +7,8 @@ description: "Index your codebase for improved AI understanding" Codebase Indexing enables semantic code search across your entire project using AI embeddings. Instead of searching for exact text matches, it understands the _meaning_ of your queries, helping Kilo Code find relevant code even when you don't know specific function names or file locations. -{% callout type="warning" title="Experimental" %} -Codebase Indexing is currently **experimental** in the CLI and the new VS Code extension. You must explicitly opt in before the feature becomes available — see the **Setup** section below. Behavior, configuration, and defaults may change in future releases. +{% callout type="info" title="Opt-in indexing" %} +Codebase Indexing is disabled by default. It starts only after you enable indexing globally or for an individual project. Configuring an embedding provider without enabling one of those toggles does not start indexing. {% /callout %} ## What It Does @@ -34,28 +34,10 @@ This enables natural language queries like "user authentication logic" or "datab {% tabs %} {% tab label="VSCode" %} -### 1. Enable the experimental flag - -Codebase Indexing is gated behind an experimental flag. Until the flag is on, the Indexing UI is hidden and `semantic_search` is unavailable. - -1. Open Kilo Code **Settings** → **Experimental**. -2. Toggle **Semantic Indexing** on. -3. The **Indexing** tab will appear in Settings and the indexing status indicator will appear at the bottom of the prompt input panel. - -Alternatively, set `experimental.semantic_indexing` to `true` in your `kilo.jsonc`: - -```json -{ - "experimental": { - "semantic_indexing": true - } -} -``` - -### 2. Configure indexing +### Configure indexing 1. Open Kilo Code **Settings** → **Indexing**, or click the indexing indicator at the bottom of the prompt input panel. -2. Toggle **Enable Indexing** on. +2. Turn on **Global Enable** to index every workspace, or turn on **Enable for This Project** to index only the current workspace. Both toggles are off until explicitly enabled. 3. Pick an **Embedding Provider** and fill in its required fields. 4. Pick a **Vector Store** (`Qdrant` or `LanceDB`) and configure it. 5. Optionally adjust **Tuning Parameters** (search score, batch size, retries, max results). @@ -106,23 +88,9 @@ The prompt input panel shows a compact indexing status indicator that reflects t {% /tab %} {% tab label="CLI" %} -### 1. Enable the experimental flag +### Configure indexing -Codebase Indexing is gated behind an experimental flag. Until the flag is on, the `/indexing` command is hidden and `semantic_search` is unavailable. - -Set the flag in your `kilo.jsonc`: - -```json -{ - "experimental": { - "semantic_indexing": true - } -} -``` - -Restart the CLI for the change to take effect. The `/indexing` command (and aliases `/index`, `/embedding`) will appear in the command palette once the flag is active. - -### 2. Configure indexing +The `/indexing` command (and aliases `/index`, `/embedding`) is available when the indexing plugin is installed. Indexing remains disabled until it is enabled globally or for the current project. Open a Kilo TUI session and run: @@ -198,7 +166,7 @@ When indexing is enabled, the CLI shows an indexing status badge at the bottom o {% /tab %} {% tab label="VSCode (Legacy)" %} -The legacy extension does not require an experimental flag. +The legacy extension uses its own Codebase Indexing settings panel. ### Open Codebase Indexing Settings diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 889c12d8e71..0e0423b9046 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -4,7 +4,6 @@ type PluginSpec = string | [string, Record] type ConfigLike = { plugin?: readonly PluginSpec[] | null - experimental?: { semantic_indexing?: boolean } | null } export type Features = { @@ -13,6 +12,6 @@ export type Features = { export function configFeatures(config?: ConfigLike | null): Features { return { - indexing: hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true, + indexing: hasIndexingPlugin(config?.plugin ?? []), } } diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index cd91df1ecff..0d20e40d656 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -85,37 +85,19 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { - it("requires experimental.semantic_indexing when indexing plugin is present", () => { - expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(false) - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: {} }).indexing).toBe(false) - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: false } }).indexing).toBe( - false, - ) + it("enables indexing settings when the indexing plugin is present", () => { + expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) - it("detects supported indexing plugin specifiers when experimental.semantic_indexing is true", () => { - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: true } }).indexing).toBe(true) - expect( - configFeatures({ plugin: ["kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ plugin: ["@kilocode/kilo-indexing"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ plugin: ["@kilocode/kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ - plugin: ["file:///tmp/.opencode/plugin/kilo-indexing.js"], - experimental: { semantic_indexing: true }, - }).indexing, - ).toBe(true) - expect( - configFeatures({ - plugin: ["file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"], - experimental: { semantic_indexing: true }, - }).indexing, - ).toBe(true) + it("detects supported indexing plugin specifiers", () => { + expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["kilo-indexing@1.2.3"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["@kilocode/kilo-indexing"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["@kilocode/kilo-indexing@1.2.3"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["file:///tmp/.opencode/plugin/kilo-indexing.js"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"] }).indexing).toBe( + true, + ) }) it("ignores unrelated plugin lists", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 320153907f1..77d65f6779f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -164,19 +164,6 @@ const ExperimentalTab: Component = () => { - - updateExperimental("semantic_indexing", checked)} - hideLabel - > - {language.t("settings.experimental.semanticIndexing.title")} - - - { const [saved, setSaved] = createSignal>({}) const cfg: Config = { - experimental: { - semantic_indexing: true, - }, indexing: { provider: "openai", model: "text-embedding-3-large", @@ -439,9 +436,6 @@ export const IndexingKiloModelPreset: Story = { name: "IndexingTab - Kilo stale custom model fallback", render: () => { const cfg: Config = { - experimental: { - semantic_indexing: true, - }, indexing: { provider: "kilo", model: "custom/model", @@ -473,9 +467,6 @@ export const IndexingKiloCatalogLoading: Story = { render: () => { const [saved, setSaved] = createSignal>({}) const cfg: Config = { - experimental: { - semantic_indexing: true, - }, indexing: {}, } return ( diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 28497f83da9..bd47ed509b8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -40,7 +40,6 @@ export interface WatcherConfig { export interface ExperimentalConfig { disable_paste_summary?: boolean batch_tool?: boolean - semantic_indexing?: boolean codebase_search?: boolean speech_to_text_model?: string primary_tools?: string[] diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 48746eb0c9f..742ef72deef 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -74,7 +74,7 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { function normalizeLoadedConfig(data: unknown, source: string) { if (!isRecord(data)) return data - const copy = { ...data } + const copy = KilocodeConfig.retireIndexingFlag({ ...data }, source) // kilocode_change const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy if (!hadLegacy) return copy delete copy.theme @@ -350,9 +350,6 @@ export const Info = Schema.Struct({ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), // kilocode_change // kilocode_change start - semantic_indexing: Schema.optional(Schema.Boolean).annotate({ - description: "Enable semantic codebase indexing and the semantic_search tool", - }), speech_to_text_model: Schema.optional(Schema.String).annotate({ description: "Speech-to-text transcription model ID to use for voice input", }), diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 71844ca3d06..cbaf9afc362 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -115,6 +115,14 @@ export namespace KilocodeConfig { return stripGlobalIndexing(info) } + export function retireIndexingFlag(info: Record, source: string) { + if (!isRecord(info.experimental) || !("semantic_indexing" in info.experimental)) return info + const experimental = { ...info.experimental } + delete experimental.semantic_indexing + log.warn("ignored retired experimental.semantic_indexing config; use indexing.enabled instead", { path: source }) + return { ...info, experimental } + } + function stripGlobalIndexing(info: Config.Info): Config.Info { // Indexing provider/storage settings can be global, but enablement is exposed separately from project enablement. if (info.indexing?.enabled === undefined) return info diff --git a/packages/opencode/src/kilocode/indexing-feature.ts b/packages/opencode/src/kilocode/indexing-feature.ts index a19b1b316b9..3cb2e6042ce 100644 --- a/packages/opencode/src/kilocode/indexing-feature.ts +++ b/packages/opencode/src/kilocode/indexing-feature.ts @@ -9,7 +9,6 @@ type PluginSpec = string | [string, Record] type ConfigLike = { plugin?: readonly PluginSpec[] | null - experimental?: { semantic_indexing?: boolean } | null } type Req = { @@ -21,7 +20,7 @@ type LogLike = { } export function indexingEnabled(config?: ConfigLike | null): boolean { - return hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true + return hasIndexingPlugin(config?.plugin ?? []) } export function resolveIndexingPlugin(req: Req, log?: LogLike): string { diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts index 03ccf671b9e..ab0c883de69 100644 --- a/packages/opencode/src/kilocode/indexing.ts +++ b/packages/opencode/src/kilocode/indexing.ts @@ -242,15 +242,6 @@ export namespace KiloIndexing { return track(hit, await inert(() => missing())) } - if (cfg.experimental?.semantic_indexing !== true) { - return track( - hit, - await inert(() => - disabledIndexingStatus("Semantic indexing is disabled. Enable it in the Experimental settings."), - ), - ) - } - if (isWorktreePath(dir)) { return track(hit, await inert(() => worktreeDisabled())) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index cce16404aae..32327bbdc8b 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -54,9 +54,6 @@ async function writeConfig(dir: string, config: object, name = "kilo.json") { const cfg: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { provider: "ollama", vectorStore: "qdrant", @@ -93,6 +90,22 @@ describe("markdown substitutions", () => { }) describe("kilocode indexing config", () => { + test("ignores retired semantic indexing flags in existing configs", async () => { + await using tmp = await tmpdir({ git: true }) + await writeConfig(tmp.path, { + experimental: { semantic_indexing: true, batch_tool: true }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.batch_tool).toBe(true) + expect(config.experimental).not.toHaveProperty("semantic_indexing") + }, + }) + }) + test("keeps global indexing enabled in global config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/indexing-feature.test.ts b/packages/opencode/test/kilocode/indexing-feature.test.ts index 627e0d096f4..1cfde17a0b1 100644 --- a/packages/opencode/test/kilocode/indexing-feature.test.ts +++ b/packages/opencode/test/kilocode/indexing-feature.test.ts @@ -9,12 +9,8 @@ import { describe("indexing plugin helpers", () => { test("detects plugin-enabled configs", () => { expect(indexingEnabled({ plugin: ["global-plugin"] })).toBe(false) - expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(false) - expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: false } })).toBe(false) - expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: true } })).toBe(true) - expect( - indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"], experimental: { semantic_indexing: true } }), - ).toBe(true) + expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(true) + expect(indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"] })).toBe(true) }) test("adds indexing plugin when present but missing from config", () => { diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts index e90222ee0aa..14a5770b2bf 100644 --- a/packages/opencode/test/kilocode/indexing-startup.test.ts +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -16,9 +16,6 @@ const fetch = global.fetch const cfg: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: true, provider: "ollama", @@ -29,13 +26,9 @@ const cfg: Partial = { }, } -const off: Partial = { +const unset: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: false, - }, indexing: { - enabled: true, provider: "ollama", vectorStore: "qdrant", ollama: { @@ -45,9 +38,6 @@ const off: Partial = { } const inactive: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: false, provider: "ollama", @@ -56,9 +46,6 @@ const inactive: Partial = { } const kilo: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: true, vectorStore: "qdrant", @@ -66,9 +53,6 @@ const kilo: Partial = { } const implicitOpenAi: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: true, vectorStore: "qdrant", @@ -79,9 +63,6 @@ const implicitOpenAi: Partial = { } const staleKilo: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: true, provider: "kilo", @@ -303,8 +284,8 @@ describe("indexing startup degradation", () => { } }) - test("stays disabled when semantic indexing flag is off", async () => { - await using tmp = await tmpdir({ git: true, config: off }) + test("stays disabled when indexing enablement is unset", async () => { + await using tmp = await tmpdir({ git: true, config: unset }) process.env["KILO_CONFIG_DIR"] = tmp.path const init = spyOn(CodeIndexManager.prototype, "initialize") @@ -315,11 +296,11 @@ describe("indexing startup degradation", () => { expect(status).toMatchObject({ state: "Disabled", - message: "Semantic indexing is disabled. Enable it in the Experimental settings.", + message: "Indexing disabled.", }) expect(await KiloIndexing.available()).toBe(false) expect(KiloIndexing.ready()).toBe(false) - expect(await KiloIndexing.search("flag off")).toEqual([]) + expect(await KiloIndexing.search("disabled")).toEqual([]) expect(init).not.toHaveBeenCalled() }, }) diff --git a/packages/opencode/test/kilocode/indexing-worktree.test.ts b/packages/opencode/test/kilocode/indexing-worktree.test.ts index 0a9a303e505..fa42d92e1be 100644 --- a/packages/opencode/test/kilocode/indexing-worktree.test.ts +++ b/packages/opencode/test/kilocode/indexing-worktree.test.ts @@ -7,9 +7,6 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" const cfg: Partial = { plugin: ["@kilocode/kilo-indexing"], - experimental: { - semantic_indexing: true, - }, indexing: { enabled: true, provider: "ollama", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 653ff749dbc..4beaea772f3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1427,7 +1427,6 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean codebase_search?: boolean - semantic_indexing?: boolean speech_to_text_model?: string openTelemetry?: boolean primary_tools?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8f41c9639c3..a752025f001 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -16786,9 +16786,6 @@ "codebase_search": { "type": "boolean" }, - "semantic_indexing": { - "type": "boolean" - }, "speech_to_text_model": { "type": "string" }, From 0107a0163cf73004ee13b0ae5fd46811a273d80a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 16:06:54 +0200 Subject: [PATCH 03/19] feat(cli): guide Agent Manager recall usage --- .changeset/recall-sibling-context.md | 5 +++++ packages/opencode/src/kilocode/tool/agent-manager.txt | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/recall-sibling-context.md diff --git a/.changeset/recall-sibling-context.md b/.changeset/recall-sibling-context.md new file mode 100644 index 00000000000..3ecc1fb6f3d --- /dev/null +++ b/.changeset/recall-sibling-context.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Guide Agent Manager orchestration to recall completed session context only when needed. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index d1a85145af5..3c3c59bb4b3 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -10,4 +10,6 @@ Each task may provide a prompt, a short display name, and a branch name. Keep di By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. +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. From 96538336971ef8001fe2f9a40e5bb848c0e1051a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 16:08:38 +0200 Subject: [PATCH 04/19] test(cli): wait for disabled indexing startup status --- packages/opencode/test/kilocode/indexing-startup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts index 14a5770b2bf..63c873a994c 100644 --- a/packages/opencode/test/kilocode/indexing-startup.test.ts +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -292,7 +292,7 @@ describe("indexing startup degradation", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const status = await KiloIndexing.current() + const status = await wait(() => KiloIndexing.current(), "Disabled") expect(status).toMatchObject({ state: "Disabled", From cc5755d948d1f874ed031904530d6220d247a621 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 14:19:32 +0000 Subject: [PATCH 05/19] feat(vscode): add BYOK Gateway link in provider connect dialog footer --- .../settings/ProviderConnectDialog.tsx | 8 ++++++++ .../webview-ui/src/styles/dialogs.css | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx index 6dbfd0174ef..a6a8f162f20 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx @@ -381,6 +381,14 @@ const ProviderConnectDialog: Component = (props) =>
+ + For more usage stats, BYOK via Kilo's Gateway. + diff --git a/packages/kilo-vscode/webview-ui/src/styles/dialogs.css b/packages/kilo-vscode/webview-ui/src/styles/dialogs.css index e6cbfd3efae..ea589714222 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/dialogs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/dialogs.css @@ -9,9 +9,26 @@ .dialog-confirm-actions { display: flex; justify-content: flex-end; + align-items: center; gap: 8px; } +.provider-connect-byok-link { + margin-right: auto; + font-size: var(--kilo-font-size-12); + color: var(--text-weak-base); + text-decoration: none; +} + +.provider-connect-byok-link:hover { + color: var(--text-base); +} + +.provider-connect-byok-link span { + color: var(--vscode-textLink-foreground); + text-decoration: underline; +} + /* Provider Connect Dialog */ .provider-connect-body { font-size: var(--kilo-font-size-13); From 63f39f6ae49dd7f9d5a8115f3907d53a3b92a4dd Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 16:47:54 +0200 Subject: [PATCH 06/19] test(vscode): enforce scoped webview accessibility coverage --- .../webview-accessibility-validation.md | 5 ++ .github/workflows/visual-regression.yml | 3 +- bun.lock | 6 ++ packages/kilo-vscode/.storybook/main.ts | 2 +- packages/kilo-vscode/.storybook/preview.tsx | 1 + packages/kilo-vscode/package.json | 3 + packages/kilo-vscode/script/launch.ts | 10 +++- .../kilo-vscode/tests/accessibility.spec.ts | 57 +++++++++++++++++++ 8 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 .changeset/webview-accessibility-validation.md create mode 100644 packages/kilo-vscode/tests/accessibility.spec.ts diff --git a/.changeset/webview-accessibility-validation.md b/.changeset/webview-accessibility-validation.md new file mode 100644 index 00000000000..194f77bd216 --- /dev/null +++ b/.changeset/webview-accessibility-validation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support accessibility regression checks and assistive-technology testing for VS Code webviews. diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index d059175adc8..b71e88032cc 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -26,6 +26,7 @@ jobs: - "packages/kilo-vscode/.storybook/**" - "packages/kilo-vscode/tests/visual-regression*" - "packages/kilo-vscode/tests/permission-dock-dropdown*" + - "packages/kilo-vscode/tests/accessibility*" - "packages/kilo-docs/public/img/screenshot-tests/**" - ".github/workflows/visual-regression.yml" - name: Check if PR is from a fork @@ -277,7 +278,7 @@ jobs: run: bun run build-storybook working-directory: packages/kilo-vscode - - name: Generate baselines for new/missing stories + - name: Generate baselines and enforce webview accessibility checks run: bun run test:visual:update working-directory: packages/kilo-vscode env: diff --git a/bun.lock b/bun.lock index 80be92af405..8631641321f 100644 --- a/bun.lock +++ b/bun.lock @@ -266,7 +266,9 @@ "zod": "^3.24.2", }, "devDependencies": { + "@axe-core/playwright": "4.11.3", "@playwright/test": "1.57.0", + "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@types/diff": "^6.0.0", "@types/mocha": "^10.0.10", @@ -832,6 +834,8 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], + "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], "@azu/style-format": ["@azu/style-format@1.0.1", "", { "dependencies": { "@azu/format-text": "^1.0.1" } }, "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g=="], @@ -4444,6 +4448,8 @@ "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], + "@axe-core/playwright/axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], diff --git a/packages/kilo-vscode/.storybook/main.ts b/packages/kilo-vscode/.storybook/main.ts index d1ef464f284..300333356fb 100644 --- a/packages/kilo-vscode/.storybook/main.ts +++ b/packages/kilo-vscode/.storybook/main.ts @@ -5,7 +5,7 @@ import solidPlugin from "vite-plugin-solid" const config: StorybookConfig = { framework: "storybook-solidjs-vite", stories: ["../webview-ui/src/stories/**/*.stories.@(ts|tsx)"], - addons: ["@storybook/addon-docs"], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], staticDirs: [{ from: "../assets/icons", to: "/icons" }], refs: {}, viteFinal: async (config) => { diff --git a/packages/kilo-vscode/.storybook/preview.tsx b/packages/kilo-vscode/.storybook/preview.tsx index 21758200fda..7add8f8897e 100644 --- a/packages/kilo-vscode/.storybook/preview.tsx +++ b/packages/kilo-vscode/.storybook/preview.tsx @@ -82,6 +82,7 @@ const preview: Preview = { theme: "kilo-vscode", colorScheme: "dark", vscodeTheme: "dark-modern", + a11y: { manual: true }, }, } diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index bbcff2d2d19..5c90122c127 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1015,6 +1015,7 @@ "rebuild-sdk": "bun run --cwd ../sdk/js build", "storybook": "storybook dev -p 6007", "build-storybook": "storybook build -o storybook-static", + "test:a11y": "playwright test tests/accessibility.spec.ts", "test:visual": "playwright test", "test:visual:update": "playwright test --update-snapshots", "snapshot:build": "bun script/dev-snapshot.ts build", @@ -1022,7 +1023,9 @@ "extension": "bun script/launch.ts" }, "devDependencies": { + "@axe-core/playwright": "4.11.3", "@playwright/test": "1.57.0", + "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@types/diff": "^6.0.0", "@types/mocha": "^10.0.10", diff --git a/packages/kilo-vscode/script/launch.ts b/packages/kilo-vscode/script/launch.ts index caa0a8b4936..92f48a740a1 100644 --- a/packages/kilo-vscode/script/launch.ts +++ b/packages/kilo-vscode/script/launch.ts @@ -14,6 +14,7 @@ * --wait Block until the VS Code window is closed * --clean Wipe the user-data and extensions dirs before launching * --preserve-settings Merge defaults into existing VS Code user settings + * --accessible Enable VS Code accessibility support for assistive-technology testing * * Environment: * VSCODE_EXEC_PATH Path to VS Code executable (same as --app-path) @@ -86,6 +87,7 @@ const explicit = opts["app-path"] as string | undefined const blocking = opts["wait"] === true const clean = opts["clean"] === true const preserve = opts["preserve-settings"] === true +const accessible = opts["accessible"] === true // --------------------------------------------------------------------------- // VS Code executable detection @@ -251,11 +253,11 @@ async function installVsix(path: string, app: string) { // Settings for isolated instance // --------------------------------------------------------------------------- -function settings(keep: boolean) { +function settings(keep: boolean, enabled: boolean) { const dir = join(userDir, "User") const file = join(dir, "settings.json") const defaults = { - "editor.accessibilitySupport": "off", + "editor.accessibilitySupport": enabled ? "on" : "off", "extensions.autoCheckUpdates": false, "extensions.autoUpdate": false, "extensions.ignoreRecommendations": true, @@ -270,6 +272,7 @@ function settings(keep: boolean) { mkdirSync(dir, { recursive: true }) const cfg = keep && existsSync(file) ? { ...defaults, ...load(file) } : defaults + if (enabled) cfg["editor.accessibilitySupport"] = "on" writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n") } @@ -306,7 +309,7 @@ async function launch() { const app = detect() - settings(preserve) + settings(preserve, accessible) const args = [workspace, `--extensions-dir=${extDir}`, `--user-data-dir=${userDir}`, "--skip-release-notes"] @@ -338,6 +341,7 @@ async function launch() { console.log(`[launch] Executable: ${app}`) console.log(`[launch] Workspace: ${workspace}`) console.log(`[launch] State: ${base}`) + console.log(`[launch] Accessibility support: ${accessible ? "on" : "off"}`) if (blocking) { const result = Bun.spawnSync([app, ...args], { diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts new file mode 100644 index 00000000000..43b49354113 --- /dev/null +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -0,0 +1,57 @@ +import AxeBuilder from "@axe-core/playwright" +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const RULES = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"] + +// Explicitly ratchet in repaired/stable workflows rather than making existing +// untriaged Storybook findings block unrelated webview changes. +const STORIES = [ + { id: "profile--not-logged-in", name: "Profile / not logged in" }, + { id: "profile--logged-in-personal", name: "Profile / personal account" }, + { id: "profile--logged-in", name: "Profile / organization account" }, + { id: "settings--providers-configure", name: "Settings / providers empty state" }, + { id: "marketplace--skills-tab-empty", name: "Marketplace / skills empty state" }, + { id: "marketplace--agents-tab-empty", name: "Marketplace / agents empty state" }, +] + +function url(id: string) { + return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` +} + +async function open(page: Page, id: string) { + await page.goto(url(id), { waitUntil: "load" }) + await page.waitForSelector("#storybook-root *", { state: "attached" }) +} + +async function scan(page: Page) { + const result = await new AxeBuilder({ page }).include("#storybook-root").withTags(RULES).analyze() + const details = result.violations + .map((item) => `${item.id}: ${item.help}\n${item.nodes.map((node) => ` ${node.target.join(" ")}`).join("\n")}`) + .join("\n") + + expect(result.violations, details).toEqual([]) +} + +test.describe("webview accessibility ratchet", () => { + for (const story of STORIES) { + test(`${story.name} passes automated WCAG checks`, async ({ page }) => { + await open(page, story.id) + await scan(page) + }) + } + + test("Profile login exposes a keyboard-operable named control", async ({ page }) => { + await open(page, "profile--not-logged-in") + + const login = page.getByRole("button", { name: "Login with Kilo Code" }) + await page.keyboard.press("Tab") + await expect(login).toBeFocused() + + await login.evaluate((node) => { + node.addEventListener("click", () => node.setAttribute("data-keyboard-activated", "true"), { once: true }) + }) + await page.keyboard.press("Enter") + await expect(login).toHaveAttribute("data-keyboard-activated", "true") + }) +}) From ffad65db8e8fe061d86cbedb133521dcdeb44024 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 17:01:56 +0200 Subject: [PATCH 07/19] fix(vscode): address accessibility coverage feedback --- .github/workflows/visual-regression.yml | 4 ++-- packages/kilo-vscode/script/launch.ts | 6 ++++-- packages/kilo-vscode/tests/accessibility.spec.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index b71e88032cc..6717c0fff59 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -26,7 +26,7 @@ jobs: - "packages/kilo-vscode/.storybook/**" - "packages/kilo-vscode/tests/visual-regression*" - "packages/kilo-vscode/tests/permission-dock-dropdown*" - - "packages/kilo-vscode/tests/accessibility*" + - "packages/kilo-vscode/tests/accessibility*" # kilocode_change - "packages/kilo-docs/public/img/screenshot-tests/**" - ".github/workflows/visual-regression.yml" - name: Check if PR is from a fork @@ -278,7 +278,7 @@ jobs: run: bun run build-storybook working-directory: packages/kilo-vscode - - name: Generate baselines and enforce webview accessibility checks + - name: Generate baselines and enforce webview accessibility checks # kilocode_change run: bun run test:visual:update working-directory: packages/kilo-vscode env: diff --git a/packages/kilo-vscode/script/launch.ts b/packages/kilo-vscode/script/launch.ts index 92f48a740a1..464597bbc8d 100644 --- a/packages/kilo-vscode/script/launch.ts +++ b/packages/kilo-vscode/script/launch.ts @@ -271,8 +271,10 @@ function settings(keep: boolean, enabled: boolean) { } mkdirSync(dir, { recursive: true }) - const cfg = keep && existsSync(file) ? { ...defaults, ...load(file) } : defaults - if (enabled) cfg["editor.accessibilitySupport"] = "on" + const cfg = + keep && existsSync(file) + ? { ...defaults, ...load(file), ...(enabled ? { "editor.accessibilitySupport": "on" } : {}) } + : defaults writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n") } diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 43b49354113..4600a9f0cd6 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -1,5 +1,5 @@ import AxeBuilder from "@axe-core/playwright" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" const RULES = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"] @@ -33,6 +33,13 @@ async function scan(page: Page) { expect(result.violations, details).toEqual([]) } +async function reach(page: Page, target: Locator) { + for (let step = 0; step < 10; step++) { + await page.keyboard.press("Tab") + if (await target.evaluate((node) => node === document.activeElement)) return + } +} + test.describe("webview accessibility ratchet", () => { for (const story of STORIES) { test(`${story.name} passes automated WCAG checks`, async ({ page }) => { @@ -45,7 +52,7 @@ test.describe("webview accessibility ratchet", () => { await open(page, "profile--not-logged-in") const login = page.getByRole("button", { name: "Login with Kilo Code" }) - await page.keyboard.press("Tab") + await reach(page, login) await expect(login).toBeFocused() await login.evaluate((node) => { From d3c5f2886f07dbcd7669ee691a6a2a0b72a6f6e1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 17:11:31 +0200 Subject: [PATCH 08/19] fix(vscode): make session history accessible to screen readers --- .changeset/clear-history-navigation.md | 5 + .../tests/history-accessibility.spec.ts | 76 ++++++++++++++ .../components/history/CloudSessionList.tsx | 16 +++ .../src/components/history/HistoryView.tsx | 68 +++++++++++-- .../src/components/history/SessionList.tsx | 99 ++++++++++++------- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + .../src/stories/history.stories.tsx | 23 ++++- .../webview-ui/src/styles/history.css | 33 ++++++- 26 files changed, 305 insertions(+), 53 deletions(-) create mode 100644 .changeset/clear-history-navigation.md create mode 100644 packages/kilo-vscode/tests/history-accessibility.spec.ts diff --git a/.changeset/clear-history-navigation.md b/.changeset/clear-history-navigation.md new file mode 100644 index 00000000000..4edf1ee7dcf --- /dev/null +++ b/.changeset/clear-history-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Correct screen reader and keyboard operation for session history rows and Local or Cloud history navigation. diff --git a/packages/kilo-vscode/tests/history-accessibility.spec.ts b/packages/kilo-vscode/tests/history-accessibility.spec.ts new file mode 100644 index 00000000000..c2b791738ab --- /dev/null +++ b/packages/kilo-vscode/tests/history-accessibility.spec.ts @@ -0,0 +1,76 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +function story(page: Page, id: string) { + return page.goto(`/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) +} + +test.describe("history session accessibility", () => { + test("opens a selected session through a standalone named row control", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const row = page.getByRole("button", { name: /Refactor authentication module.*Current session/ }) + await expect(row).toHaveAttribute("data-selected", "true") + await expect(page.locator('[data-slot="list-item"] button')).toHaveCount(0) + + await row.focus() + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="selected-session"]')).toHaveText("s1") + }) + + test("announces the active filtered result before Enter opens it", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const search = page.getByPlaceholder("Search sessions...") + await search.fill("screenshot") + await expect(search).toBeFocused() + await expect(page.locator('[data-slot="session-list-status"]')).toHaveText("Add screenshot test coverage") + + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="selected-session"]')).toHaveText("s2") + }) + + test("focuses and activates separate named rename and delete controls", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const rename = page.getByRole("button", { name: "Rename: Add screenshot test coverage" }) + await rename.focus() + await expect(rename).toBeFocused() + await page.keyboard.press("Enter") + await expect(page.getByRole("textbox", { name: "Rename" })).toBeFocused() + + await story(page, "history-sessionlist--with-items") + const remove = page.getByRole("button", { name: "Delete session: Add screenshot test coverage" }) + await remove.focus() + await expect(remove).toBeFocused() + await page.keyboard.press("Enter") + await expect(page.getByRole("dialog", { name: "Delete session" })).toBeVisible() + }) + + test("exposes Local and Cloud as keyboard navigable selected tabs", async ({ page }) => { + await story(page, "history-sessionlist--sources") + + const local = page.getByRole("tab", { name: "Local" }) + const cloud = page.getByRole("tab", { name: "Cloud" }) + await expect(page.getByRole("tablist", { name: "History source" })).toBeVisible() + await expect(local).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Local" })).toBeVisible() + + await local.focus() + await page.keyboard.press("ArrowRight") + await expect(cloud).toBeFocused() + await expect(local).toHaveAttribute("aria-selected", "true") + await page.keyboard.press("Enter") + await expect(cloud).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Cloud" })).toBeVisible() + await expect(page.getByPlaceholder("Search sessions...")).toBeFocused() + + await cloud.focus() + await page.keyboard.press("ArrowLeft") + await expect(local).toBeFocused() + await page.keyboard.press("Enter") + await expect(local).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Local" })).toBeVisible() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx index 5314ca57033..79e7d8b8ff5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx @@ -63,9 +63,11 @@ const CloudSessionList: Component = (props) => { const [gitUrl, setGitUrl] = createSignal(null) const [repoOnly, setRepoOnly] = createSignal(true) const [initialized, setInitialized] = createSignal(false) + const [notice, setNotice] = createSignal("") let loadGen = 0 let activeGen = 0 + let seq = 0 const unsub = vscode.onMessage((message: ExtensionMessage) => { if (message.type === "cloudSessionsLoaded") { @@ -110,6 +112,16 @@ const CloudSessionList: Component = (props) => { }) }) + function announce(s: DisplaySession | undefined) { + const id = ++seq + setNotice("") + if (!s) return + queueMicrotask(() => { + if (id !== seq) return + setNotice(s.title) + }) + } + function loadMore() { const cursor = nextCursor() if (!cursor || loading()) return @@ -130,6 +142,7 @@ const CloudSessionList: Component = (props) => { items={sessions()} key={(s) => s.id} filterKeys={["title"]} + onMove={announce} onSelect={(s) => { if (s) props.onSelectSession?.(s.id) }} @@ -161,6 +174,9 @@ const CloudSessionList: Component = (props) => { )} +
+ {notice()} +
-
+
@@ -81,12 +116,25 @@ const HistoryView: Component = (props) => {
-
- {tab() === "local" ? ( - - ) : ( - - )} + +
) diff --git a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx index 6a0cbed69d4..b1f2d2eaa80 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx @@ -47,6 +47,8 @@ const SessionList: Component = (props) => { const [renamingId, setRenamingId] = createSignal(null) const [pendingRenameId, setPendingRenameId] = createSignal(null) + const [notice, setNotice] = createSignal("") + let seq = 0 onMount(() => { console.log("[Kilo New] SessionList mounted, loading sessions") @@ -74,11 +76,30 @@ const SessionList: Component = (props) => { setRenamingId(null) } + function name(s: SessionInfo) { + return s.title || language.t("session.untitled") + } + + function label(action: string, s: SessionInfo) { + return `${action}: ${name(s)}` + } + + function announce(s: SessionInfo | undefined) { + const id = ++seq + setNotice("") + if (!s) return + queueMicrotask(() => { + if (id !== seq) return + const current = session.currentSessionID() === s.id ? `. ${language.t("session.current")}` : "" + setNotice(`${name(s)}${current}`) + }) + } + function confirmDelete(s: SessionInfo) { dialog.show(() => (
- {language.t("session.delete.confirm", { name: s.title || language.t("session.untitled") })} + {language.t("session.delete.confirm", { name: name(s) })}
) } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 65a9fca70bc..3096e9cc52c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -972,6 +972,8 @@ export const dict = { "session.delete.confirm": 'حذف الجلسة "{{name}}"؟', "session.delete.button": "حذف الجلسة", "session.untitled": "بدون عنوان", + "session.current": "الجلسة الحالية", + "session.history.sources": "مصدر السجل", "session.recent": "الأخيرة", "session.showHistory": "عرض السجل", "session.search.placeholder": "البحث في الجلسات...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 978635e1d7f..29be47eb3af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -987,6 +987,8 @@ export const dict = { "session.delete.confirm": 'Excluir sessão "{{name}}"?', "session.delete.button": "Excluir sessão", "session.untitled": "Sem título", + "session.current": "Sessão atual", + "session.history.sources": "Fonte do histórico", "session.recent": "Recentes", "session.showHistory": "Mostrar Histórico", "session.search.placeholder": "Buscar sessões...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a8cffff9bbf..f74f47c2b73 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1029,6 +1029,8 @@ export const dict = { "session.delete.confirm": 'Izbriši sesiju "{{name}}"?', "session.delete.button": "Izbriši sesiju", "session.untitled": "Bez naslova", + "session.current": "Trenutna sesija", + "session.history.sources": "Izvor historije", "session.recent": "Nedavne", "session.showHistory": "Prikaži historiju", "session.search.placeholder": "Pretraži sesije...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index c8e2564d104..972e7345242 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1021,6 +1021,8 @@ export const dict = { "session.delete.confirm": 'Slet session "{{name}}"?', "session.delete.button": "Slet session", "session.untitled": "Unavngivet", + "session.current": "Aktuel session", + "session.history.sources": "Historikkilde", "session.recent": "Seneste", "session.showHistory": "Vis historik", "session.search.placeholder": "Søg sessioner...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 10dcae819cf..4916f8e148b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1036,6 +1036,8 @@ export const dict = { "session.delete.confirm": 'Sitzung "{{name}}" löschen?', "session.delete.button": "Sitzung löschen", "session.untitled": "Unbenannt", + "session.current": "Aktuelle Sitzung", + "session.history.sources": "Quelle des Verlaufs", "session.recent": "Kürzlich", "session.showHistory": "Verlauf anzeigen", "session.search.placeholder": "Sitzungen suchen...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 209473ffec2..b1e1dea8bd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -947,8 +947,10 @@ export const dict = { "session.delete.confirm": 'Delete session "{{name}}"?', "session.delete.button": "Delete session", "session.untitled": "Untitled", + "session.current": "Current session", "session.recent": "Recent", "session.showHistory": "Show History", + "session.history.sources": "History source", "session.search.placeholder": "Search sessions...", "session.empty": "No sessions yet. Click + to start a new conversation.", "session.tab.local": "Local", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 41e631baae0..6b03530f744 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1031,6 +1031,8 @@ export const dict = { "session.delete.confirm": '¿Eliminar sesión "{{name}}"?', "session.delete.button": "Eliminar sesión", "session.untitled": "Sin título", + "session.current": "Sesión actual", + "session.history.sources": "Origen del historial", "session.recent": "Recientes", "session.showHistory": "Mostrar historial", "session.search.placeholder": "Buscar sesiones...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 00e97da1e08..7ea9c3f65a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1039,6 +1039,8 @@ export const dict = { "session.delete.confirm": 'Supprimer la session "{{name}}" ?', "session.delete.button": "Supprimer la session", "session.untitled": "Sans titre", + "session.current": "Session actuelle", + "session.history.sources": "Source de l'historique", "session.recent": "Récentes", "session.showHistory": "Afficher l'historique", "session.search.placeholder": "Rechercher des sessions...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 3cbf57e3233..d65b7755ffe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1015,6 +1015,8 @@ export const dict = { "session.delete.confirm": 'セッション "{{name}}" を削除しますか?', "session.delete.button": "セッションを削除", "session.untitled": "無題", + "session.current": "現在のセッション", + "session.history.sources": "履歴のソース", "session.recent": "最近", "session.showHistory": "履歴を表示", "session.search.placeholder": "セッションを検索...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 49d970cab1f..df6a0582dd3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -977,6 +977,8 @@ export const dict = { "session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?', "session.delete.button": "세션 삭제", "session.untitled": "제목 없음", + "session.current": "현재 세션", + "session.history.sources": "기록 출처", "session.recent": "최근", "session.showHistory": "기록 보기", "session.search.placeholder": "세션 검색...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index bbf88c3558c..6c6009848e8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -984,6 +984,8 @@ export const dict = { "session.delete.confirm": 'Sessie "{{name}}" verwijderen?', "session.delete.button": "Verwijder sessie", "session.untitled": "Naamloos", + "session.current": "Huidige sessie", + "session.history.sources": "Geschiedenisbron", "session.recent": "Recent", "session.showHistory": "Geschiedenis weergeven", "session.search.placeholder": "Zoek sessies...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index b63c4f10db9..914f49c01c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -988,6 +988,8 @@ export const dict = { "session.delete.confirm": 'Slette sesjonen "{{name}}"?', "session.delete.button": "Slett sesjon", "session.untitled": "Uten tittel", + "session.current": "Gjeldende økt", + "session.history.sources": "Historikkilde", "session.recent": "Nylige", "session.showHistory": "Vis historikk", "session.search.placeholder": "Søk i sesjoner...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index ded68719eed..182ed2902c7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -987,6 +987,8 @@ export const dict = { "session.delete.confirm": 'Usunąć sesję "{{name}}"?', "session.delete.button": "Usuń sesję", "session.untitled": "Bez tytułu", + "session.current": "Bieżąca sesja", + "session.history.sources": "Źródło historii", "session.recent": "Ostatnie", "session.showHistory": "Pokaż historię", "session.search.placeholder": "Szukaj sesji...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6c896b8b3a6..999d9ca9da8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1026,6 +1026,8 @@ export const dict = { "session.delete.confirm": 'Удалить сессию "{{name}}"?', "session.delete.button": "Удалить сессию", "session.untitled": "Без названия", + "session.current": "Текущая сессия", + "session.history.sources": "Источник истории", "session.recent": "Недавние", "session.showHistory": "Показать историю", "session.search.placeholder": "Поиск сессий...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 595f78de6c7..76ff742a59d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1012,6 +1012,8 @@ export const dict = { "session.delete.confirm": 'ลบเซสชัน "{{name}}" หรือไม่?', "session.delete.button": "ลบเซสชัน", "session.untitled": "ไม่มีชื่อ", + "session.current": "เซสชันปัจจุบัน", + "session.history.sources": "แหล่งที่มาของประวัติ", "session.recent": "ล่าสุด", "session.showHistory": "แสดงประวัติ", "session.search.placeholder": "ค้นหาเซสชัน...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 55506f6227d..b5dd9eb8e8a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -982,6 +982,8 @@ export const dict = { "session.delete.confirm": '"{{name}}" oturumu silinsin mi?', "session.delete.button": "Oturumu sil", "session.untitled": "Adsız", + "session.current": "Geçerli oturum", + "session.history.sources": "Geçmiş kaynağı", "session.recent": "Son", "session.showHistory": "Geçmişi Göster", "session.search.placeholder": "Oturum ara...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index bcec1940cba..2bb3b56509d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -983,6 +983,8 @@ export const dict = { "session.delete.confirm": 'Видалити сесію "{{name}}"?', "session.delete.button": "Видалити сесію", "session.untitled": "Без назви", + "session.current": "Поточна сесія", + "session.history.sources": "Джерело історії", "session.recent": "Останні", "session.showHistory": "Показати історію", "session.search.placeholder": "Пошук сесій...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index f427e567ab4..624ccd5df50 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -996,6 +996,8 @@ export const dict = { "session.delete.confirm": '删除会话 "{{name}}"?', "session.delete.button": "删除会话", "session.untitled": "无标题", + "session.current": "当前会话", + "session.history.sources": "历史记录来源", "session.recent": "最近", "session.showHistory": "显示历史", "session.search.placeholder": "搜索会话...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 1acda70b28e..69a25a2065f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -964,6 +964,8 @@ export const dict = { "session.delete.confirm": '刪除工作階段 "{{name}}"?', "session.delete.button": "刪除工作階段", "session.untitled": "未命名", + "session.current": "目前的工作階段", + "session.history.sources": "歷史記錄來源", "session.recent": "最近", "session.showHistory": "顯示歷史", "session.search.placeholder": "搜尋工作階段...", diff --git a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx index 751a75f66a6..604416c539d 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx @@ -25,6 +25,7 @@ import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en" import { dict as appEn } from "../i18n/en" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" import SessionList from "../components/history/SessionList" +import HistoryView from "../components/history/HistoryView" const dict: Record = { ...appEn, ...uiEn, ...kiloEn } function t(key: string) { @@ -150,12 +151,32 @@ const meta: Meta = { export default meta type Story = StoryObj +const SessionListDemo = () => { + const [selected, setSelected] = createSignal("") + + return ( + +
+ + + {selected()} + +
+
+ ) +} + export const WithItems: Story = { name: "With sessions", + render: () => , +} + +export const Sources: Story = { + name: "Local and cloud sources", render: () => (
- +
), diff --git a/packages/kilo-vscode/webview-ui/src/styles/history.css b/packages/kilo-vscode/webview-ui/src/styles/history.css index 83be0683e90..e61598222ff 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/history.css +++ b/packages/kilo-vscode/webview-ui/src/styles/history.css @@ -36,14 +36,33 @@ body.vscode-light color: var(--text-on-interactive-base, white); } +.session-list .session-row { + display: flex; + align-items: center; + align-self: stretch; + width: 100%; +} + +.session-list .session-row > [data-slot="list-item"] { + flex: 1; + min-width: 0; + width: auto; +} + +.session-list [data-slot="session-row-editor"] { + display: flex; + width: 100%; + padding: 2px 8px; +} + body.vscode-light .session-list - [data-slot="list-item"][data-active="true"] + .session-row:has([data-slot="list-item"][data-active="true"]) [data-slot="session-row-action"] [data-slot="icon-svg"], body.vscode-light .session-list - [data-slot="list-item"][data-selected="true"] + .session-row:has([data-slot="list-item"][data-selected="true"]) [data-slot="session-row-action"] [data-slot="icon-svg"] { color: var(--text-on-interactive-base, white); @@ -58,9 +77,9 @@ body.vscode-light transition: opacity 0.15s; } -.session-list [data-slot="list-item"]:hover [data-slot="session-row-action"], -.session-list [data-slot="list-item"][data-active="true"] [data-slot="session-row-action"], -.session-list [data-slot="session-row-action"]:focus-within { +.session-list .session-row:hover [data-slot="session-row-action"], +.session-list .session-row:focus-within [data-slot="session-row-action"], +.session-list .session-row:has([data-slot="list-item"][data-active="true"]) [data-slot="session-row-action"] { opacity: 1; } @@ -146,6 +165,10 @@ body.vscode-light flex-direction: column; } +.history-view-content[hidden] { + display: none; +} + /* Cloud Session List (inside History View) */ .cloud-session-list { display: flex; From 38fcaa65e7320e3befa73066ee1a890057d7173b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 17:12:43 +0200 Subject: [PATCH 09/19] fix(vscode): make model selection accessible to screen readers --- .changeset/clear-model-navigation.md | 5 + .../model-selector-accessibility.spec.ts | 132 +++++ .../src/components/settings/ModeEditView.tsx | 2 + .../src/components/settings/ModelsTab.tsx | 10 + .../src/components/shared/ModelSelector.tsx | 530 ++++++++++-------- .../src/stories/settings.stories.tsx | 11 + .../webview-ui/src/stories/shared.stories.tsx | 61 +- .../webview-ui/src/styles/model-selector.css | 52 +- 8 files changed, 541 insertions(+), 262 deletions(-) create mode 100644 .changeset/clear-model-navigation.md create mode 100644 packages/kilo-vscode/tests/model-selector-accessibility.spec.ts diff --git a/.changeset/clear-model-navigation.md b/.changeset/clear-model-navigation.md new file mode 100644 index 00000000000..932636c1a70 --- /dev/null +++ b/.changeset/clear-model-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make model selection in chat and settings operable with screen readers by announcing searchable options, keyboard navigation, selected values, and model-setting purpose. diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts new file mode 100644 index 00000000000..c413a11fcff --- /dev/null +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +function story(id: string) { + return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` +} + +async function load(page: Page, id: string) { + await page.goto(story(id), { waitUntil: "load" }) + await page.waitForSelector("#storybook-root *", { state: "attached" }) +} + +test("model selector exposes combobox relationships and active option movement", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const listbox = page.getByRole("listbox", { name: "Review model" }) + const alpha = page.getByRole("option", { name: "Alpha" }) + const bravo = page.getByRole("option", { name: "Bravo" }) + + await expect(combobox).toBeFocused() + await expect(combobox).toHaveAttribute("aria-expanded", "true") + await expect(combobox).toHaveAttribute("aria-controls", await listbox.getAttribute("id")) + await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id")) + await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.") + await expect(alpha.locator("button")).toHaveCount(0) + await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible() + + await combobox.press("ArrowDown") + await expect(combobox).toBeFocused() + await expect(combobox).toHaveAttribute("aria-activedescendant", await bravo.getAttribute("id")) + + const expand = page.getByRole("button", { name: "Expand" }) + const controls = await expand.getAttribute("aria-controls") + const preview = page.locator(`[id="${controls}"]`) + await expect(expand).toHaveAttribute("aria-expanded", "false") + await expect(preview).toHaveAttribute("aria-hidden", "true") + await expand.click() + const collapse = page.getByRole("button", { name: "Collapse", exact: true }) + await expect(collapse).toHaveAttribute("aria-controls", controls!) + await expect(collapse).toHaveAttribute("aria-expanded", "true") + await expect(preview).toHaveAttribute("aria-hidden", "false") +}) + +test("selected favorite remains selected when its duplicate group is collapsed", async ({ page }) => { + await load(page, "shared--model-selector-selected-favorite") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const alpha = page.getByRole("option", { name: "Alpha" }) + await expect(alpha.first()).toHaveAttribute("aria-selected", "true") + + await page.getByRole("button", { name: "Collapse Favorites" }).click() + await expect(alpha).toHaveCount(1) + await expect(alpha).toHaveAttribute("aria-selected", "true") + await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id")) +}) + +test("Enter selects the active option and Escape restores selector focus", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + await combobox.press("ArrowDown") + await combobox.press("Enter") + + const trigger = page.getByRole("button", { name: "Review model: Bravo" }) + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") + await expect(trigger).toBeFocused() + + await trigger.click() + const reopened = page.getByRole("combobox", { name: "Review model: Bravo. Search models" }) + await reopened.press("ArrowDown") + await reopened.press("Escape") + + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") + await expect(trigger).toBeFocused() +}) + +test("no-match search announces the empty result and can choose the default option", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + await combobox.fill("no matching model") + + await expect(page.locator(".model-selector-empty")).toHaveText("No model results") + const clear = page.getByRole("option", { name: "Use default model" }) + await expect(combobox).toHaveAttribute("aria-activedescendant", await clear.getAttribute("id")) + await combobox.press("Enter") + + await expect(page.getByTestId("model-selector-value")).toHaveText("default") + await expect(page.getByRole("button", { name: "Review model: Use default model" })).toBeFocused() +}) + +test("settings and mode editing expose distinct model field purposes", async ({ page }) => { + await load(page, "settings--models-accessible-labels") + + await expect(page.getByRole("button", { name: "Default Model: Not set" })).toHaveAccessibleDescription( + "Primary model for conversations", + ) + await expect(page.getByRole("button", { name: "Small Model: Not set" })).toHaveAccessibleDescription( + /Lightweight model/, + ) + await expect(page.getByRole("button", { name: "Subagent Model: Not set" })).toHaveAccessibleDescription( + /Default model and reasoning effort/, + ) + await expect(page.getByRole("button", { name: "Autocomplete model: Not set" })).toHaveAccessibleDescription( + "Select the model used for inline code completions", + ) + await expect(page.getByRole("button", { name: "Model per Mode: code: Not set" })).toHaveAccessibleDescription( + /Override the default model for specific modes/, + ) + + await load(page, "settings--mode-edit-export") + await expect(page.getByRole("button", { name: /Model Override:/ })).toHaveAccessibleDescription( + "Override the default model for this agent", + ) +}) + +test("chat picker Escape returns focus to the prompt", async ({ page }) => { + await load(page, "prompt-input--default-420") + + await page.getByRole("button", { name: /^Select model:/ }).click() + const combobox = page.getByRole("combobox", { name: /^Select model:.*Search models$/ }) + await expect(combobox).toBeFocused() + await combobox.press("Escape") + + await expect(page.locator("textarea.prompt-input")).toBeFocused() +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx index 12ba482b3eb..159ef186272 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx @@ -180,6 +180,8 @@ const ModeEditView: Component = (props) => { placement="bottom-start" allowClear clearLabel={language.t("settings.providers.notSet")} + label={language.t("settings.agentBehaviour.modelOverride.title")} + description={language.t("settings.agentBehaviour.modelOverride.description")} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index c01e0434094..a3fc83d1c21 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -101,6 +101,8 @@ const ModelsTab: Component = () => { placement="bottom-start" allowClear clearLabel={language.t("settings.providers.notSet")} + label={language.t("settings.providers.defaultModel.title")} + description={language.t("settings.providers.defaultModel.description")} /> { allowClear clearLabel={language.t("settings.providers.notSet")} includeAutoSmall + label={language.t("settings.providers.smallModel.title")} + description={language.t("settings.providers.smallModel.description")} /> { placement="bottom-start" allowClear clearLabel={language.t("settings.providers.notSet")} + label={language.t("settings.providers.subagentModel.title")} + description={language.t("settings.providers.subagentModel.description")} /> { favorites={false} allowClear clearLabel={language.t("settings.providers.notSet")} + label={language.t("settings.autocomplete.model.title")} + description={language.t("settings.autocomplete.model.description")} /> @@ -167,6 +175,8 @@ const ModelsTab: Component = () => { placement="bottom-start" allowClear clearLabel={language.t("settings.providers.notSet")} + label={`${language.t("settings.providers.modeModels")}: ${agent.name}`} + description={language.t("settings.providers.modeModels.description")} /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 98698a5a6d7..5533c16c748 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -7,7 +7,18 @@ * ModelSelector — thin wrapper wired to session context for chat usage. */ -import { createSignal, createMemo, createEffect, onCleanup, For, Show, createSelector, useContext } from "solid-js" +import { + createSignal, + createMemo, + createEffect, + createUniqueId, + onCleanup, + For, + Show, + createSelector, + useContext, + untrack, +} from "solid-js" import type { Accessor, Component } from "solid-js" import { PopupSelector } from "./PopupSelector" import { Button } from "@kilocode/kilo-ui/button" @@ -98,6 +109,10 @@ export interface ModelSelectorBaseProps { deferDismiss?: boolean /** Render inline instead of through a portal when nested in a dialog. */ portal?: boolean + /** Accessible purpose of this model setting or selector. */ + label?: string + /** Additional accessible context for this model setting. */ + description?: string } export const ModelSelectorBase: Component = (props) => { @@ -106,6 +121,12 @@ export const ModelSelectorBase: Component = (props) => { // Session context is optional — ModelSelectorBase is also used in Settings // where SessionProvider may not be mounted. const session = useContext(SessionContext) + const uid = createUniqueId() + const listID = `${uid}-models` + const previewID = `${uid}-preview` + const descriptionID = `${uid}-description` + const optionID = (key: string) => `${uid}-option-${encodeURIComponent(key)}` + const groupID = (key: string) => `${uid}-group-${encodeURIComponent(key)}` const activeModel = () => { const items = props.models if (items) return items.find((m) => m.providerID === props.value?.providerID && m.id === props.value?.modelID) @@ -312,9 +333,16 @@ export const ModelSelectorBase: Component = (props) => { const activeKey = (m?: EnrichedModel | null) => { if (!m) return props.allowClear ? CLEAR_KEY : defaultKey() const key = modelKey(m.providerID, m.id) - if (!debouncedSearch() && favoriteKeys().has(key)) return favoriteKey(m) + const favorite = favoriteKey(m) + if (!debouncedSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite return canonicalKey(m) } + const chosen = (row: ModelRow) => { + if (row.kind === "clear") return !props.value?.providerID + if (!row.model || !isActive(row.model)) return false + return activeKey(row.model) === row.key + } + const activeOptionID = () => (rowMap().has(selectedKey()) ? optionID(selectedKey()) : undefined) const [anchor, setAnchor] = createSignal(null) const previewModel = createMemo(() => rowMap().get(previewKey() ?? "")?.model ?? null) @@ -329,7 +357,11 @@ export const ModelSelectorBase: Component = (props) => { // which resets selection. createEffect(() => { rows() // track - setSelectedKey((prev) => (rowMap().has(prev) ? prev : defaultKey())) + setSelectedKey((prev) => { + if (rowMap().has(prev)) return prev + const next = untrack(() => activeKey(activeModel())) + return rowMap().has(next) ? next : defaultKey() + }) setPreActiveKey((prev) => (prev && rowMap().has(prev) ? prev : null)) setPreviewKey((prev) => (prev && rowMap().has(prev) ? prev : null)) }) @@ -545,247 +577,277 @@ export const ModelSelectorBase: Component = (props) => { notSet: language.t("dialog.model.notSet"), }, ) + const label = () => props.label ?? language.t("dialog.model.select.title") + const controlLabel = () => `${label()}: ${triggerLabel()}` + const searchLabel = () => `${controlLabel()}. ${language.t("dialog.model.search.placeholder")}` + const describedBy = () => (props.description ? descriptionID : undefined) return ( - - {triggerLabel()} - - - - - } - class={`model-selector-popover${expanded() ? " model-selector-popover--expanded" : ""}`} - > - {(bodyH) => { - createEffect(() => { - if (!expanded()) return - const h = bodyH() - if (h === undefined) return - const chrome = (searchWrapperRef?.offsetHeight ?? 0) + (splitterRef?.offsetHeight ?? 0) - setPreviewHeight((h - chrome) / 2) - }) - return ( -
-
- setSearch(e.currentTarget.value)} - /> - - { - setExpanded((v) => { - if (v) { - setPreActiveKey(null) - setPreviewKey(null) - } - return !v - }) - requestAnimationFrame(() => { - searchRef?.focus() - scrollRow(preActiveKey() ?? selectedKey(), "nearest") - }) - }} + <> + + + {props.description} + + + + {triggerLabel()} + + + + + } + class={`model-selector-popover${expanded() ? " model-selector-popover--expanded" : ""}`} + > + {(bodyH) => { + createEffect(() => { + if (!expanded()) return + const h = bodyH() + if (h === undefined) return + const chrome = (searchWrapperRef?.offsetHeight ?? 0) + (splitterRef?.offsetHeight ?? 0) + setPreviewHeight((h - chrome) / 2) + }) + return ( +
+
+ setSearch(e.currentTarget.value)} /> - -
- -
- -
{language.t("dialog.model.empty")}
-
- - -
pickClear()} - onMouseMove={() => { - setPointer(true) - }} - onMouseEnter={() => { - if (pointer()) setSelectedKey(CLEAR_KEY) - }} + - - {props.clearLabel ?? language.t("dialog.model.notSet")} - -
-
+ { + setExpanded((v) => { + if (v) { + setPreActiveKey(null) + setPreviewKey(null) + } + return !v + }) + requestAnimationFrame(() => { + searchRef?.focus() + scrollRow(preActiveKey() ?? selectedKey(), "nearest") + }) + }} + /> + +
- - {(group) => { - const shown = () => isGroupOpen(group.key) - return ( - <> - - - - {(row) => { - if (!row.model) return null - const model = row.model - const hovered = () => isSelected(row.key) - const preActive = () => isPreActive(row.key) - const showSelectBtn = () => expanded() && preActive() && !isActive(model) - const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) - const showProvider = () => row.kind === "favorite" - return ( -
{ - refs.set(row.key, el) - onCleanup(() => refs.delete(row.key)) - }} - class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) && row.kind === "model" ? " active" : ""}`} - role="option" - aria-selected={isActive(model) && row.kind === "model"} - onClick={() => { - setRow(row.key) - setPreviewKey(row.key) - if (!expanded()) selectRow(row) - searchRef?.focus() - }} - onDblClick={() => { - if (expanded()) selectRow(row) - }} - onMouseMove={() => { - setPointer(true) - }} - onMouseEnter={() => { - if (pointer()) setSelectedKey(row.key) - }} - > -
- - {(() => { - const full = sanitizeName(model.name) - const sep = full.indexOf(": ") - if (sep < 0) return {full} - return ( - <> - {full.slice(0, sep)} - {full.slice(sep + 2)} - - ) - })()} - - - {language.t("model.tag.free")} - - - {model.providerName} + + {group.label} + + + + + + {(row) => { + if (!row.model) return null + const model = row.model + const hovered = () => isSelected(row.key) + const preActive = () => isPreActive(row.key) + const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) + const showProvider = () => row.kind === "favorite" + const starLabel = () => + `${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}` + return ( + - - - - - - -
- ) - }} - - - - ) - }} - -
+ ) + }} +
+
+
+ ) + }} + +
- -
- -
- + +
+ +
+ +
-
- ) - }} - + ) + }} + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index f084e2d025a..d677de27974 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -71,6 +71,17 @@ export const ModelsAutocompleteOpen: Story = { ), } +export const ModelsAccessibleLabels: Story = { + name: "ModelsTab — accessible model labels", + render: () => ( + +
+ +
+
+ ), +} + function OpenModelPicker(props: { children: any }) { let ref: HTMLDivElement | undefined onMount(() => { diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index 18f48f1db9c..7474b0809a4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -3,9 +3,13 @@ * Stories for shared controls: ModelSelector. */ +import { createSignal } from "solid-js" import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { StoryProviders } from "./StoryProviders" +import { StoryProviders, mockSessionValue } from "./StoryProviders" import { ModelSelectorBase } from "../components/shared/ModelSelector" +import { SessionContext } from "../context/session" +import type { EnrichedModel } from "../context/provider" +import type { ModelSelection } from "../types/messages" const meta: Meta = { title: "Shared", @@ -32,3 +36,58 @@ export const ModelSelectorNoProviders: Story = { ), } + +const ACCESSIBLE_MODELS: EnrichedModel[] = [ + { id: "alpha", name: "Alpha", providerID: "kilo", providerName: "Kilo" }, + { id: "bravo", name: "Bravo", providerID: "kilo", providerName: "Kilo" }, + { id: "charlie", name: "Charlie", providerID: "kilo", providerName: "Kilo" }, +] + +const AccessibleModelSelector = () => { + const [value, setValue] = createSignal({ providerID: "kilo", modelID: "alpha" }) + + return ( +
+ { + setValue(providerID && modelID ? { providerID, modelID } : null) + }} + /> + {value()?.modelID ?? "default"} +
+ ) +} + +export const ModelSelectorAccessible: Story = { + name: "ModelSelector — accessible interaction", + render: () => ( + + + + ), +} + +export const ModelSelectorSelectedFavorite: Story = { + name: "ModelSelector — selected favorite", + render: () => { + const session = { + ...mockSessionValue(), + favoriteModels: () => [{ providerID: "kilo", modelID: "alpha" }], + } + + return ( + + + + + + ) + }, +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css index cc8c23f0395..7c1a737694d 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css +++ b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css @@ -207,6 +207,18 @@ flex-shrink: 0; } +.model-selector-assistive { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .model-selector-search-wrapper { display: flex; align-items: center; @@ -305,10 +317,17 @@ margin-left: 2px; } +.model-selector-row { + display: flex; + align-items: center; +} + .model-selector-item { display: flex; align-items: center; gap: 6px; + flex: 1; + min-width: 0; padding: 5px 12px; font-size: var(--kilo-font-size-12); cursor: pointer; @@ -399,12 +418,16 @@ color 0.1s ease; } +.model-selector-row > .model-selector-star { + margin-right: 12px; +} + .model-selector-star [data-component="icon"] { color: inherit; } -.model-selector-item:hover .model-selector-star, -.model-selector-item.selected .model-selector-star, +.model-selector-row:hover .model-selector-star, +.model-selector-row.selected .model-selector-star, .model-selector-star--active { opacity: 1; } @@ -422,31 +445,6 @@ opacity: 1; } -.model-selector-item-select-btn { - flex-shrink: 0; - padding: 2px 10px; - font-size: var(--kilo-font-size-11); - font-weight: 600; - font-family: inherit; - border-radius: 2px; - border: none; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - opacity: 0.95; - cursor: pointer; - line-height: 1.6; - letter-spacing: 0.02em; -} - -.model-selector-item-select-btn:hover { - opacity: 1; -} - -.model-selector-item-select-btn--hidden { - opacity: 0; - pointer-events: none; -} - /* ============================================ Thinking Selector (uses kilo-ui Popover) ============================================ */ From 13cbb35977b7999c9c08670e44dabecb2e48aeaf Mon Sep 17 00:00:00 2001 From: Bazhen Rzheutskii Date: Fri, 29 May 2026 00:30:43 +0400 Subject: [PATCH 10/19] fix(indexing): prevent LanceDB metadata type coercion causing full re-index on restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LanceDB infers the value column type from the first row in _createMetadataData(). Since vector_size (1024) was stored as a number, all subsequent string values (embedding_provider, embedding_model_id) and booleans (indexing_complete) were silently coerced to NaN/0/1, corrupting metadata. On restart, _getStoredEmbeddingProfile() received NaN values, causing needsRecreation=true every time — the database was dropped and recreated, the file-hash cache cleared, and a full re-index triggered on every VS Code reopen. Store all metadata values as strings to ensure consistent column type. --- .../vector-store/lancedb-vector-store.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts index 39bc14760dc..d4433d2e30c 100644 --- a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts +++ b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts @@ -136,7 +136,7 @@ export class LanceDBVectorStore implements IVectorStore { return [ { key: KEY.size, - value: this.vectorSize, + value: String(this.vectorSize), }, { key: KEY.provider, @@ -148,11 +148,11 @@ export class LanceDBVectorStore implements IVectorStore { }, { key: KEY.dimension, - value: this.profile.dimension, + value: String(this.profile.dimension), }, { key: KEY.complete, - value: false, + value: "false", }, ] } @@ -572,7 +572,7 @@ export class LanceDBVectorStore implements IVectorStore { } const metadataTable = await db.openTable(this.metadataTableName) const metadataResults = await metadataTable.query().where(`key = '${KEY.complete}'`).toArray() - const indexed = metadataResults.length > 0 ? metadataResults[0].value : false + const indexed = metadataResults.length > 0 ? String(metadataResults[0].value) === "true" : false log.info("LanceDB indexing metadata evaluated", { workspacePath: this.workspacePath, pointCount, @@ -590,14 +590,16 @@ export class LanceDBVectorStore implements IVectorStore { throw new Error(`Invalid metadata key: ${key}`) } await metadataTable.delete(`key = '${key}'`) - await metadataTable.add([{ key, value }]) + // All values must be strings to prevent LanceDB from inferring the value column + // type as number from the first row, which corrupts subsequent string/boolean values. + await metadataTable.add([{ key, value: String(value) }]) } private async _persistEmbeddingProfile(metadataTable: Table): Promise { await this._upsertMetadata(metadataTable, KEY.provider, this.profile.provider) await this._upsertMetadata(metadataTable, KEY.model, this.profile.modelId) - await this._upsertMetadata(metadataTable, KEY.dimension, this.profile.dimension) - await this._upsertMetadata(metadataTable, KEY.size, this.vectorSize) + await this._upsertMetadata(metadataTable, KEY.dimension, String(this.profile.dimension)) + await this._upsertMetadata(metadataTable, KEY.size, String(this.vectorSize)) } /** @@ -609,7 +611,7 @@ export class LanceDBVectorStore implements IVectorStore { const db = await this.getDb() const metadataTable = await db.openTable(this.metadataTableName) await this._persistEmbeddingProfile(metadataTable) - await this._upsertMetadata(metadataTable, KEY.complete, true) + await this._upsertMetadata(metadataTable, KEY.complete, "true") log.info("Marked indexing as complete") } catch (error) { log.error("Failed to mark indexing as complete", { error }) @@ -626,7 +628,7 @@ export class LanceDBVectorStore implements IVectorStore { const db = await this.getDb() const metadataTable = await db.openTable(this.metadataTableName) await this._persistEmbeddingProfile(metadataTable) - await this._upsertMetadata(metadataTable, KEY.complete, false) + await this._upsertMetadata(metadataTable, KEY.complete, "false") log.info("Marked indexing as incomplete (in progress)") } catch (error) { log.error("Failed to mark indexing as incomplete", { error }) From eeff6d9df8d378c561c4ca212d650be1dfbd912a Mon Sep 17 00:00:00 2001 From: Bazhen Rzheutskii Date: Fri, 29 May 2026 00:41:08 +0400 Subject: [PATCH 11/19] =?UTF-8?q?fix(indexing):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20redundant=20String()=20casts,=20add=20chan?= =?UTF-8?q?geset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fix-lancedb-metadata-type-coercion.md | 5 +++++ .../src/indexing/vector-store/lancedb-vector-store.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-lancedb-metadata-type-coercion.md diff --git a/.changeset/fix-lancedb-metadata-type-coercion.md b/.changeset/fix-lancedb-metadata-type-coercion.md new file mode 100644 index 00000000000..42172ed84a3 --- /dev/null +++ b/.changeset/fix-lancedb-metadata-type-coercion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix LanceDB metadata corruption that caused a full re-index on every VS Code restart diff --git a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts index d4433d2e30c..fc607371f9c 100644 --- a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts +++ b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts @@ -598,8 +598,8 @@ export class LanceDBVectorStore implements IVectorStore { private async _persistEmbeddingProfile(metadataTable: Table): Promise { await this._upsertMetadata(metadataTable, KEY.provider, this.profile.provider) await this._upsertMetadata(metadataTable, KEY.model, this.profile.modelId) - await this._upsertMetadata(metadataTable, KEY.dimension, String(this.profile.dimension)) - await this._upsertMetadata(metadataTable, KEY.size, String(this.vectorSize)) + await this._upsertMetadata(metadataTable, KEY.dimension, this.profile.dimension) + await this._upsertMetadata(metadataTable, KEY.size, this.vectorSize) } /** From e74814064821d9d63077b5af034e7ff3642d4e94 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 09:29:28 +0200 Subject: [PATCH 12/19] fix(vscode): restore focus after canceling history deletion --- .../tests/history-accessibility.spec.ts | 30 ++++++++++- .../src/components/history/SessionList.tsx | 53 +++++++++++-------- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/tests/history-accessibility.spec.ts b/packages/kilo-vscode/tests/history-accessibility.spec.ts index c2b791738ab..554dd3ee00d 100644 --- a/packages/kilo-vscode/tests/history-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/history-accessibility.spec.ts @@ -31,21 +31,47 @@ test.describe("history session accessibility", () => { await expect(page.locator('[data-slot="selected-session"]')).toHaveText("s2") }) - test("focuses and activates separate named rename and delete controls", async ({ page }) => { + test("focuses row actions without opening a session during rename", async ({ page }) => { await story(page, "history-sessionlist--with-items") + const selected = page.locator('[data-slot="selected-session"]') const rename = page.getByRole("button", { name: "Rename: Add screenshot test coverage" }) await rename.focus() await expect(rename).toBeFocused() await page.keyboard.press("Enter") - await expect(page.getByRole("textbox", { name: "Rename" })).toBeFocused() + const input = page.getByRole("textbox", { name: "Rename" }) + await expect(input).toBeFocused() + await expect(selected).toBeEmpty() + await input.fill("Updated screenshot test coverage") + await page.keyboard.press("Enter") + await expect(input).toBeHidden() + await expect(selected).toBeEmpty() + + const renamed = page.getByRole("button", { name: "Rename: Add screenshot test coverage" }) + await renamed.focus() + await page.keyboard.press("Enter") + await expect(input).toBeFocused() + await page.keyboard.press("Escape") + await expect(input).toBeHidden() + await expect(selected).toBeEmpty() + }) + + test("deleting does not open a session and restores focus after cancel", async ({ page }) => { await story(page, "history-sessionlist--with-items") + + const selected = page.locator('[data-slot="selected-session"]') const remove = page.getByRole("button", { name: "Delete session: Add screenshot test coverage" }) await remove.focus() await expect(remove).toBeFocused() await page.keyboard.press("Enter") await expect(page.getByRole("dialog", { name: "Delete session" })).toBeVisible() + await expect(selected).toBeEmpty() + + await page.getByRole("button", { name: "Cancel" }).click() + await expect(page.getByRole("dialog", { name: "Delete session" })).toBeHidden() + await expect(remove).toBeFocused() + await expect(selected).toBeEmpty() }) test("exposes Local and Cloud as keyboard navigable selected tabs", async ({ page }) => { diff --git a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx index b1f2d2eaa80..6d412adb77d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx @@ -95,29 +95,36 @@ const SessionList: Component = (props) => { }) } - function confirmDelete(s: SessionInfo) { - dialog.show(() => ( - -
- {language.t("session.delete.confirm", { name: name(s) })} -
- - + function confirmDelete(s: SessionInfo, restore?: HTMLElement) { + dialog.show( + () => ( + +
+ {language.t("session.delete.confirm", { name: name(s) })} +
+ + +
-
-
- )) +
+ ), + () => { + queueMicrotask(() => { + if (restore?.isConnected) restore.focus() + }) + }, + ) } function wrapItem(item: SessionInfo, node: JSX.Element): JSX.Element { @@ -143,7 +150,7 @@ const SessionList: Component = (props) => { size="small" variant="ghost" aria-label={label(language.t("session.delete.title"), item)} - onClick={() => confirmDelete(item)} + onClick={(event) => confirmDelete(item, event.currentTarget)} /> } From 96d2d3addb121b25d17ff79b33ee3570577aec83 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 09:32:07 +0200 Subject: [PATCH 13/19] fix(vscode): preserve model preview interactions --- .../model-selector-accessibility.spec.ts | 21 ++++++++++- .../src/components/shared/ModelSelector.tsx | 35 ++++++++++++++++--- .../webview-ui/src/styles/model-selector.css | 23 +++++++++++- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index c413a11fcff..dd634e67ff9 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -37,11 +37,28 @@ test("model selector exposes combobox relationships and active option movement", const preview = page.locator(`[id="${controls}"]`) await expect(expand).toHaveAttribute("aria-expanded", "false") await expect(preview).toHaveAttribute("aria-hidden", "true") + await expect(preview.locator("button, a, [tabindex]")).toHaveCount(0) await expand.click() const collapse = page.getByRole("button", { name: "Collapse", exact: true }) await expect(collapse).toHaveAttribute("aria-controls", controls!) await expect(collapse).toHaveAttribute("aria-expanded", "true") await expect(preview).toHaveAttribute("aria-hidden", "false") + await expect(preview.getByRole("button", { name: "Add to favorites" })).toBeVisible() +}) + +test("expanded preview waits for explicit pointer selection", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + await page.getByRole("button", { name: "Expand" }).click() + await page.getByRole("option", { name: "Bravo" }).click() + + await expect(page.getByTestId("model-selector-value")).toHaveText("alpha") + await expect(page.getByRole("combobox", { name: "Review model: Alpha. Search models" })).toBeVisible() + await expect(page.locator(".model-selector-preview")).toContainText("Bravo") + + await page.getByRole("button", { name: "Select: Bravo" }).click() + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") }) test("selected favorite remains selected when its duplicate group is collapsed", async ({ page }) => { @@ -50,9 +67,11 @@ test("selected favorite remains selected when its duplicate group is collapsed", await page.getByRole("button", { name: "Review model: Alpha" }).click() const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) const alpha = page.getByRole("option", { name: "Alpha" }) + const favorites = page.getByRole("button", { name: "Collapse Favorites" }) await expect(alpha.first()).toHaveAttribute("aria-selected", "true") + await expect.poll(() => favorites.evaluate((el) => getComputedStyle(el).borderTopStyle)).toBe("solid") - await page.getByRole("button", { name: "Collapse Favorites" }).click() + await favorites.click() await expect(alpha).toHaveCount(1) await expect(alpha).toHaveAttribute("aria-selected", "true") await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id")) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 5533c16c748..4ae79db99d6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -427,7 +427,10 @@ export const ModelSelectorBase: Component = (props) => { // Listen for slash command trigger const onTrigger = () => setOpen(true) window.addEventListener("openModelPicker", onTrigger) - onCleanup(() => window.removeEventListener("openModelPicker", onTrigger)) + onCleanup(() => { + window.removeEventListener("openModelPicker", onTrigger) + clearTimeout(previewTimer) + }) const onEscape = (e: KeyboardEvent) => { if (!open() || e.key !== "Escape") return @@ -720,7 +723,7 @@ export const ModelSelectorBase: Component = (props) => { {(group) => { const shown = () => isGroupOpen(group.key) return ( -
+
+ + +
) }} @@ -841,7 +866,9 @@ export const ModelSelectorBase: Component = (props) => { class={`model-selector-preview${expanded() ? " model-selector-preview--visible" : ""}`} style={expanded() ? { height: `${previewHeight()}px` } : {}} > - + + +
) diff --git a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css index 7c1a737694d..adecd84ceae 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css +++ b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css @@ -292,7 +292,7 @@ outline-offset: -1px; } -.model-selector-group-label:not(:first-child) { +.model-selector-group:not(:first-child) > .model-selector-group-label { border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border)); margin-top: 4px; padding-top: 8px; @@ -445,6 +445,27 @@ opacity: 1; } +.model-selector-item-select-btn { + flex-shrink: 0; + margin-right: 12px; + padding: 2px 10px; + font-size: var(--kilo-font-size-11); + font-weight: 600; + font-family: inherit; + border-radius: 2px; + border: none; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + opacity: 0.95; + cursor: pointer; + line-height: 1.6; + letter-spacing: 0.02em; +} + +.model-selector-item-select-btn:hover { + opacity: 1; +} + /* ============================================ Thinking Selector (uses kilo-ui Popover) ============================================ */ From d62163ba9e1e7e414d3db27bf61f65d207bd0082 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 29 May 2026 07:34:53 +0000 Subject: [PATCH 14/19] chore: update kilo-vscode visual regression baselines --- .../settings/models-accessible-labels-chromium-linux.png | 3 +++ .../shared/model-selector-accessible-chromium-linux.png | 3 +++ .../shared/model-selector-selected-favorite-chromium-linux.png | 3 +++ 3 files changed, 9 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png new file mode 100644 index 00000000000..01ea6e7ac02 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4 +size 30244 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 From 3268badb6a6e0fa974e84655331ebbe55154c433 Mon Sep 17 00:00:00 2001 From: Aarav Date: Fri, 29 May 2026 04:10:27 -0600 Subject: [PATCH 15/19] docs: clarify per-directory AGENTS.md loading behavior (#10707) Per-directory AGENTS.md files are dynamically loaded when the agent reads files in that directory, not pre-loaded at session start. This is injected as tags during file access, providing context-sensitive instructions for monorepos and large projects. --- packages/kilo-docs/pages/customize/agents-md.md | 14 +++++++++----- .../pages/customize/custom-instructions.md | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/kilo-docs/pages/customize/agents-md.md b/packages/kilo-docs/pages/customize/agents-md.md index 7eccef968da..cbe0e6743fb 100644 --- a/packages/kilo-docs/pages/customize/agents-md.md +++ b/packages/kilo-docs/pages/customize/agents-md.md @@ -57,21 +57,25 @@ my-project/ The filename must be uppercase (`AGENTS.md`), not lowercase (`agents.md`). This ensures consistency across different operating systems and tools. {% /callout %} -### Subdirectory AGENTS.md Files +### Per-Directory AGENTS.md Files -You can also place AGENTS.md files in subdirectories to provide context-specific instructions: +You can place AGENTS.md files in subdirectories to provide context-specific instructions when the agent accesses files in those locations: ``` my-project/ ├── AGENTS.md # Root-level instructions ├── src/ │ └── backend/ -│ └── AGENTS.md # Backend-specific instructions +│ └── AGENTS.md # Backend-specific instructions (loaded when reading backend files) └── docs/ - └── AGENTS.md # Documentation-specific instructions + └── AGENTS.md # Documentation-specific instructions (loaded when reading docs files) ``` -When working in a subdirectory, Kilo Code will load both the root AGENTS.md and any subdirectory AGENTS.md files, with subdirectory files taking precedence for conflicting instructions. +{% callout type="info" %} +Per-directory AGENTS.md files are **dynamically loaded** when the agent reads files in that directory - they are not pre-loaded at session start. When the agent reads a file in `src/backend/`, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. + +This is useful for providing context-specific guidance for different parts of a monorepo or project. +{% /callout %} ## File Protection diff --git a/packages/kilo-docs/pages/customize/custom-instructions.md b/packages/kilo-docs/pages/customize/custom-instructions.md index c46dda3120e..4caf933f583 100644 --- a/packages/kilo-docs/pages/customize/custom-instructions.md +++ b/packages/kilo-docs/pages/customize/custom-instructions.md @@ -50,7 +50,7 @@ Project-level instructions are loaded before global instructions and apply to ev You can place `AGENTS.md` files in any subdirectory of your project. These are loaded dynamically — when the agent's Read tool accesses a file in that directory, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. -This is useful for providing context-specific guidance for different parts of a monorepo or project. +This is useful for providing context-specific guidance for different parts of a monorepo or project. The subdirectory file does not need to duplicate root-level instructions; it supplements them for tasks within that directory. ## Additional Instruction Sources @@ -127,7 +127,7 @@ Project-level instructions are loaded before global instructions and apply to ev You can place `AGENTS.md` files in any subdirectory of your project. These are loaded dynamically — when the agent's Read tool accesses a file in that directory, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. -This is useful for providing context-specific guidance for different parts of a monorepo or project. +This is useful for providing context-specific guidance for different parts of a monorepo or project. The subdirectory file does not need to duplicate root-level instructions; it supplements them for tasks within that directory. ## Additional Instruction Sources From daef31e98a8b8a98eb37901f5cee38feec78fb67 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 12:15:52 +0200 Subject: [PATCH 16/19] refactor(cli): remove SessionPrompt promise facade --- .../src/kilo-sessions/remote-sender.ts | 9 +- packages/opencode/src/session/prompt.ts | 8 - .../test/kilocode/plan-followup.test.ts | 11 +- .../kilocode/session-prompt-queue.test.ts | 384 ++++++++++-------- .../kilocode/sessions/remote-sender.test.ts | 55 ++- script/check-opencode-promise-facades.ts | 1 - 6 files changed, 259 insertions(+), 209 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 38a70975b3f..9666789233b 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -76,6 +76,7 @@ export namespace RemoteSender { readonly list: () => Promise> readonly reply: (input: Permission.ReplyInput) => Promise } + prompt?: (input: SessionPrompt.PromptInput) => Promise } export type Sender = { @@ -97,6 +98,12 @@ export namespace RemoteSender { return AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) }, } + const prompt = + options.prompt ?? + (async (input: SessionPrompt.PromptInput) => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input))) + }) const sub = options.subscribe ?? @@ -294,7 +301,7 @@ export namespace RemoteSender { return } dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => { - await SessionPrompt.prompt(input.data as SessionPrompt.PromptInput) + await prompt(input.data as SessionPrompt.PromptInput) }) return } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 17748fa93f7..97d74a2b4aa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -13,7 +13,6 @@ import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" import { SessionRevert } from "./revert" -import { makeRuntime } from "@/effect/run-service" // kilocode_change import * as Session from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" @@ -2149,11 +2148,4 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) -export const prompt = (input: PromptInput) => runPromise((svc) => svc.prompt(input)) -export const loop = (input: LoopInput) => runPromise((svc) => svc.loop(input)) -export const cancel = (sessionID: SessionID) => runPromise((svc) => svc.cancel(sessionID)) -// kilocode_change end - export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index d65dd8fd9bb..ea6f55eacb0 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test" +import { Effect } from "effect" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { TuiEvent } from "../../src/cli/cmd/tui/event" @@ -1211,10 +1212,16 @@ describe("plan follow-up", () => { expect(followup).toBeDefined() if (!followup) return - expect(states.some((x) => x.sessionID === followup && x.type === "busy")).toBe(true) + const sid = followup + expect(states.some((x) => x.sessionID === sid && x.type === "busy")).toBe(true) const { SessionPrompt } = await import("../../src/session/prompt") - await SessionPrompt.cancel(followup) + await Effect.runPromise( + SessionPrompt.Service.use((svc) => svc.cancel(sid)).pipe( + Effect.provide(SessionPrompt.defaultLayer), + Effect.scoped, + ), + ) deferred.resolve("## Discoveries\n\nexample") await expect(pending).resolves.toBe("break") diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index c07663aff97..9c7b419851a 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -13,7 +13,7 @@ import { SessionCompaction } from "../../src/session/compaction" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" Log.init({ print: false }) @@ -60,10 +60,20 @@ function reply(input: { text: string; ready?: () => void; wait?: Promise>, text: string) { +function hasText(msg: MessageV2.WithParts, text: string) { return msg.parts.some((part) => part.type === "text" && part.text.includes(text)) } +function scoped(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise) { + return Effect.runPromise( + SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe( + Effect.provide(SessionPrompt.defaultLayer), + provideInstance(dir), + Effect.scoped, + ), + ) +} + // Find the last non-system message in an OpenAI-compatible request body. Kept // tolerant: we only care about role invariants, not the exact content shape, // because providers may serialize `content` as a string or as a parts array. @@ -414,77 +424,82 @@ describe("session prompt queue", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const session = await Session.create({ title: "Queued prompt regression" }) - const first = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "first prompt" }], - }) + fn: async () => + scoped(tmp.path, async (prompt) => { + const session = await Session.create({ title: "Queued prompt regression" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }), + ) - await ready.promise + await ready.promise - const second = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "second prompt" }], - }) + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }), + ) - const one = await first - await injected.promise - const two = await second + const one = await first + await injected.promise + const two = await second - expect(calls).toHaveLength(2) + expect(calls).toHaveLength(2) - // The in-flight stream must complete; no aborted error on msg1's reply. - expect(one.info.role).toBe("assistant") - if (one.info.role === "assistant") expect(one.info.error).toBeUndefined() - expect(hasText(one, "first reply")).toBe(true) - expect(hasText(two, "second reply")).toBe(true) + // The in-flight stream must complete; no aborted error on msg1's reply. + expect(one.info.role).toBe("assistant") + if (one.info.role === "assistant") expect(one.info.error).toBeUndefined() + expect(hasText(one, "first reply")).toBe(true) + expect(hasText(two, "second reply")).toBe(true) - const msgs = await Session.messages({ sessionID: session.id }) - const users = msgs.filter((msg) => msg.info.role === "user") - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - const prompts = users.flatMap((msg) => - msg.parts.filter((part) => part.type === "text").map((part) => part.text), - ) - const text = assistants.flatMap((msg) => - msg.parts.filter((part) => part.type === "text").map((part) => part.text), - ) - expect(users).toHaveLength(2) - expect(assistants).toHaveLength(2) - expect(prompts).toContain("first prompt") - expect(prompts).toContain("second prompt") - expect(text).toContain("first reply") - expect(text).toContain("second reply") + const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((msg) => msg.info.role === "user") + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + const prompts = users.flatMap((msg) => + msg.parts.filter((part) => part.type === "text").map((part) => part.text), + ) + const text = assistants.flatMap((msg) => + msg.parts.filter((part) => part.type === "text").map((part) => part.text), + ) + expect(users).toHaveLength(2) + expect(assistants).toHaveLength(2) + expect(prompts).toContain("first prompt") + expect(prompts).toContain("second prompt") + expect(text).toContain("first reply") + expect(text).toContain("second reply") - const firstUser = users.find((msg) => hasText(msg, "first prompt")) - const secondUser = users.find((msg) => hasText(msg, "second prompt")) - const firstReply = assistants.find((msg) => hasText(msg, "first reply")) - const secondReply = assistants.find((msg) => hasText(msg, "second reply")) - if ( - firstUser?.info.role !== "user" || - secondUser?.info.role !== "user" || - firstReply?.info.role !== "assistant" || - secondReply?.info.role !== "assistant" - ) { - throw new Error("missing expected messages") - } - expect(firstReply.info.parentID).toBe(firstUser.info.id) - expect(secondReply.info.parentID).toBe(secondUser.info.id) + const firstUser = users.find((msg) => hasText(msg, "first prompt")) + const secondUser = users.find((msg) => hasText(msg, "second prompt")) + const firstReply = assistants.find((msg) => hasText(msg, "first reply")) + const secondReply = assistants.find((msg) => hasText(msg, "second reply")) + if ( + firstUser?.info.role !== "user" || + secondUser?.info.role !== "user" || + firstReply?.info.role !== "assistant" || + secondReply?.info.role !== "assistant" + ) { + throw new Error("missing expected messages") + } + expect(firstReply.info.parentID).toBe(firstUser.info.id) + expect(secondReply.info.parentID).toBe(secondUser.info.id) - // Regression for #9492: the second LLM request must end with the - // queued user prompt, not an assistant tail from the prior turn. - // Anthropic's API rejects requests whose final message is assistant - // (prefill), and scope() is supposed to partition the queued target - // turn to the end before the model request is built. - expect(bodies).toHaveLength(2) - const second2 = bodies[1] - expect(JSON.stringify(second2)).toContain("second prompt") - const tail = lastConversational(second2) - expect(tail?.role).toBe("user") - expect(JSON.stringify(tail?.content)).toContain("second prompt") - }, + // Regression for #9492: the second LLM request must end with the + // queued user prompt, not an assistant tail from the prior turn. + // Anthropic's API rejects requests whose final message is assistant + // (prefill), and scope() is supposed to partition the queued target + // turn to the end before the model request is built. + expect(bodies).toHaveLength(2) + const second2 = bodies[1] + expect(JSON.stringify(second2)).toContain("second prompt") + const tail = lastConversational(second2) + expect(tail?.role).toBe("user") + expect(JSON.stringify(tail?.content)).toContain("second prompt") + }), }) } finally { server.stop(true) @@ -531,52 +546,59 @@ describe("session prompt queue", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const session = await Session.create({ title: "Queued cancel regression" }) - const first = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "first prompt" }], - }) - await ready.promise + fn: async () => + scoped(tmp.path, async (prompt) => { + const session = await Session.create({ title: "Queued cancel regression" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }), + ) + await ready.promise - const second = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "second prompt" }], - }) - const third = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "third prompt" }], - }) + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }), + ) + const third = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "third prompt" }], + }), + ) - // Let msg2/msg3's enqueue capture the current version before cancel bumps it. - await Bun.sleep(20) - expect(calls).toHaveLength(1) + // Let msg2/msg3's enqueue capture the current version before cancel bumps it. + await Bun.sleep(20) + expect(calls).toHaveLength(1) - await SessionPrompt.cancel(session.id) - await Promise.all([first, second, third]) + await Effect.runPromise(prompt.cancel(session.id)) + await Promise.all([first, second, third]) - // The queued prompts must never reach the LLM once cancel flushes the queue. - expect(calls).toHaveLength(1) - const msgs = await Session.messages({ sessionID: session.id }) - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(1) - expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) + // The queued prompts must never reach the LLM once cancel flushes the queue. + expect(calls).toHaveLength(1) + const msgs = await Session.messages({ sessionID: session.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(1) + expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) - // Internal state should have no lingering tail/version/target entries after the last release. - const ids = await Effect.runPromise( - KiloSessionPromptQueue.enqueue( - session.id, - MessageID.make("message_probe"), - Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)), - Effect.succeed([]), - ), - ) - expect(ids).toEqual([]) - expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false) - }, + // Internal state should have no lingering tail/version/target entries after the last release. + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + session.id, + MessageID.make("message_probe"), + Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + expect(ids).toEqual([]) + expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false) + }), }) } finally { server.stop(true) @@ -590,41 +612,44 @@ describe("session prompt queue", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const session = await Session.create({ title: "Suggestion unblock regression" }) - const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => { - if (event.properties.sessionID === session.id) shown.resolve() - }) - const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => { - if (event.properties.sessionID === session.id) dismissed.resolve() - }) - - try { - const base = Suggestion.show({ - sessionID: session.id, - text: "Run review?", - actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], - }).catch((err) => { - if (err instanceof Suggestion.DismissedError) return "dismissed" - throw err + fn: async () => + scoped(tmp.path, async (prompt) => { + const session = await Session.create({ title: "Suggestion unblock regression" }) + const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => { + if (event.properties.sessionID === session.id) shown.resolve() + }) + const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => { + if (event.properties.sessionID === session.id) dismissed.resolve() }) - await shown.promise - await SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "replacement prompt" }], - noReply: true, - }) - await dismissed.promise + try { + const base = Suggestion.show({ + sessionID: session.id, + text: "Run review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], + }).catch((err) => { + if (err instanceof Suggestion.DismissedError) return "dismissed" + throw err + }) - expect(await base).toBe("dismissed") - expect(await Suggestion.list()).toEqual([]) - } finally { - offShown() - offDismissed() - } - }, + await shown.promise + await Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }), + ) + await dismissed.promise + + expect(await base).toBe("dismissed") + expect(await Suggestion.list()).toEqual([]) + } finally { + offShown() + offDismissed() + } + }), }) }) @@ -635,49 +660,52 @@ describe("session prompt queue", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const session = await Session.create({ title: "Question unblock regression" }) - const offAsked = Bus.subscribe(Question.Event.Asked, (event) => { - if (event.properties.sessionID === session.id) asked.resolve() - }) - const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => { - if (event.properties.sessionID === session.id) rejected.resolve() - }) - - try { - const pending = Question.ask({ - sessionID: session.id, - questions: [ - { - header: "Continue?", - question: "Should I continue?", - options: [ - { label: "Yes", description: "Go ahead" }, - { label: "No", description: "Stop" }, - ], - }, - ], - }).catch((err) => { - if (err instanceof Question.RejectedError) return "rejected" - throw err + fn: async () => + scoped(tmp.path, async (prompt) => { + const session = await Session.create({ title: "Question unblock regression" }) + const offAsked = Bus.subscribe(Question.Event.Asked, (event) => { + if (event.properties.sessionID === session.id) asked.resolve() + }) + const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => { + if (event.properties.sessionID === session.id) rejected.resolve() }) - await asked.promise - await SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "replacement prompt" }], - noReply: true, - }) - await rejected.promise + try { + const pending = Question.ask({ + sessionID: session.id, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go ahead" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) - expect(await pending).toBe("rejected") - expect(await Question.list()).toEqual([]) - } finally { - offAsked() - offRejected() - } - }, + await asked.promise + await Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }), + ) + await rejected.promise + + expect(await pending).toBe("rejected") + expect(await Question.list()).toEqual([]) + } finally { + offAsked() + offRejected() + } + }), }) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 734d974dc5a..bc7210a8f74 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -4,10 +4,12 @@ import { Effect } from "effect" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol" -import { SessionPrompt } from "../../../src/session/prompt" +import type { SessionPrompt } from "../../../src/session/prompt" import { Question } from "../../../src/question" import { Permission } from "../../../src/permission" import { PermissionID } from "../../../src/permission/schema" +import { ModelID, ProviderID } from "../../../src/provider/schema" +import { SessionID } from "../../../src/session/schema" import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change function fakeConn() { @@ -55,6 +57,12 @@ function permissions(items: Permission.Request[] = []) { } } +function prompts(calls: SessionPrompt.PromptInput[]) { + return async (input: SessionPrompt.PromptInput) => { + calls.push(input) + } +} + // kilocode_change start afterEach(() => { mock.restore() @@ -321,13 +329,14 @@ describe("RemoteSender", () => { // kilocode_change start test("send_message normalizes string model without prefix", async () => { const { conn, sent } = fakeConn() - const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never) + const calls: SessionPrompt.PromptInput[] = [] const sender = RemoteSender.create({ conn, directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), + prompt: prompts(calls), }) sender.handle({ @@ -344,22 +353,25 @@ describe("RemoteSender", () => { await new Promise((r) => setTimeout(r, 0)) expect(sent[0]).toEqual({ type: "response", id: "req_model_string", result: {} }) - expect(prompt).toHaveBeenCalledWith({ - sessionID: "ses_x", - parts: [{ type: "text", text: "hello" }], - model: { providerID: "kilo", modelID: "anthropic/claude-sonnet-4-20250514" }, - }) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_x"), + parts: [{ type: "text", text: "hello" }], + model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("anthropic/claude-sonnet-4-20250514") }, + }, + ]) }) test("send_message keeps kilocode-prefixed model unchanged before internal conversion", async () => { const { conn } = fakeConn() - const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never) + const calls: SessionPrompt.PromptInput[] = [] const sender = RemoteSender.create({ conn, directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), + prompt: prompts(calls), }) sender.handle({ @@ -375,11 +387,13 @@ describe("RemoteSender", () => { await new Promise((r) => setTimeout(r, 0)) - expect(prompt).toHaveBeenCalledWith({ - sessionID: "ses_x", - parts: [{ type: "text", text: "hello" }], - model: { providerID: "kilo", modelID: "gpt-5-mini" }, - }) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_x"), + parts: [{ type: "text", text: "hello" }], + model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("gpt-5-mini") }, + }, + ]) }) test("send_message rejects structured model on remote path", () => { @@ -411,13 +425,14 @@ describe("RemoteSender", () => { test("send_message does not special-case kilo-prefixed model", async () => { const { conn, sent } = fakeConn() - const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never) + const calls: SessionPrompt.PromptInput[] = [] const sender = RemoteSender.create({ conn, directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), + prompt: prompts(calls), }) sender.handle({ @@ -434,11 +449,13 @@ describe("RemoteSender", () => { await new Promise((r) => setTimeout(r, 0)) expect(sent[0]).toEqual({ type: "response", id: "req_model_kilo", result: {} }) - expect(prompt).toHaveBeenCalledWith({ - sessionID: "ses_x", - parts: [{ type: "text", text: "hello" }], - model: { providerID: "kilo", modelID: "kilo/gpt-5-mini" }, - }) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_x"), + parts: [{ type: "text", text: "hello" }], + model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("kilo/gpt-5-mini") }, + }, + ]) }) // kilocode_change end diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 2e0b10778ad..49181f613f4 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -25,7 +25,6 @@ const allow: Record = { "installation/index.ts": "existing installation facade outside #10655", "question/index.ts": "transitional facade deferred for upstream reconciliation in #10655", "session/compaction.ts": "existing compaction facade outside #10655", - "session/prompt.ts": "transitional facade tracked by #10655", "session/session.ts": "transitional facade tracked by #10655", "sync/index.ts": "sync event runtime boundary", } From 41729dcb596dfa37c32bac1b9e9143197e862252 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 12:28:44 +0200 Subject: [PATCH 17/19] fix(vscode): localize BYOK gateway footer link --- .changeset/gateway-byok-footer-link.md | 5 ++++ packages/kilo-docs/source-links.md | 2 ++ packages/kilo-i18n/src/ar.ts | 3 +++ packages/kilo-i18n/src/br.ts | 3 +++ packages/kilo-i18n/src/bs.ts | 3 +++ packages/kilo-i18n/src/da.ts | 3 +++ packages/kilo-i18n/src/de.ts | 3 +++ packages/kilo-i18n/src/en.ts | 3 +++ packages/kilo-i18n/src/es.ts | 3 +++ packages/kilo-i18n/src/fr.ts | 3 +++ packages/kilo-i18n/src/ja.ts | 3 +++ packages/kilo-i18n/src/ko.ts | 3 +++ packages/kilo-i18n/src/nl.ts | 3 +++ packages/kilo-i18n/src/no.ts | 3 +++ packages/kilo-i18n/src/pl.ts | 3 +++ packages/kilo-i18n/src/ru.ts | 3 +++ packages/kilo-i18n/src/th.ts | 3 +++ packages/kilo-i18n/src/tr.ts | 3 +++ packages/kilo-i18n/src/uk.ts | 3 +++ packages/kilo-i18n/src/zh.ts | 3 +++ packages/kilo-i18n/src/zht.ts | 3 +++ .../settings/ProviderConnectDialog.tsx | 24 ++++++++++++------- .../webview-ui/src/styles/dialogs.css | 18 +++++++------- 23 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 .changeset/gateway-byok-footer-link.md diff --git a/.changeset/gateway-byok-footer-link.md b/.changeset/gateway-byok-footer-link.md new file mode 100644 index 00000000000..8f63766d93b --- /dev/null +++ b/.changeset/gateway-byok-footer-link.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Link to Kilo Gateway BYOK usage information from provider API key connection dialogs. diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index e4b29c05385..a7f098288a0 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -35,6 +35,8 @@ - +- + - - diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 87f1c525f82..265a1264835 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "قم بزيارة ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " للحصول على مفتاح API الخاص بك.", + "provider.connect.kiloGateway.byok.prefix": "للحصول على المزيد من إحصائيات الاستخدام، استخدم ", + "provider.connect.kiloGateway.byok.link": "BYOK عبر Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "موصى به", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 21864df9338..54198063b33 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visite ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " para obter sua chave de API.", + "provider.connect.kiloGateway.byok.prefix": "Para mais estatísticas de uso, utilize ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Recomendados", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index e517c1352f2..b5de90e2493 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Posjetite ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " da preuzmete svoj API ključ.", + "provider.connect.kiloGateway.byok.prefix": "Za više statistika korištenja, koristite ", + "provider.connect.kiloGateway.byok.link": "BYOK putem Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Preporučeno", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index b8189401bd6..0f52fcf19be 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besøg ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " for at hente din API-nøgle.", + "provider.connect.kiloGateway.byok.prefix": "For flere brugsstatistikker, brug ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Anbefalede", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index a65837305ff..ade7e97a636 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besuchen Sie ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", + "provider.connect.kiloGateway.byok.prefix": "Für weitere Nutzungsstatistiken ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": " nutzen.", // Provider dialog translations "dialog.provider.group.recommended": "Empfohlen", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index e2bc7de26d5..3aae15ff32a 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -9,6 +9,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visit ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " to collect your API key.", + "provider.connect.kiloGateway.byok.prefix": "For more usage stats, ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Recommended", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 8312118c26a..bf7eeb3e48e 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visita ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " para obtener tu clave API.", + "provider.connect.kiloGateway.byok.prefix": "Para más estadísticas de uso, utiliza ", + "provider.connect.kiloGateway.byok.link": "BYOK a través de Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Recomendados", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 510c0c7df91..d7c24995a89 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visitez ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " pour obtenir votre clé API.", + "provider.connect.kiloGateway.byok.prefix": "Pour plus de statistiques d'utilisation, utilisez ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Recommandés", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 634e4729604..a570e5defc2 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -6,6 +6,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " にアクセスしてAPIキーを取得してください。", + "provider.connect.kiloGateway.byok.prefix": "詳細な使用統計については、", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway経由でBYOK", + "provider.connect.kiloGateway.byok.suffix": "をご利用ください。", // Provider dialog translations "dialog.provider.group.recommended": "おすすめ", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 1e3c37ee0f5..843eeba2d33 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -6,6 +6,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": "를 방문하여 API 키를 받으세요.", + "provider.connect.kiloGateway.byok.prefix": "더 많은 사용 통계를 보려면 ", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway를 통해 BYOK", + "provider.connect.kiloGateway.byok.suffix": "를 사용하세요.", // Provider dialog translations "dialog.provider.group.recommended": "추천", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 3e42fb7abff..8c6d82c315d 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -9,6 +9,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Bezoek ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " om je API key op te halen.", + "provider.connect.kiloGateway.byok.prefix": "Voor meer gebruiksstatistieken, gebruik ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Aanbevolen", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c23bf6c3e84..e680849c09e 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besøk ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " for å hente API-nøkkelen din.", + "provider.connect.kiloGateway.byok.prefix": "For mer bruksstatistikk, bruk ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Anbefalt", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 09cbe56e59b..259a273b3d3 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Odwiedź ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", aby otrzymać swój klucz API.", + "provider.connect.kiloGateway.byok.prefix": "Aby uzyskać więcej statystyk użycia, użyj ", + "provider.connect.kiloGateway.byok.link": "BYOK przez Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Polecane", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 89dc4e409b6..faa2fca6228 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Посетите ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", чтобы получить ваш API-ключ.", + "provider.connect.kiloGateway.byok.prefix": "Для получения дополнительной статистики использования используйте ", + "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Рекомендуемые", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 8affeb54c16..29060784760 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "เยี่ยมชม ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " เพื่อรับ API key ของคุณ", + "provider.connect.kiloGateway.byok.prefix": "สำหรับสถิติการใช้งานเพิ่มเติม โปรดใช้ ", + "provider.connect.kiloGateway.byok.link": "BYOK ผ่าน Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": "", // Provider dialog translations "dialog.provider.group.recommended": "แนะนำ", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index b4e335f0501..1ec28fe0eee 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "API anahtarınızı almak için ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " adresini ziyaret edin.", + "provider.connect.kiloGateway.byok.prefix": "Daha fazla kullanım istatistiği için ", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway üzerinden BYOK", + "provider.connect.kiloGateway.byok.suffix": " kullanın.", // Provider dialog translations "dialog.provider.group.recommended": "Önerilen", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 795fd1398bc..afe7ee3cfd5 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -7,6 +7,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Відвідайте ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " щоб отримати свій API-ключ.", + "provider.connect.kiloGateway.byok.prefix": "Для отримання додаткової статистики використання використовуйте ", + "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", // Provider dialog translations "dialog.provider.group.recommended": "Рекомендовані", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index f23fbb9c66a..3096ab5dd5f 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -5,6 +5,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "访问 ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " 获取您的 API 密钥。", + "provider.connect.kiloGateway.byok.prefix": "如需更多使用统计信息,请", + "provider.connect.kiloGateway.byok.link": "通过 Kilo's Gateway 进行 BYOK", + "provider.connect.kiloGateway.byok.suffix": "。", // Provider dialog translations "dialog.provider.group.recommended": "推荐", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 930ddcf3ac5..8e0ee47746b 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -5,6 +5,9 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "訪問 ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " 獲取您的 API 金鑰。", + "provider.connect.kiloGateway.byok.prefix": "如需更多使用統計資訊,請", + "provider.connect.kiloGateway.byok.link": "透過 Kilo's Gateway 進行 BYOK", + "provider.connect.kiloGateway.byok.suffix": "。", // Provider dialog translations "dialog.provider.group.recommended": "推薦", diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx index a6a8f162f20..f5e4f71ea3b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx @@ -380,15 +380,21 @@ const ProviderConnectDialog: Component = (props) => {state.error}
-
- - For more usage stats, BYOK via Kilo's Gateway. - +
+ diff --git a/packages/kilo-vscode/webview-ui/src/styles/dialogs.css b/packages/kilo-vscode/webview-ui/src/styles/dialogs.css index ea589714222..5db5a5c719f 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/dialogs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/dialogs.css @@ -9,23 +9,23 @@ .dialog-confirm-actions { display: flex; justify-content: flex-end; - align-items: center; gap: 8px; } -.provider-connect-byok-link { - margin-right: auto; +.provider-connect-actions { + align-items: center; + flex-wrap: wrap; +} + +.provider-connect-byok { + flex: 1 1 200px; font-size: var(--kilo-font-size-12); color: var(--text-weak-base); - text-decoration: none; } -.provider-connect-byok-link:hover { - color: var(--text-base); -} - -.provider-connect-byok-link span { +.provider-connect-byok-link { color: var(--vscode-textLink-foreground); + cursor: pointer; text-decoration: underline; } From e9bbd4deaf8d38247f93ad8434f85a289516c7e9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 13:07:00 +0200 Subject: [PATCH 18/19] fix(vscode): translate history accessibility labels in Italian --- packages/kilo-vscode/webview-ui/src/i18n/it.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 3ce53bc55d3..d0fb5e1f8e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -841,8 +841,10 @@ export const dict = { "session.delete.confirm": 'Eliminare la sessione "{{name}}"?', "session.delete.button": "Elimina sessione", "session.untitled": "Senza titolo", + "session.current": "Sessione corrente", "session.recent": "Recenti", "session.showHistory": "Mostra cronologia", + "session.history.sources": "Origine cronologia", "session.search.placeholder": "Cerca sessioni...", "session.empty": "Ancora nessuna sessione. Fai clic su + per avviare una nuova conversazione.", "session.tab.local": "Locale", From 6731c1dd500308cbd8b77ca644e28818c3f3897e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 29 May 2026 11:10:08 +0000 Subject: [PATCH 19/19] chore: update kilo-vscode visual regression baselines --- .../history-sessionlist/sources-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png new file mode 100644 index 00000000000..d64935ee798 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4caad41e47c02636156c91066ef88dc4cb0df298ef1a0f2048cad12aafd985ee +size 21865