From 9c279a16b4a14fc117f34d7aa19e771149031931 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:09:51 +0000 Subject: [PATCH 1/6] fix: respect model badge metadata Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/honest-model-badges.md | 7 ++++++ packages/core/src/models.ts | 1 + packages/kilo-gateway/src/api/models.ts | 2 ++ packages/kilo-gateway/test/api/models.test.ts | 7 +++++- .../tests/unit/model-selector-utils.test.ts | 17 ++++++++++--- .../components/shared/model-selector-utils.ts | 4 +-- .../src/types/messages/providers.ts | 1 + .../components/free-model-disclosure.ts | 6 ++--- .../src/kilocode/provider/provider.ts | 6 +++-- .../kilocode/free-model-disclosure.test.ts | 25 ++++--------------- .../test/kilocode/kilo-loader-auth.test.ts | 8 ++++-- 11 files changed, 50 insertions(+), 34 deletions(-) create mode 100644 .changeset/honest-model-badges.md diff --git a/.changeset/honest-model-badges.md b/.changeset/honest-model-badges.md new file mode 100644 index 00000000000..f808c5181aa --- /dev/null +++ b/.changeset/honest-model-badges.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Show model free and prompt-training indicators only when their explicit catalog metadata is enabled. diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 89ec7ead863..1c24176844d 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -74,6 +74,7 @@ export const Model = Schema.Struct({ recommendedIndex: Schema.optional(Schema.Finite), prompt: Schema.optional(Schema.String), isFree: Schema.optional(Schema.Boolean), + mayTrainOnYourPrompts: Schema.optional(Schema.Boolean), ai_sdk_provider: Schema.optional(Schema.String), // kilocode_change end experimental: Schema.optional( diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 3a099946e99..d59fb219fc7 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -36,6 +36,7 @@ const openRouterModelSchema = z.object({ supported_parameters: z.array(z.string()).optional(), preferredIndex: z.number().optional(), isFree: z.boolean().optional(), + mayTrainOnYourPrompts: z.boolean().optional(), terminalBench: z .object({ overallScore: z.number(), @@ -190,6 +191,7 @@ function transformToModelDevFormat(model: OpenRouterModel): any { ai_sdk_provider: model.opencode?.ai_sdk_provider, tool_call: supportsTools, isFree: model.isFree, + mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, ...(model.terminalBench && { terminalBench: model.terminalBench }), ...(inputPrice !== undefined && outputPrice !== undefined && { diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index e9739b513fe..10ce9ecedb8 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -15,6 +15,8 @@ const VALID_RESPONSE = JSON.stringify({ output_modalities: ["text"], }, supported_parameters: ["tools", "temperature"], + isFree: false, + mayTrainOnYourPrompts: true, }, ], }) @@ -143,7 +145,10 @@ test("returns models without error on success", async () => { ;(globalThis as any).fetch = orig expect(result.error).toBeUndefined() - expect(Object.keys(result.models).length).toBeGreaterThan(0) + expect(result.models["test/model-a"]).toMatchObject({ + isFree: false, + mayTrainOnYourPrompts: true, + }) }) test("preserves Terminal Bench metadata as a dedicated model field", async () => { diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index d7fef3854d0..4483a554888 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -8,6 +8,7 @@ import { PROVIDER_ORDER, freeDataLabel, isDataCollectedModel, + isFree, } from "../../webview-ui/src/components/shared/model-selector-utils" const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } @@ -102,11 +103,19 @@ describe("freeDataLabel", () => { }) }) +describe("isFree", () => { + it("uses only explicit free metadata", () => { + expect(isFree({ isFree: true })).toBe(true) + expect(isFree({ isFree: false })).toBe(false) + expect(isFree({})).toBe(false) + }) +}) + describe("isDataCollectedModel", () => { - it("only marks free Kilo Gateway models with the training disclosure", () => { - expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: true })).toBe(true) - expect(isDataCollectedModel({ providerID: "openrouter", isFree: true })).toBe(false) - expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: false })).toBe(false) + it("uses only explicit prompt training metadata", () => { + expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true) + expect(isDataCollectedModel({ mayTrainOnYourPrompts: false })).toBe(false) + expect(isDataCollectedModel({})).toBe(false) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index ba8024d4fc4..f5a183f1845 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -22,8 +22,8 @@ export function isFree(model: Pick): boolean { return model.isFree === true } -export function isDataCollectedModel(model: Pick): boolean { - return model.isFree === true && model.providerID === KILO_GATEWAY_ID +export function isDataCollectedModel(model: Pick): boolean { + return model.mayTrainOnYourPrompts === true } export function freeDataLabel(_free: string, data: string): string { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts index daf3525d359..840691d6e1d 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts @@ -18,6 +18,7 @@ export interface ProviderModel { options?: { description?: string } recommendedIndex?: number isFree?: boolean + mayTrainOnYourPrompts?: boolean terminalBench?: { overallScore: number avgAttemptCostUsd: number diff --git a/packages/opencode/src/kilocode/components/free-model-disclosure.ts b/packages/opencode/src/kilocode/components/free-model-disclosure.ts index 4be603e4cfe..f28ce099345 100644 --- a/packages/opencode/src/kilocode/components/free-model-disclosure.ts +++ b/packages/opencode/src/kilocode/components/free-model-disclosure.ts @@ -1,7 +1,7 @@ export const FreeModelDisclosure = { label: "May train", - panel: "Free - data may be used for training", - collectsData(model: { isFree?: boolean; api?: { npm?: string } }): boolean { - return model.isFree === true && model.api?.npm === "@kilocode/kilo-gateway" + panel: "Data may be used for training", + collectsData(model: { mayTrainOnYourPrompts?: boolean }): boolean { + return model.mayTrainOnYourPrompts === true }, } as const diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 155a5f96cea..cb35d737ac6 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -35,6 +35,7 @@ export const KILO_MODEL_SCHEMA_EXTENSIONS = { recommendedIndex: optionalOmitUndefined(Schema.Finite), prompt: Schema.optional(Schema.Literals(PROMPTS)), isFree: Schema.optional(Schema.Boolean), + mayTrainOnYourPrompts: Schema.optional(Schema.Boolean), terminalBench: optionalOmitUndefined( Schema.Struct({ overallScore: Schema.Finite, @@ -49,12 +50,12 @@ export const KILO_MODEL_SCHEMA_EXTENSIONS = { // --------------------------------------------------------------------------- export function patchModelsDevModel(providerID: string, source: any) { - const free = providerID === "kilo" && source.cost?.input === 0 && source.cost?.output === 0 return { variants: providerID === "kilo" ? (source.variants ?? {}) : {}, recommendedIndex: source.recommendedIndex, prompt: source.prompt, - isFree: source.isFree ?? (free ? true : undefined), + isFree: source.isFree, + mayTrainOnYourPrompts: source.mayTrainOnYourPrompts, terminalBench: source.terminalBench, ai_sdk_provider: source.ai_sdk_provider, options: source.options ?? {}, @@ -70,6 +71,7 @@ export function patchConfigModel(cfg: any, existing: any) { recommendedIndex: cfg.recommendedIndex ?? existing?.recommendedIndex, prompt: cfg.prompt ?? existing?.prompt, isFree: cfg.isFree ?? existing?.isFree, + mayTrainOnYourPrompts: cfg.mayTrainOnYourPrompts ?? existing?.mayTrainOnYourPrompts, terminalBench: existing?.terminalBench, ai_sdk_provider: cfg.ai_sdk_provider ?? existing?.ai_sdk_provider, variants: cfg.variants diff --git a/packages/opencode/test/kilocode/free-model-disclosure.test.ts b/packages/opencode/test/kilocode/free-model-disclosure.test.ts index fa715b6c542..f1825c41483 100644 --- a/packages/opencode/test/kilocode/free-model-disclosure.test.ts +++ b/packages/opencode/test/kilocode/free-model-disclosure.test.ts @@ -4,27 +4,12 @@ import { FreeModelDisclosure } from "../../src/kilocode/components/free-model-di describe("FreeModelDisclosure", () => { test("uses compact CLI labels", () => { expect(FreeModelDisclosure.label).toBe("May train") - expect(FreeModelDisclosure.panel).toBe("Free - data may be used for training") + expect(FreeModelDisclosure.panel).toBe("Data may be used for training") }) - test("only Kilo Gateway free models get the training disclosure", () => { - expect( - FreeModelDisclosure.collectsData({ - isFree: true, - api: { npm: "@kilocode/kilo-gateway" }, - }), - ).toBe(true) - expect( - FreeModelDisclosure.collectsData({ - isFree: true, - api: { npm: "@openrouter/ai-sdk-provider" }, - }), - ).toBe(false) - expect( - FreeModelDisclosure.collectsData({ - isFree: false, - api: { npm: "@kilocode/kilo-gateway" }, - }), - ).toBe(false) + test("uses only explicit prompt training metadata", () => { + expect(FreeModelDisclosure.collectsData({ mayTrainOnYourPrompts: true })).toBe(true) + expect(FreeModelDisclosure.collectsData({ mayTrainOnYourPrompts: false })).toBe(false) + expect(FreeModelDisclosure.collectsData({})).toBe(false) }) }) diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index a53dbd1431f..26567407aad 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -86,6 +86,8 @@ function layer() { id: "paid-model", name: "Paid Model", cost: { input: 1, output: 2 }, + isFree: false, + mayTrainOnYourPrompts: true, limit: { context: 128000, output: 4096 }, }, }, @@ -129,11 +131,13 @@ it.live("assembles paid Kilo models without auth", () => id: "paid-model", providerID: "kilo", cost: { input: 1, output: 2 }, + isFree: false, + mayTrainOnYourPrompts: true, }) }), ) -it.live("marks zero-cost Kilo models as free when the catalog omits isFree", () => +it.live("does not infer free status from zero catalog prices", () => Effect.gen(function* () { const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe( Effect.provide(layer()), @@ -141,7 +145,7 @@ it.live("marks zero-cost Kilo models as free when the catalog omits isFree", () ) const kilo = Provider.fromModelsDevProvider(providers.kilo) - expect(kilo.models["free-model"].isFree).toBe(true) + expect(kilo.models["free-model"].isFree).toBeUndefined() }), ) From c5ffbd1aedbeca1b500bbf13527219b2845598cc Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:13:41 +0000 Subject: [PATCH 2/6] chore(sdk): regenerate model metadata types Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/sdk/js/src/v2/gen/types.gen.ts | 223 ++++----- packages/sdk/openapi.json | 621 ++++++++++++------------ 2 files changed, 424 insertions(+), 420 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5d2a7d48ff4..7b2b6fa7987 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,16 +5,10 @@ export type ClientOptions = { } export type Event = + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -22,6 +16,10 @@ export type Event = | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -46,6 +44,7 @@ export type Event = | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated + | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -59,6 +58,7 @@ export type Event = | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventIndexingStatus export type OAuth = { type: "oauth" @@ -85,71 +85,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -212,6 +147,61 @@ export type QuestionRejected = { requestID: string } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type SessionNetworkWait = { id: string sessionID: string @@ -426,6 +416,16 @@ export type Pty = { sessionID?: string | null } +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + export type OutputFormatText = { type: "text" } @@ -859,16 +859,10 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -876,6 +870,10 @@ export type GlobalEvent = { | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -900,6 +898,7 @@ export type GlobalEvent = { | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated + | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -913,6 +912,7 @@ export type GlobalEvent = { | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventIndexingStatus | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1511,6 +1511,7 @@ export type Model = { recommendedIndex?: number prompt?: "codex" | "gemini" | "beast" | "anthropic" | "trinity" | "anthropic_without_todo" | "ling" | "gpt55" isFree?: boolean + mayTrainOnYourPrompts?: boolean terminalBench?: { overallScore: number avgAttemptCostUsd: number @@ -2722,6 +2723,14 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventServerConnected = { id: string type: "server.connected" @@ -2746,38 +2755,6 @@ export type EventGlobalConfigUpdated = { } } -export type EventKilocodeAgentManagerStart = { - id: string - type: "kilocode.agent_manager.start" - properties: { - requestID: string - sessionID: string - mode: "worktree" | "local" - versions?: boolean - tasks: Array<{ - prompt?: string - name?: string - branchName?: string - }> - } -} - -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - -export type EventServerInstanceDisposed = { - id: string - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventFileEdited = { id: string type: "file.edited" @@ -3055,6 +3032,22 @@ export type EventProjectUpdated = { properties: Project } +export type EventKilocodeAgentManagerStart = { + id: string + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + prompt?: string + name?: string + branchName?: string + }> + } +} + export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -3163,6 +3156,14 @@ export type EventInstallationUpdateAvailable = { } } +export type EventIndexingStatus = { + id: string + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + export type PromptSource = { start: number end: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 186b8a7444d..09c112b0a4f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14344,6 +14344,9 @@ "schemas": { "Event": { "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -14353,27 +14356,6 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -14395,6 +14377,18 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -14467,6 +14461,9 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -14505,6 +14502,9 @@ }, { "$ref": "#/components/schemas/EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/EventIndexingStatus" } ] }, @@ -14585,170 +14585,6 @@ } ] }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "IndexingStatusState": { - "type": "string", - "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] - }, - "IndexingStatus": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/IndexingStatusState" - }, - "message": { - "type": "string" - }, - "processedFiles": { - "type": "integer", - "minimum": 0 - }, - "totalFiles": { - "type": "integer", - "minimum": 0 - }, - "percent": { - "type": "integer", - "minimum": 0, - "maximum": 100 - } - }, - "required": ["state", "message", "processedFiles", "totalFiles", "percent"], - "additionalProperties": false - }, "QuestionOption": { "type": "object", "properties": { @@ -14891,6 +14727,140 @@ "required": ["sessionID", "requestID"], "additionalProperties": false }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionNetworkWait": { "type": "object", "properties": { @@ -15520,6 +15490,36 @@ "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, + "IndexingStatusState": { + "type": "string", + "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] + }, + "IndexingStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/IndexingStatusState" + }, + "message": { + "type": "string" + }, + "processedFiles": { + "type": "integer", + "minimum": 0 + }, + "totalFiles": { + "type": "integer", + "minimum": 0 + }, + "percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["state", "message", "processedFiles", "totalFiles", "percent"], + "additionalProperties": false + }, "OutputFormatText": { "type": "object", "properties": { @@ -16871,6 +16871,9 @@ }, "payload": { "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -16880,27 +16883,6 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -16922,6 +16904,18 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -16994,6 +16988,9 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -17033,6 +17030,9 @@ { "$ref": "#/components/schemas/EventInstallationUpdate-available" }, + { + "$ref": "#/components/schemas/EventIndexingStatus" + }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -18684,6 +18684,9 @@ "isFree": { "type": "boolean" }, + "mayTrainOnYourPrompts": { + "type": "boolean" + }, "terminalBench": { "type": "object", "properties": { @@ -22633,6 +22636,30 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerConnected": { "type": "object", "properties": { @@ -22687,109 +22714,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventKilocodeAgent_managerStart": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilocode.agent_manager.start"] - }, - "properties": { - "type": "object", - "properties": { - "requestID": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "mode": { - "type": "string", - "enum": ["worktree", "local"] - }, - "versions": { - "type": "boolean" - }, - "tasks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "prompt": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branchName": { - "type": "string" - } - }, - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 20 - } - }, - "required": ["requestID", "sessionID", "mode", "tasks"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventIndexingStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["indexing.status"] - }, - "properties": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/IndexingStatus" - } - }, - "required": ["status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventServerInstanceDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.instance.disposed"] - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventFileEdited": { "type": "object", "properties": { @@ -23629,6 +23553,61 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventKilocodeAgent_managerStart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.agent_manager.start"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "mode": { + "type": "string", + "enum": ["worktree", "local"] + }, + "versions": { + "type": "boolean" + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branchName": { + "type": "string" + } + }, + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": ["requestID", "sessionID", "mode", "tasks"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -23957,6 +23936,30 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventIndexingStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["indexing.status"] + }, + "properties": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IndexingStatus" + } + }, + "required": ["status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "PromptSource": { "type": "object", "properties": { From c4d5f2096401d180bb20c0652f909f720c2a2f4e Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:42:34 +0000 Subject: [PATCH 3/6] fix(vscode): show training badge for paid models Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../tests/unit/model-preview-data-line.test.ts | 9 +++++++++ .../webview-ui/src/components/shared/ModelPreview.tsx | 6 ++++-- .../webview-ui/src/components/shared/ModelSelector.tsx | 10 ++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts index 63d9e27175d..788a70b61e5 100644 --- a/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts +++ b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts @@ -22,6 +22,15 @@ describe("model preview data collection line", () => { expect(styles).toContain(".model-preview-data-line") }) + it("renders prompt training independently from the free badge", () => { + expect(selector).toMatch( + /[\s\S]*?<\/Show>\s*/, + ) + expect(preview).toMatch( + /[\s\S]*?<\/Show>\s*/, + ) + }) + it("uses the book open check icon for all webview model data disclosures", () => { expect(selector).toContain('Icon name="book-open-check"') expect(selector).not.toContain('Icon name="warning"') diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index f4d49660bc7..ae3589f86d8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -91,9 +91,11 @@ export const ModelPreview: Component = (props) => { })()} - + - {freeLabel()} + + {freeLabel()} + 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 e59975e7e22..00eb157021b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -828,11 +828,13 @@ export const ModelSelectorBase: Component = (props) => { ) })()} - + - - {freeLabel()} - + + + {freeLabel()} + + From 4acc1f8bcccc48f01fc046d799f5546bff247407 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 10 Jun 2026 21:47:13 +0200 Subject: [PATCH 4/6] adopt row based virtualization --- .changeset/fast-agent-transcripts.md | 5 + .../tests/unit/task-timeline-tooltip.test.ts | 18 +- .../tests/unit/timeline-geometry.test.ts | 46 ++++ .../tests/unit/transcript-cache.test.ts | 86 +++++++ .../tests/unit/transcript-rows.test.ts | 227 ++++++++++++++++++ .../src/components/chat/AssistantMessage.tsx | 3 +- .../src/components/chat/MessageList.tsx | 160 ++++++++---- .../src/components/chat/TaskTimeline.tsx | 154 +++++++----- .../src/components/chat/TranscriptRow.tsx | 131 ++++++++++ .../src/components/chat/transcript-cache.ts | 85 +++++++ .../webview-ui/src/context/transcript-rows.ts | 196 +++++++++++++++ .../webview-ui/src/styles/task-header.css | 50 ++-- .../webview-ui/src/utils/timeline/geometry.ts | 73 ++++++ 13 files changed, 1085 insertions(+), 149 deletions(-) create mode 100644 .changeset/fast-agent-transcripts.md create mode 100644 packages/kilo-vscode/tests/unit/timeline-geometry.test.ts create mode 100644 packages/kilo-vscode/tests/unit/transcript-cache.test.ts create mode 100644 packages/kilo-vscode/tests/unit/transcript-rows.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/transcript-cache.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/timeline/geometry.ts diff --git a/.changeset/fast-agent-transcripts.md b/.changeset/fast-agent-transcripts.md new file mode 100644 index 00000000000..be0d8f1928b --- /dev/null +++ b/.changeset/fast-agent-transcripts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep large Agent Manager transcripts responsive by mounting only viewport-visible conversation rows. diff --git a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts index 4d2115af1d6..d7185852886 100644 --- a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts +++ b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts @@ -19,12 +19,20 @@ describe("TaskTimeline delegated tooltip contract", () => { expect(src).not.toMatch(/ { - expect(src).toMatch(/data-tip=\{bar\(\)\.tip\}/) - expect(src).toMatch(/role="img"/) - expect(src).toMatch(/aria-label=\{bar\(\)\.tip\}/) - expect(src).toMatch(/if \(!bar \|\| !ref\?\.contains\(bar\)\) return hideTip\(\)/) + it("delegates SVG hit testing to one portal tooltip", () => { + expect(src).toMatch(/hit\(layout\(\)\.items, e\.clientX - rect\.left \+ ref\.scrollLeft\)/) + expect(src).toMatch(/const bar = bars\(\)\[idx\]/) + expect(src).toMatch(/text: bar\.tip/) expect(src).toMatch(//) expect(src).toMatch(/class="task-timeline-tooltip"/) }) + + it("keeps accessibility and bar overlays bounded", () => { + expect(src).toMatch(/data-timeline-count=\{bars\(\)\.length\}/) + expect(src).toMatch(/tabIndex=\{0\}/) + expect(src).toMatch(/aria-label=\{aria\(\)\}/) + expect(src).toMatch(//) + expect(src).not.toMatch(/ { + const bars = [ + { bg: "blue", width: 3, height: 4 }, + { bg: "red", width: 5, height: 8 }, + { bg: "blue", width: 2, height: 6 }, + ] + + it("preserves visual positions while grouping paths by color", () => { + const result = geometry(bars, 10) + + expect(result.width).toBe(13) + expect(result.items.map((item) => [item.idx, item.x])).toEqual([ + [0, 0], + [1, 4], + [2, 10], + ]) + expect(result.paths).toHaveLength(2) + expect(result.paths.map((path) => path.bg)).toEqual(["blue", "red"]) + expect(result.paths[0]!.d).toContain("M0,10") + expect(result.paths[0]!.d).toContain("M10,10") + expect(result.paths[1]!.d).toContain("M4,10") + }) + + it("hit tests bars but not their gaps", () => { + const items = geometry(bars, 10).items + + expect(hit(items, 0)).toBe(0) + expect(hit(items, 2.99)).toBe(0) + expect(hit(items, 3)).toBe(-1) + expect(hit(items, 4)).toBe(1) + expect(hit(items, 12)).toBe(-1) + }) + + it("navigates in visual order with bounded endpoints", () => { + expect(navigate(-1, 3, "ArrowRight")).toBe(0) + expect(navigate(1, 3, "ArrowLeft")).toBe(0) + expect(navigate(2, 3, "ArrowRight")).toBe(2) + expect(navigate(1, 3, "Home")).toBe(0) + expect(navigate(1, 3, "End")).toBe(2) + expect(navigate(1, 3, "Escape")).toBe(1) + expect(navigate(0, 0, "ArrowRight")).toBe(-1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-cache.test.ts b/packages/kilo-vscode/tests/unit/transcript-cache.test.ts new file mode 100644 index 00000000000..1a60a893ce9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-cache.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "bun:test" +import type { CacheSnapshot } from "virtua" +import { + getMeasurement, + getScroll, + layoutFingerprint, + resetTranscriptCaches, + resolveAnchor, + rowFingerprint, + setMeasurement, + setScroll, +} from "../../webview-ui/src/components/chat/transcript-cache" + +const snapshot = (id: number) => ({ id }) as unknown as CacheSnapshot +const layout = layoutFingerprint({ width: 800, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" }) + +describe("transcript measurement cache", () => { + beforeEach(resetTranscriptCaches) + + it("returns a cache only for the exact row and layout fingerprints", () => { + const keys = rowFingerprint(["a", "bc"]) + const cache = snapshot(1) + setMeasurement("session", keys, layout, cache) + + expect(getMeasurement("session", keys, layout)).toBe(cache) + expect(getMeasurement("session", rowFingerprint(["a", "bd"]), layout)).toBeUndefined() + expect(getMeasurement("session", keys, layout)).toBeUndefined() + }) + + it("invalidates a measurement when layout changes", () => { + const keys = rowFingerprint(["row"]) + setMeasurement("session", keys, layout, snapshot(1)) + const changed = layoutFingerprint({ width: 801, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" }) + + expect(getMeasurement("session", keys, changed)).toBeUndefined() + expect(getMeasurement("session", keys, layout)).toBeUndefined() + }) + + it("uses collision-safe row and layout fingerprints", () => { + expect(rowFingerprint(["a|b", "c"])).not.toBe(rowFingerprint(["a", "b|c"])) + expect(layoutFingerprint({ width: 80, ratio: 1, font: "a|b", size: "c", line: "d" })).not.toBe( + layoutFingerprint({ width: 80, ratio: 1, font: "a", size: "b|c", line: "d" }), + ) + }) + + it("evicts the least recently used measurement after 16 sessions", () => { + const keys = rowFingerprint(["row"]) + for (let i = 0; i < 16; i += 1) setMeasurement(`s${i}`, keys, layout, snapshot(i)) + expect(getMeasurement("s0", keys, layout)).toBeDefined() + setMeasurement("s16", keys, layout, snapshot(16)) + + expect(getMeasurement("s1", keys, layout)).toBeUndefined() + expect(getMeasurement("s0", keys, layout)).toBeDefined() + }) +}) + +describe("transcript scroll cache", () => { + beforeEach(resetTranscriptCaches) + + it("stores bottom-follow and anchored positions independently from measurements", () => { + setMeasurement("a", rowFingerprint(["row"]), layout, snapshot(1)) + setScroll("a", { type: "bottom" }) + setScroll("b", { type: "anchor", key: "row-2", offset: 37 }) + + expect(getScroll("a")).toEqual({ type: "bottom" }) + expect(getScroll("b")).toEqual({ type: "anchor", key: "row-2", offset: 37 }) + }) + + it("resolves an anchor after prepended rows shift its index", () => { + const state = { type: "anchor", key: "stable", offset: 19 } as const + expect(resolveAnchor(state, ["stable", "tail"])).toEqual({ index: 0, offset: 19 }) + expect(resolveAnchor(state, ["older-1", "older-2", "stable", "tail"])).toEqual({ index: 2, offset: 19 }) + expect(resolveAnchor(state, ["other"])).toBeUndefined() + expect(resolveAnchor({ type: "bottom" }, ["stable"])).toBeUndefined() + }) + + it("evicts the least recently used scroll state after 50 sessions", () => { + for (let i = 0; i < 50; i += 1) setScroll(`s${i}`, { type: "bottom" }) + expect(getScroll("s0")).toEqual({ type: "bottom" }) + setScroll("s50", { type: "anchor", key: "row", offset: 4 }) + + expect(getScroll("s1")).toBeUndefined() + expect(getScroll("s0")).toEqual({ type: "bottom" }) + expect(getScroll("s50")).toEqual({ type: "anchor", key: "row", offset: 4 }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts new file mode 100644 index 00000000000..f983df98fe9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "bun:test" +import { messageTurns } from "../../webview-ui/src/context/session-queue" +import { partitionRows, transcriptRows } from "../../webview-ui/src/context/transcript-rows" +import type { Message, Part } from "../../webview-ui/src/types/messages" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const part = (id: string, messageID: string): Part => ({ id, messageID, type: "text", text: id }) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("transcriptRows", () => { + it("preserves turn order across user, bounded assistant, diff, and error rows", () => { + const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } }) + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1", { error: { name: "ProviderError" } }) + const u2 = user("u2") + const a3 = assistant("a3", "u2") + const parts = { + u1: [part("up1", "u1")], + a1: Array.from({ length: 10 }, (_, i) => part(`p${i}`, "a1")), + a2: [part("p10", "a2")], + a3: [part("p11", "a3")], + } + + const rows = transcriptRows(messageTurns([u1, a1, a2, u2, a3]), lookup(parts)) + + expect(rows.map((row) => `${row.turn}:${row.type}`)).toEqual([ + "u1:user", + "u1:assistant", + "u1:assistant", + "u1:assistant", + "u1:diff", + "u1:error", + "u2:user", + "u2:assistant", + ]) + expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([8, 2, 1, 1]) + }) + + it("uses the configured bound and keeps an empty assistant renderable", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1") + const rows = transcriptRows( + messageTurns([u1, a1, a2]), + lookup({ a1: Array.from({ length: 7 }, (_, i) => part(`p${i}`, "a1")) }), + { size: 3 }, + ) + + expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([3, 3, 1, 0]) + }) + + it("omits synthetic users for partial turns and carries row metadata", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [part("p1", "a1")] }), { + queued: new Set(["u1"]), + live: new Set(["u1"]), + }) + + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ type: "assistant", turn: "u1", partial: true, queued: true, live: true }) + }) + + it("places only the first visible non-abort error after diffs", () => { + const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } }) + const a1 = assistant("a1", "u1", { error: { name: "MessageAbortedError" } }) + const a2 = assistant("a2", "u1", { error: { name: "HiddenError" } }) + const a3 = assistant("a3", "u1", { error: { name: "ShownError" } }) + const rows = transcriptRows(messageTurns([u1, a1, a2, a3]), lookup({}), { hidden: (id) => id === "a2" }) + + expect(rows.slice(-2).map((row) => row.type)).toEqual(["diff", "error"]) + expect(rows.at(-1)).toMatchObject({ type: "error", message: a3, error: a3.error }) + }) + + it("keeps keys stable when older turns are prepended and parts are appended", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = Array.from({ length: 8 }, (_, i) => part(`p${i}`, "a1")) + const current = transcriptRows(messageTurns([u1, a1]), lookup({ a1: parts })) + const older = user("u0") + const next = transcriptRows(messageTurns([older, u1, a1]), lookup({ a1: [...parts, part("p8", "a1")] })) + + expect(next.find((row) => row.type === "user" && row.turn === "u1")?.key).toBe(current[0]?.key) + expect(next.find((row) => row.type === "assistant" && row.parts[0]?.id === "p0")?.key).toBe(current[1]?.key) + }) + + it("reuses unchanged rows across prepend and append updates", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const p1 = part("p1", "a1") + const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [p1] })) + const u0 = user("u0") + const a2 = assistant("a2", "u2") + const second = transcriptRows(messageTurns([u0, u1, a1, u2, a2]), lookup({ a1: [p1] }), {}, first) + + expect(second[1]).toBe(first[0]) + expect(second[2]).toBe(first[1]) + expect(second[3]).not.toBe(first[2]) + expect(second[4]).not.toBe(first[2]) + }) + + it("selects the last real assistant text part as the copy target", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1") + const synthetic: Part = { ...part("p2", "a2"), synthetic: true } + const blank: Part = { ...part("p3", "a2"), text: " " } + const rows = transcriptRows( + messageTurns([u1, a1, a2]), + lookup({ a1: [part("p1", "a1")], a2: [synthetic, blank] }), + ) + + expect(rows.filter((row) => row.type === "assistant").map((row) => row.copy)).toEqual(["p1", "p1"]) + }) + + it("keeps compaction replies ordered under the compacted turn and respects revert turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2", { + parts: [{ id: "compact", messageID: "u2", type: "compaction", auto: false }], + }) + const a2 = assistant("a2", "u1") + const u3 = user("u3") + const turns = messageTurns([u1, a1, u2, a2, u3], "u3") + const rows = transcriptRows(turns, (id) => (id === "u2" ? u2.parts ?? [] : [])) + + expect(rows.map((row) => `${row.turn}:${row.message.id}`)).toEqual([ + "u1:u1", + "u1:a1", + "u2:u2", + "u2:a2", + ]) + }) + + it("replaces only rows whose data or metadata changed", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const p1 = part("p1", "a1") + const first = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [p1] })) + const changed = { ...p1, text: "changed" } + const second = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), {}, first) + + expect(second[0]).toBe(first[0]) + expect(second[1]).not.toBe(first[1]) + + const live = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), { live: new Set(["u1"]) }, second) + expect(live[0]).not.toBe(second[0]) + expect(live[1]).not.toBe(second[1]) + }) +}) + +describe("partitionRows", () => { + it("pins only a bounded live suffix and virtualizes completed history", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = Array.from({ length: 18 }, (_, i) => part(`p${i}`, "a2")) + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup({ a1: [part("old", "a1")], a2: parts }), { + live: new Set(["u2"]), + }) + const result = partitionRows(rows) + + expect(result.keep).toEqual([result.virtual.length - 2, result.virtual.length - 1]) + expect(result.keep.map((idx) => result.virtual[idx]).every((row) => row?.live && row.turn === "u2")).toBe(true) + expect( + result.keep + .flatMap((idx) => { + const row = result.virtual[idx] + return row?.type === "assistant" ? row.parts : [] + }) + .map((item) => item.id), + ).toEqual(["p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17"]) + expect(result.virtual.some((row) => row.turn === "u1")).toBe(true) + expect(result.virtual.some((row) => row.turn === "u2" && row.type === "user")).toBe(true) + }) + + it("returns completed live metadata to virtual history after queue handoff", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), { + live: new Set(["u1"]), + queued: new Set(["u2"]), + }) + const active = partitionRows(first) + expect(active.keep).toEqual([0, 1]) + expect(active.keep.map((idx) => active.virtual[idx]?.turn)).toEqual(["u1", "u1"]) + expect(active.queued.map((row) => row.turn)).toEqual(["u2"]) + + const second = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), { + live: new Set(["u2"]), + }) + const handed = partitionRows(second) + + expect(handed.keep.map((idx) => handed.virtual[idx]?.turn)).toEqual(["u2"]) + expect(handed.virtual.filter((row) => row.turn === "u1")).toHaveLength(2) + }) + + it("keeps queued rows in visual order inside virtual data", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { + live: new Set(["u1"]), + queued: new Set(["u2"]), + }) + const result = partitionRows(rows) + + expect(result.virtual.map((row) => row.turn)).toEqual(["u1"]) + expect(result.keep).toEqual([0]) + expect(result.queued.map((row) => row.turn)).toEqual(["u2"]) + expect(result.queued[0]).toMatchObject({ type: "user", queued: true }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 92fb514d580..1b93f888255 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -110,6 +110,7 @@ function matchToolRequest = (props) => { const open = createMemo(() => config().terminal_command_display !== "collapsed") const parts = createMemo(() => { - const stored = data.store.part?.[props.message.id] + const stored = props.parts ?? data.store.part?.[props.message.id] if (!stored) return [] return (stored as SDKPart[]).filter((part) => isRenderable(part)) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index eaf4243e970..107a4d7e64e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -20,23 +20,32 @@ import { useLanguage } from "../../context/language" import { recentSessions } from "../../context/session-utils" import { formatRelativeDate } from "../../utils/date" import { FeedbackDialog } from "./FeedbackDialog" -import { VscodeSessionTurn } from "./VscodeSessionTurn" +import { TranscriptRowView } from "./TranscriptRow" import { RevertBanner } from "./RevertBanner" import { AccountSwitcher } from "../shared/AccountSwitcher" import { KiloNotifications } from "./KiloNotifications" import { WorkingIndicator } from "../shared/WorkingIndicator" import { TurnOutcome } from "../shared/TurnOutcome" import { QuestionDock } from "./QuestionDock" -import { Virtualizer } from "virtua/solid" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { SuggestBar } from "./SuggestBar" +import { + getMeasurement, + getScroll, + layoutFingerprint, + resolveAnchor, + rowFingerprint, + setMeasurement, + setScroll, +} from "./transcript-cache" import { activeUserMessageID as getActiveUserMessageID, messageTurns, - partitionTurns, queuedUserMessageIDs, stableMessageTurns, type MessageTurn, } from "../../context/session-queue" +import { partitionRows, transcriptRows, type TranscriptRow } from "../../context/transcript-rows" import type { QuestionRequest, SuggestionRequest } from "../../types/messages" const KiloLogo = (): JSX.Element => { @@ -88,7 +97,8 @@ export const MessageList: Component = (props) => { }) const [scrollEl, setScrollEl] = createSignal() - const positions = new Map() + const [virtualizer, setVirtualizer] = createSignal() + const [layout, setLayout] = createSignal("") const boundary = () => session.revert()?.messageID const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -107,45 +117,55 @@ export const MessageList: Component = (props) => { const queuedIDs = createMemo( () => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))), ) - const [held, setHeld] = createSignal<{ sid: string; ids: Set }>() - createEffect(() => { - const id = activeUserID() - const sid = session.currentSessionID() - const paused = autoScroll.userScrolled() - if (!sid || (!id && !paused)) { - setHeld(undefined) - return - } - if (!id) return - if (!paused) { - setHeld({ sid, ids: new Set([id]) }) - return - } - setHeld((prev) => { - if (prev?.sid === sid && prev.ids.has(id)) return prev - const ids = prev?.sid === sid ? new Set(prev.ids) : new Set() - ids.add(id) - return { sid, ids } - }) - }) - const directIDs = createMemo(() => { - const item = held() - const ids = item && item.sid === session.currentSessionID() ? new Set(item.ids) : new Set() + const rows = createMemo((prev: TranscriptRow[] | undefined) => { const active = activeUserID() - if (active) ids.add(active) - return ids + return transcriptRows( + turns(), + (msg) => session.getParts(msg), + { + queued: queuedIDs(), + live: new Set(active ? [active] : []), + hidden: session.isErrorHidden, + }, + prev, + ) + }) + const partition = createMemo(() => partitionRows(rows())) + const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const fingerprint = createMemo(() => rowFingerprint(keys())) + const measurement = createMemo(() => { + const id = session.currentSessionID() + const token = layout() + if (!id || !token || session.loading() || keys().length === 0) return undefined + return getMeasurement(id, fingerprint(), token) }) - // Keep the growing live turn out of Virtua. Resizing a tall virtual item while - // the user reads within it makes Virtua compensate scrollTop as if earlier - // content moved, dragging the viewport downward during streaming. Preserve - // direct-rendered tail turns while paused so completion and queue handoffs do - // not move a turn being read back into the virtualized history. - const partition = createMemo(() => partitionTurns(turns(), directIDs(), queuedIDs())) - const save = (id: string | undefined) => { + let active = { id: session.currentSessionID(), keys: keys(), fingerprint: fingerprint() } + createEffect(() => { + const id = session.currentSessionID() + const current = keys() + const value = fingerprint() + if (!id || session.loading() || active.id !== id) return + active = { id, keys: current, fingerprint: value } + }) + + const save = (id: string | undefined, saved = active) => { const el = scrollEl() - if (!id || !el) return - positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() }) + if (!id || !el || saved.id !== id) return + const handle = virtualizer() + const token = layout() + if (handle && token && saved.keys.length > 0) { + setMeasurement(id, saved.fingerprint, token, handle.cache) + } + if (!autoScroll.userScrolled()) { + setScroll(id, { type: "bottom" }) + return + } + if (!handle || saved.keys.length === 0) return + const index = handle.findStartIndex() + const key = saved.keys[index] + if (!key) return + setScroll(id, { type: "anchor", key, offset: handle.scrollOffset - handle.getItemOffset(index) }) } const maybeLoadOlder = () => { @@ -159,16 +179,44 @@ export const MessageList: Component = (props) => { maybeLoadOlder() } + let resize: ResizeObserver | undefined + const refreshLayout = () => { + const el = scrollEl() + if (!el) return + const style = getComputedStyle(el) + setLayout( + layoutFingerprint({ + width: Math.round(el.clientWidth), + ratio: window.devicePixelRatio, + font: style.fontFamily, + size: style.fontSize, + line: style.lineHeight, + }), + ) + } const setScrollRef = (el: HTMLElement | undefined) => { + resize?.disconnect() setScrollEl(el) autoScroll.scrollRef(el) + if (!el) return + refreshLayout() + resize = new ResizeObserver(refreshLayout) + resize.observe(el) } + window.addEventListener("resize", refreshLayout) + document.fonts?.addEventListener("loadingdone", refreshLayout) + onCleanup(() => { + resize?.disconnect() + window.removeEventListener("resize", refreshLayout) + document.fonts?.removeEventListener("loadingdone", refreshLayout) + }) const [pendingRestore, setPendingRestore] = createSignal() createEffect( on(session.currentSessionID, (id, prev) => { save(prev) + active = { id, keys: [], fingerprint: rowFingerprint([]) } setPendingRestore(id) }), ) @@ -185,9 +233,11 @@ export const MessageList: Component = (props) => { if (pendingRestore() !== id) return const el = scrollEl() if (!el) return - const pos = positions.get(id) - if (pos?.userScrolled) { - el.scrollTop = pos.top + const state = getScroll(id) + const anchor = resolveAnchor(state, keys()) + const handle = virtualizer() + if (state?.type === "anchor" && anchor && handle) { + handle.scrollToIndex(anchor.index, { offset: anchor.offset }) autoScroll.pause() maybeLoadOlder() } else { @@ -262,28 +312,36 @@ export const MessageList: Component = (props) => { {language.t("session.messages.loadEarlier")} - 0 || partition().direct.length > 0}> -
- 0}> + 0}> +
+ - {(turn) => } + {(row, index) => ( + + )} - - {(turn) => } -
- {(turn) => } + {(row) => } {(req) => } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx index 3f4efb1db5b..17f5f36a7aa 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -1,21 +1,13 @@ /** - * TaskTimeline — horizontal strip of colored bars representing session activity. - * - * Each bar = one Part from assistant messages. - * Color = part type (read=blue, write=dark blue, tool=indigo, error=red, text=gray). - * Width = proportional to time between parts. - * Height = proportional to content length. - * - * Interactions: drag scroll, mouse wheel, auto-scroll to latest. - * - * No virtualization needed: SolidJS creates each element once and - * updates bindings in place (unlike React). Even 1000+ bars are fine. + * Horizontal session activity timeline rendered as color-grouped SVG paths. + * Pointer and keyboard interaction use the same pure bar geometry. */ -import { Component, Index, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js" +import { Component, For, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js" import { Portal } from "solid-js/web" import { useSession } from "../../context/session" import { color, label } from "../../utils/timeline/colors" +import { geometry, hit, navigate } from "../../utils/timeline/geometry" import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes" import type { Part, Message } from "../../types/messages" @@ -56,7 +48,8 @@ export const TaskTimeline: Component = () => { let dragging = false let startX = 0 let startScroll = 0 - let tipBar: HTMLElement | undefined + const [hover, setHover] = createSignal(-1) + const [active, setActive] = createSignal(-1) const [tip, setTip] = createSignal<{ text: string; x: number; y: number }>() const messages = () => session.visibleMessages() @@ -71,12 +64,20 @@ export const TaskTimeline: Component = () => { } const bars = createMemo(() => collect(messages(), allParts())) + const layout = createMemo(() => geometry(bars(), MAX_HEIGHT)) const busy = () => session.status() === "busy" + const selected = () => { + const idx = active() + if (idx >= 0 && idx < bars().length) return idx + return bars().length - 1 + } + const aria = () => { + const idx = selected() + const bar = bars()[idx] + if (!bar) return "Session activity timeline, no activity" + return `Session activity timeline, bar ${idx + 1} of ${bars().length}: ${bar.tip}` + } - // Reading scrollWidth and writing scrollLeft synchronously for every appended bar can - // force repeated layout during streamed part updates. Batch those appends behind one - // animation frame, after Solid has applied the current DOM updates. Only follow while - // pinned so inspecting earlier activity is not interrupted by incoming bars. let prev = 0 let frame: number | undefined let follow = true @@ -87,6 +88,7 @@ export const TaskTimeline: Component = () => { on( () => bars().length, (len) => { + if (active() >= len) setActive(len - 1) if (len > prev && ref && follow && frame === undefined) { frame = requestAnimationFrame(() => { frame = undefined @@ -103,28 +105,32 @@ export const TaskTimeline: Component = () => { }) const hideTip = () => { - tipBar = undefined + setHover(-1) setTip(undefined) } createEffect(on(bars, hideTip, { defer: true })) - const showTip = (e: PointerEvent) => { - if (dragging || !(e.target instanceof Element)) return - const bar = e.target.closest(".task-timeline-bar") - if (!bar || !ref?.contains(bar)) return hideTip() - if (bar === tipBar) return - const rect = bar.getBoundingClientRect() - tipBar = bar + const showTip = (idx: number) => { + const item = layout().items[idx] + const bar = bars()[idx] + if (!ref || !item || !bar) return hideTip() + const rect = ref.getBoundingClientRect() const margin = Math.min(160, window.innerWidth / 2) + setHover(idx) setTip({ - text: bar.dataset.tip ?? "", - x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + rect.width / 2)), - y: rect.top, + text: bar.tip, + x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + item.x - ref.scrollLeft + item.width / 2)), + y: rect.top + MAX_HEIGHT - item.height, }) } - // ── Drag scroll ────────────────────────────────────────────────── + const pointerIndex = (e: PointerEvent) => { + if (!ref) return -1 + const rect = ref.getBoundingClientRect() + return hit(layout().items, e.clientX - rect.left + ref.scrollLeft) + } + const onPointerDown = (e: PointerEvent) => { hideTip() if (!ref) return @@ -137,19 +143,24 @@ export const TaskTimeline: Component = () => { } const onPointerMove = (e: PointerEvent) => { - if (!dragging || !ref) return showTip(e) + if (!ref) return + if (!dragging) { + const idx = pointerIndex(e) + if (idx === hover()) return + if (idx < 0) return hideTip() + return showTip(idx) + } ref.scrollLeft = startScroll - (e.clientX - startX) } const onPointerUp = (e: PointerEvent) => { if (!ref) return dragging = false - ref.releasePointerCapture(e.pointerId) + if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId) ref.style.cursor = "grab" ref.style.userSelect = "" } - // ── Wheel → horizontal scroll ──────────────────────────────────── const onWheel = (e: WheelEvent) => { hideTip() if (!ref) return @@ -157,6 +168,20 @@ export const TaskTimeline: Component = () => { ref.scrollLeft += e.deltaY || e.deltaX } + const onKeyDown = (e: KeyboardEvent) => { + if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return + e.preventDefault() + const idx = navigate(selected(), bars().length, e.key) + setActive(idx) + const item = layout().items[idx] + if (!item) return + const left = item.x + const right = item.x + item.width + if (left < ref.scrollLeft) ref.scrollLeft = left + if (right > ref.scrollLeft + ref.clientWidth) ref.scrollLeft = right - ref.clientWidth + showTip(idx) + } + createEffect(() => { const el = ref if (!el) return @@ -164,13 +189,37 @@ export const TaskTimeline: Component = () => { onCleanup(() => el.removeEventListener("wheel", onWheel)) }) + const overlay = (idx: number, pulse = false) => { + const item = layout().items[idx] + if (!item) return null + return ( +