From a91bf44acf1f4925fcedba74885916bece4fdbab Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 28 Aug 2026 16:25:31 +0200 Subject: [PATCH 1/2] refactor: ratchet and reduce Kilo-owned duplication --- .../workflows/check-opencode-annotations.yml | 13 + package.json | 1 + packages/core/src/kilocode/session-message.ts | 30 +- .../kilocode/event-storage-compat.test.ts | 32 +- packages/kilo-gateway/package.json | 4 +- packages/kilo-gateway/src/claw/index.ts | 39 + .../kilo-gateway/src/claw/kilo-chat-client.ts | 232 +++++ packages/kilo-gateway/src/claw/types.ts | 182 ++++ .../kilo-gateway/src/event-service/client.ts | 361 +++++++ .../src/indexing/embedders/bedrock.ts | 52 +- .../indexing/embedders/openai-compatible.ts | 190 +--- .../src/indexing/embedders/openai.ts | 72 +- .../src/indexing/embedders/openrouter.ts | 198 +--- .../src/indexing/embedders/voyage.ts | 72 +- .../src/indexing/shared/embedder-helpers.ts | 89 ++ .../shared/openai-compatible-helpers.ts | 80 ++ packages/kilo-ui/src/components/code.tsx | 76 +- packages/kilo-ui/src/components/diff-ssr.tsx | 152 +-- packages/kilo-ui/src/components/diff.tsx | 172 +--- .../kilo-ui/src/components/prompt-input.css | 100 +- packages/kilo-ui/src/pierre/diff-dom.ts | 93 ++ .../kilo-ui/src/pierre/selection-range.ts | 28 + packages/kilo-ui/src/pierre/selection.ts | 62 ++ .../src/kiloclaw/event-service-client.ts | 390 +------ .../src/kiloclaw/kilo-chat-client.ts | 269 +---- packages/kilo-vscode/src/kiloclaw/types.ts | 288 +----- .../fixtures/session-provider-activity.tsx | 1 + .../tests/unit/agent-manager-arch.test.ts | 1 + .../webview-ui/agent-manager/DiffPanel.tsx | 633 ++---------- .../agent-manager/agent-manager-review.css | 436 +------- .../agent-manager/agent-manager.css | 34 + .../diff-viewer/FullScreenDiffView.tsx | 573 ++--------- .../webview-ui/diff-viewer/ReviewDiffItem.tsx | 250 +++++ .../webview-ui/diff-viewer/diff-requests.ts | 2 + .../diff-viewer/review-annotations.ts | 7 +- .../diff-viewer/review-controller.ts | 279 +++++ .../webview-ui/diff-viewer/review-scroll.ts | 24 + .../webview-ui/diff-viewer/review-setup.ts | 53 + .../webview-ui/diff-viewer/review-state.ts | 40 + .../webview-ui/documents/DocumentPanel.tsx | 2 +- .../webview-ui/kiloclaw/lib/types.ts | 174 +--- .../webview-ui/src/styles/prompt-input.css | 60 -- .../src/kilocode/claw/kilo-chat-client.ts | 263 +---- packages/opencode/src/kilocode/claw/types.ts | 242 +---- .../src/kilocode/event-service/client.ts | 383 +------ .../src/kilocode/session-import/service.ts | 60 +- .../kilocode/session-import-service.test.ts | 45 +- .../tests/check-kilocode-duplication.test.ts | 318 ++++++ script/check-kilocode-duplication.ts | 351 +++++++ script/kilocode-duplication-allowlist.json | 960 ++++++++++++++++++ 50 files changed, 4040 insertions(+), 4428 deletions(-) create mode 100644 packages/kilo-gateway/src/claw/index.ts create mode 100644 packages/kilo-gateway/src/claw/kilo-chat-client.ts create mode 100644 packages/kilo-gateway/src/claw/types.ts create mode 100644 packages/kilo-gateway/src/event-service/client.ts create mode 100644 packages/kilo-indexing/src/indexing/shared/embedder-helpers.ts create mode 100644 packages/kilo-indexing/src/indexing/shared/openai-compatible-helpers.ts create mode 100644 packages/kilo-ui/src/pierre/diff-dom.ts create mode 100644 packages/kilo-ui/src/pierre/selection-range.ts create mode 100644 packages/kilo-ui/src/pierre/selection.ts create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/ReviewDiffItem.tsx create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/review-scroll.ts create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/review-setup.ts create mode 100644 packages/kilo-vscode/webview-ui/diff-viewer/review-state.ts create mode 100644 packages/script/tests/check-kilocode-duplication.test.ts create mode 100644 script/check-kilocode-duplication.ts create mode 100644 script/kilocode-duplication-allowlist.json diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 51d40efb260..604eaa83669 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -6,12 +6,18 @@ on: - ".github/**" - "github/**" - "packages/extensions/**" + - "packages/kilo-*/**" + - "packages/*/src/kilocode/**" + - "packages/*/src/kilo-*/**" + - "packages/plugin-atomic-chat/**" - "packages/opencode/**" - "packages/script/**" - "packages/shared/**" - "packages/storybook/**" - "packages/ui/**" - "script/**" + - "package.json" + - "bun.lock" workflow_dispatch: jobs: @@ -44,6 +50,13 @@ jobs: - name: Check domain architecture boundaries and ratchets run: bun run script/check-architecture.ts + - name: Test the Kilo duplication ratchet + working-directory: packages/script + run: bun test ./tests/check-kilocode-duplication.test.ts + + - name: Check Kilo-owned code duplication + run: bun run check:duplication + - name: Check model tool network boundary run: bun run script/check-model-tool-network.ts diff --git a/package.json b/package.json index 7b0f1f521ca..c04c503224b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "check:architecture": "bun run script/check-architecture.ts", + "check:duplication": "bun run script/check-kilocode-duplication.ts", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty && bun run script/setup-git.ts", diff --git a/packages/core/src/kilocode/session-message.ts b/packages/core/src/kilocode/session-message.ts index 74f89af193a..4f472a7bd92 100644 --- a/packages/core/src/kilocode/session-message.ts +++ b/packages/core/src/kilocode/session-message.ts @@ -8,12 +8,7 @@ function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -export function normalize(value: unknown): unknown { - if (!record(value)) return value - // New readers recover the canonical summary while old readers receive recent context inline. - if (value.type === "compaction" && typeof value.kilo_summary === "string") { - return { ...value, summary: value.kilo_summary } - } +function transform(value: Record, convert: (value: unknown) => unknown): Record { if (value.type !== "assistant" || !Array.isArray(value.content)) return value return { ...value, @@ -22,11 +17,20 @@ export function normalize(value: unknown): unknown { const status = item.state.status if (status !== "running" && status !== "completed" && status !== "error") return item if (!Array.isArray(item.state.content)) return item - return { ...item, state: { ...item.state, content: item.state.content.map((entry) => decode(entry)) } } + return { ...item, state: { ...item.state, content: item.state.content.map((entry) => convert(entry)) } } }), } } +export function normalize(value: unknown): unknown { + if (!record(value)) return value + // New readers recover the canonical summary while old readers receive recent context inline. + if (value.type === "compaction" && typeof value.kilo_summary === "string") { + return { ...value, summary: value.kilo_summary } + } + return transform(value, decode) +} + export function encode(value: unknown): unknown { if (!record(value)) return value // Preserve current semantics while making released compaction rows self-contained. @@ -37,15 +41,5 @@ export function encode(value: unknown): unknown { kilo_summary: value.summary, } } - if (value.type !== "assistant" || !Array.isArray(value.content)) return value - return { - ...value, - content: value.content.map((item) => { - if (!record(item) || item.type !== "tool" || !record(item.state)) return item - const status = item.state.status - if (status !== "running" && status !== "completed" && status !== "error") return item - if (!Array.isArray(item.state.content)) return item - return { ...item, state: { ...item.state, content: item.state.content.map((entry) => encodeContent(entry)) } } - }), - } + return transform(value, encodeContent) } diff --git a/packages/core/test/kilocode/event-storage-compat.test.ts b/packages/core/test/kilocode/event-storage-compat.test.ts index 27746ec9e5a..1cf03933546 100644 --- a/packages/core/test/kilocode/event-storage-compat.test.ts +++ b/packages/core/test/kilocode/event-storage-compat.test.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { DateTime, Effect, Layer, Schema, Stream } from "effect" +import { DateTime, Effect, Schema, Stream } from "effect" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" @@ -179,6 +179,36 @@ replay.effect("reads and replays released prompt promotion events", () => }), ) +it.effect("round-trips assistant tool content across running and settled states", () => + Effect.sync(() => { + const text = { type: "text", text: "Tool output" } + const legacy = { type: "media", mediaType: "image/png", data: "AAAA", filename: "image.png" } + const file = { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" } + const stored = { type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png", name: "image.png" } + for (const status of ["running", "completed", "error"]) { + const input = { type: "assistant", content: [{ type: "tool", state: { status, content: [text, legacy] } }] } + const normalized = StoredMessage.normalize(input) + expect(normalized).toMatchObject({ content: [{ state: { status, content: [text, file] } }] }) + const encoded = StoredMessage.encode(normalized) + expect(encoded).toMatchObject({ content: [{ state: { status, content: [text, stored] } }] }) + expect(StoredMessage.normalize(encoded)).toEqual(normalized) + expect(input.content.at(0)?.state.content.at(1)).toBe(legacy) + } + }), +) + +it.effect("leaves non-assistant values and pending tool content unchanged", () => + Effect.sync(() => { + for (const input of [null, 1, [], { type: "user", content: [] }, { type: "assistant", content: null }]) { + expect(StoredMessage.normalize(input)).toBe(input) + expect(StoredMessage.encode(input)).toBe(input) + } + const pending = { type: "assistant", content: [{ type: "tool", state: { status: "pending", content: [null] } }] } + expect(StoredMessage.normalize(pending)).toEqual(pending) + expect(StoredMessage.encode(pending)).toEqual(pending) + }), +) + it.effect("stores self-contained compaction projections for released readers", () => Effect.sync(() => { const encoded = StoredMessage.encode({ diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index a9eae19cebb..097856e798d 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -22,7 +22,9 @@ "./edit": "./src/edit.ts", "./edit-prompt": "./src/edit-prompt.ts", "./provider-usage": "./src/provider-usage.ts", - "./tui": "./src/tui.ts" + "./event-service": "./src/event-service/client.ts", + "./tui": "./src/tui.ts", + "./claw": "./src/claw/index.ts" }, "files": [ "dist" diff --git a/packages/kilo-gateway/src/claw/index.ts b/packages/kilo-gateway/src/claw/index.ts new file mode 100644 index 00000000000..81c87a24f6f --- /dev/null +++ b/packages/kilo-gateway/src/claw/index.ts @@ -0,0 +1,39 @@ +export { KiloChatApiError, KiloChatClient } from "./kilo-chat-client.js" +export type { KiloChatClientConfig } from "./kilo-chat-client.js" +export type { + ActionDeliveryFailedEvent, + ActionExecutedEvent, + ActionsBlock, + ActionItem, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationDetail, + ConversationLeftEvent, + ConversationListItem, + ConversationMember, + ConversationReadEvent, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, + KiloChatEventMap, + KiloChatEventName, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + ReactionSummary, + ReplyToSnapshot, + TextBlock, + TypingEvent, + TypingMember, + TypingStopEvent, +} from "./types.js" diff --git a/packages/kilo-gateway/src/claw/kilo-chat-client.ts b/packages/kilo-gateway/src/claw/kilo-chat-client.ts new file mode 100644 index 00000000000..7a193445117 --- /dev/null +++ b/packages/kilo-gateway/src/claw/kilo-chat-client.ts @@ -0,0 +1,232 @@ +import type { + BotStatusRecord, + ContentBlock, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, +} from "./types.js" + +export type KiloChatClientConfig = { + baseUrl: string + getToken: () => Promise + onUnauthorized?: () => void +} + +export class KiloChatApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) + this.name = "KiloChatApiError" + } +} + +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + +type HttpOpts = { + method?: string + body?: unknown + query?: Record +} + +type SendQueue = Map> + +export class KiloChatClient { + private readonly baseUrl: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly sendQueues: SendQueue = new Map() + + constructor(config: KiloChatClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ + conversations: ConversationListItem[] + hasMore: boolean + nextCursor: string | null + }> { + return this.request("/v1/conversations", { + query: { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor ?? undefined, + }, + }) + } + + createConversation(req: { + sandboxId: string + title?: string + }): Promise<{ conversationId: string; conversation?: Conversation }> { + return this.request("/v1/conversations", { method: "POST", body: req }) + } + + renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { + return this.request(`/v1/conversations/${conversationId}`, { + method: "PATCH", + body: { title }, + }) + } + + async leaveConversation(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) + } + + markConversationRead( + conversationId: string, + req: { lastSeenMessageId: string }, + ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { + return this.request(`/v1/conversations/${conversationId}/mark-read`, { + method: "POST", + body: req, + }) + } + + sendMessage(req: { + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + clientId?: string + }): Promise<{ messageId: string; clientId?: string; message?: Message }> { + const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() + const send = () => + this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { + method: "POST", + body: req, + }) + const next = prev.then(send, send) + this.sendQueues.set(req.conversationId, next) + const cleanup = () => { + if (this.sendQueues.get(req.conversationId) === next) { + this.sendQueues.delete(req.conversationId) + } + } + void next.then(cleanup, cleanup) + return next + } + + editMessage( + messageId: string, + req: { conversationId: string; content: ContentBlock[]; timestamp: number }, + ): Promise<{ messageId?: string; message?: Message }> { + return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) + } + + async deleteMessage(messageId: string, conversationId: string): Promise { + await this.request(`/v1/messages/${messageId}`, { + method: "DELETE", + query: { conversationId }, + }) + } + + listMessages( + conversationId: string, + opts?: { before?: string; limit?: number }, + ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { + return this.request(`/v1/conversations/${conversationId}/messages`, { + query: { before: opts?.before, limit: opts?.limit }, + }) + } + + executeAction( + conversationId: string, + messageId: string, + req: { groupId: string; value: ExecApprovalDecision }, + ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { + return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { + method: "POST", + body: req, + }) + } + + addReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ id: string; operationId?: string }> { + return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) + } + + async removeReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { + return this.request<{ removed: boolean; id: string | null; operationId?: string }>( + `/v1/messages/${messageId}/reactions`, + { + method: "DELETE", + query: req, + }, + ) + } + + async sendTyping(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) + } + + async sendTypingStop(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) + } + + getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { + return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) + } + + async requestBotStatus(sandboxId: string): Promise { + await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) + } + + getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { + return this.request(`/v1/conversations/${conversationId}/conversation-status`) + } + + private async request(path: string, opts: HttpOpts = {}): Promise { + const token = await this.getToken() + let url = `${this.baseUrl}${path}` + + if (opts.query) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue + params.set(k, String(v)) + } + const qs = params.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { Authorization: `Bearer ${token}` } + if (opts.body !== undefined) headers["Content-Type"] = "application/json" + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }) + + if (!res.ok) { + if (res.status === 401 || res.status === 403) this.onUnauthorized?.() + const body: unknown = await res.json().catch(() => null) + throw new KiloChatApiError(res.status, body) + } + + if (res.status === 204) return undefined as unknown as T + return (await res.json()) as T + } +} diff --git a/packages/kilo-gateway/src/claw/types.ts b/packages/kilo-gateway/src/claw/types.ts new file mode 100644 index 00000000000..f2ec425f5a8 --- /dev/null +++ b/packages/kilo-gateway/src/claw/types.ts @@ -0,0 +1,182 @@ +export type ClawStatus = { + status: + | "provisioned" + | "starting" + | "restarting" + | "recovering" + | "running" + | "stopped" + | "destroying" + | "restoring" + | null + sandboxId?: string + flyRegion?: string + machineSize?: { cpus: number; memory_mb: number } + openclawVersion?: string | null + lastStartedAt?: string | null + lastStoppedAt?: string | null + channelCount?: number + secretCount?: number + userId?: string + botName?: string | null +} + +export type ChatToken = { + token: string + expiresAt: string + kiloChatUrl: string + eventServiceUrl: string +} + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" + +export type TextBlock = { type: "text"; text: string } + +export type ActionItem = { + label: string + style: "primary" | "danger" | "secondary" + value: ExecApprovalDecision +} + +export type ActionsBlock = { + type: "actions" + groupId: string + actions: ActionItem[] + resolved?: { + value: ExecApprovalDecision + resolvedBy: string + resolvedAt: number + } +} + +export type ContentBlock = TextBlock | ActionsBlock + +export type ReactionSummary = { + emoji: string + count: number + memberIds: string[] +} + +export type Message = { + id: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + updatedAt: number | null + clientUpdatedAt: number | null + deleted: boolean + deliveryFailed: boolean + reactions: ReactionSummary[] +} + +export type ConversationListItem = { + conversationId: string + title: string | null + lastActivityAt: number | null + lastReadAt: number | null + joinedAt: number +} + +export type ConversationMember = { id: string; kind: "user" | "bot" } + +export type ConversationDetail = { + id: string + title: string | null + createdBy: string + createdAt: number + members: ConversationMember[] +} + +export type BotStatusRecord = { + online: boolean + at: number + updatedAt: number +} + +export type ConversationStatusRecord = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number + updatedAt: number +} + +export type ReplyToSnapshot = { + messageId: string + senderId: string + content: ContentBlock[] + deleted?: boolean +} + +export type MessageCreatedEvent = { + messageId: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + clientId?: string + replyTo?: ReplyToSnapshot | null +} + +export type MessageUpdatedEvent = { + messageId: string + content: ContentBlock[] + clientUpdatedAt: number | null +} + +export type MessageDeletedEvent = { messageId: string } +export type MessageDeliveryFailedEvent = { messageId: string } +export type TypingEvent = { memberId: string } +export type TypingStopEvent = { memberId: string } +export type TypingMember = { memberId: string; at: number } +export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } +export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } + +export type ConversationCreatedEvent = { + conversationId: string + conversation?: ConversationListItem +} +export type ConversationRenamedEvent = { conversationId: string; title: string } +export type ConversationLeftEvent = { conversationId: string } +export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } +export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } +export type ActionExecutedEvent = { + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + executedBy: string +} +export type ActionDeliveryFailedEvent = { conversationId: string; messageId: string; groupId: string } +export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } +export type ConversationStatusEvent = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number +} + +export type KiloChatEventMap = { + "message.created": MessageCreatedEvent + "message.updated": MessageUpdatedEvent + "message.deleted": MessageDeletedEvent + "message.delivery_failed": MessageDeliveryFailedEvent + typing: TypingEvent + "typing.stop": TypingStopEvent + "reaction.added": ReactionAddedEvent + "reaction.removed": ReactionRemovedEvent + "conversation.created": ConversationCreatedEvent + "conversation.renamed": ConversationRenamedEvent + "conversation.left": ConversationLeftEvent + "conversation.read": ConversationReadEvent + "conversation.activity": ConversationActivityEvent + "action.executed": ActionExecutedEvent + "action.delivery_failed": ActionDeliveryFailedEvent + "bot.status": BotStatusEvent + "conversation.status": ConversationStatusEvent +} + +export type KiloChatEventName = keyof KiloChatEventMap diff --git a/packages/kilo-gateway/src/event-service/client.ts b/packages/kilo-gateway/src/event-service/client.ts new file mode 100644 index 00000000000..a301a82bc30 --- /dev/null +++ b/packages/kilo-gateway/src/event-service/client.ts @@ -0,0 +1,361 @@ +const WS_SUBPROTOCOL = "kilo.events.v1" +const HANDSHAKE_TIMEOUT_MS = 10_000 +const PING_INTERVAL_MS = 15_000 +const TICKET_FETCH_TIMEOUT_MS = 10_000 + +export class WebSocketAuthError extends Error { + constructor(message = "WebSocket authentication failed") { + super(message) + this.name = "WebSocketAuthError" + } +} + +export class WebSocketConnectError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message) + this.name = "WebSocketConnectError" + } +} + +export class HandshakeTimeoutError extends Error { + constructor() { + super("WebSocket handshake timed out") + this.name = "HandshakeTimeoutError" + } +} + +function isAuthCloseCode(code: number): boolean { + if (code === 1008) return true + if (code === 4401 || code === 4403) return true + return false +} + +export type EventHandler = (context: string, payload: unknown) => void + +export type EventServiceConfig = { + url: string + getToken: () => Promise + onUnauthorized?: () => void + onServerError?: (error: unknown) => void + handshakeTimeoutMs?: number +} + +function toHttpBase(wsBase: string): string { + const trimmed = wsBase.replace(/\/$/, "") + if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) + if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) + return trimmed +} + +export class EventServiceClient { + private readonly url: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly onServerError: ((error: unknown) => void) | undefined + private readonly handshakeTimeoutMs: number + + private ws: WebSocket | null = null + private connected = false + private destroyed = false + private generation = 0 + private reconnectAttempts = 0 + private hasConnectedBefore = false + private reconnectTimer: ReturnType | null = null + private pingTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + private abortHandshake: ((err: Error) => void) | null = null + private tickets = new Set() + + private eventHandlers = new Map>() + private activeContexts = new Set() + private reconnectHandlers = new Set<() => void>() + + constructor(config: EventServiceConfig) { + this.url = config.url + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + this.onServerError = config.onServerError + this.handshakeTimeoutMs = config.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS + } + + async connect(): Promise { + const gen = ++this.generation + this.destroyed = false + this.reconnectAttempts = 0 + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + try { + await this.connectOnce() + } catch (err) { + if (this.destroyed || this.generation !== gen) return + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + } + } + + disconnect(): void { + this.generation++ + this.destroyed = true + for (const ctrl of this.tickets) ctrl.abort() + this.tickets.clear() + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.clearHandshakeTimer() + if (this.abortHandshake) { + this.abortHandshake(new Error("disconnected")) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.stopPing() + this.connected = false + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + subscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.add(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.subscribe", contexts }) + } + } + + unsubscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.delete(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.unsubscribe", contexts }) + } + } + + on(event: string, handler: (context: string, payload: T) => void): () => void { + const set = this.eventHandlers.get(event) ?? new Set() + const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as T) + set.add(wrapped) + this.eventHandlers.set(event, set) + return () => { + set.delete(wrapped) + if (set.size === 0) this.eventHandlers.delete(event) + } + } + + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler) + return () => this.reconnectHandlers.delete(handler) + } + + private handleAuthFailure(err: unknown): boolean { + if (err instanceof WebSocketAuthError) { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.onUnauthorized?.() + return true + } + return false + } + + private async connectOnce(): Promise { + const gen = this.generation + if (this.ws) { + const old = this.ws + this.ws = null + old.close() + } + + const token = await this.getToken() + if (this.destroyed || this.generation !== gen) return + const ticket = await this.fetchTicket(token) + if (this.destroyed || this.generation !== gen) return + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) + this.ws = ws + + let settled = false + const settleResolve = () => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + resolve() + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + reject(err) + } + this.abortHandshake = settleReject + + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.ws === ws) ws.close(1000, "handshake-timeout") + settleReject(new HandshakeTimeoutError()) + }, this.handshakeTimeoutMs) + + ws.addEventListener("open", () => { + if (this.ws !== ws) return + const isReconnect = this.hasConnectedBefore + this.connected = true + this.hasConnectedBefore = true + this.reconnectAttempts = 0 + this.resubscribeContexts() + if (isReconnect) { + for (const h of this.reconnectHandlers) h() + } + settleResolve() + this.startPing() + }) + + ws.addEventListener("message", (event: MessageEvent) => { + if (this.ws !== ws) return + this.handleMessage(String(event.data)) + }) + + ws.addEventListener("close", (event: CloseEvent) => { + if (this.ws !== ws) return + const wasConnected = this.connected + this.connected = false + this.stopPing() + this.clearHandshakeTimer() + if (!wasConnected) { + if (isAuthCloseCode(event.code)) { + settleReject(new WebSocketAuthError()) + } else { + settleReject( + new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), + ) + } + return + } + if (!this.destroyed) this.scheduleReconnect() + }) + + ws.addEventListener("error", () => {}) + }) + } + + private async fetchTicket(token: string): Promise { + const ctrl = new AbortController() + this.tickets.add(ctrl) + const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) + try { + const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal, + }) + if (res.status === 401 || res.status === 403) { + throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) + } + if (!res.ok) { + throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) + } + const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null + if (!body || typeof body.ticket !== "string" || !body.ticket) { + throw new WebSocketConnectError("Malformed event-service ticket response", 0) + } + return body.ticket + } catch (err) { + if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err + if ((err as { name?: string })?.name === "AbortError") { + throw new HandshakeTimeoutError() + } + throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) + } finally { + clearTimeout(timer) + this.tickets.delete(ctrl) + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer !== null) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private sendJson(msg: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)) + } + } + + private handleMessage(data: string): void { + if (data === "pong") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + if (!parsed || typeof parsed !== "object") return + const m = parsed as Record + if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { + const handlers = this.eventHandlers.get(m.event) + if (handlers) { + for (const h of handlers) h(m.context, m.payload) + } + return + } + if (m.type === "error") { + console.warn("[Kilo] event-service server error", m) + this.onServerError?.(m) + } + } + + private startPing(): void { + this.stopPing() + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send("ping") + } + }, PING_INTERVAL_MS) + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + } + + private resubscribeContexts(): void { + if (this.activeContexts.size > 0) { + this.sendJson({ + type: "context.subscribe", + contexts: Array.from(this.activeContexts), + }) + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) + const delay = base * (0.5 + Math.random() * 0.5) + this.reconnectAttempts++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + if (this.destroyed) return + const gen = this.generation + this.connectOnce().catch((err) => { + if (this.destroyed || this.generation !== gen) return + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + }) + }, delay) + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/bedrock.ts b/packages/kilo-indexing/src/indexing/embedders/bedrock.ts index 80946205eff..f155d4e778e 100644 --- a/packages/kilo-indexing/src/indexing/embedders/bedrock.ts +++ b/packages/kilo-indexing/src/indexing/embedders/bedrock.ts @@ -8,7 +8,8 @@ import { INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, } from "../constants" import { getDefaultModelId } from "../model-registry" -import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { withValidationErrorHandling, formatEmbeddingError } from "../shared/validation-helpers" +import { embedBatches } from "../shared/embedder-helpers" import { Log } from "../../util/log" const log = Log.create({ service: "embedder-bedrock" }) @@ -60,48 +61,13 @@ export class BedrockEmbedder implements IEmbedder { async createEmbeddings(texts: string[], model?: string): Promise { const modelToUse = model || this.defaultModelId - const allEmbeddings: number[][] = [] - const usage = { promptTokens: 0, totalTokens: 0 } - const remainingTexts = [...texts] - - while (remainingTexts.length > 0) { - const currentBatch: string[] = [] - let currentBatchTokens = 0 - const processedIndices: number[] = [] - - for (let i = 0; i < remainingTexts.length; i++) { - const text = remainingTexts[i] - const itemTokens = Math.ceil(text.length / 4) - - if (itemTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) - processedIndices.push(i) - continue - } - - if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { - currentBatch.push(text) - currentBatchTokens += itemTokens - processedIndices.push(i) - } else { - break - } - } - - // Remove processed items from remainingTexts (in reverse order to maintain correct indices) - for (let i = processedIndices.length - 1; i >= 0; i--) { - remainingTexts.splice(processedIndices[i], 1) - } - - if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) - allEmbeddings.push(...batchResult.embeddings) - usage.promptTokens += batchResult.usage.promptTokens - usage.totalTokens += batchResult.usage.totalTokens - } - } - - return { embeddings: allEmbeddings, usage } + return embedBatches( + texts, + MAX_ITEM_TOKENS, + MAX_BATCH_TOKENS, + (batch) => this._embedBatchWithRetries(batch, modelToUse), + (index, tokens) => log.warn(`Text at index ${index} exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + ) } /** diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 6b65c486e0a..100d2fe6ff0 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -10,7 +10,14 @@ import { } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" -import { Mutex } from "async-mutex" +import { applyQueryPrefix, embedBatches } from "../shared/embedder-helpers" +import { + createRateLimitState, + getRateLimitDelay, + projectEmbeddingResponse, + updateRateLimitState, + waitForRateLimit, +} from "../shared/openai-compatible-helpers" import { Log } from "../../util/log" const log = Log.create({ service: "embedder-openai-compatible" }) @@ -49,14 +56,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly dimensions?: number // Global rate limiting state shared across all instances - private static globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - // Mutex to ensure thread-safe access to rate limit state - mutex: new Mutex(), - } + private static globalRateLimitState = createRateLimitState() /** * Creates a new OpenAI Compatible embedder @@ -112,66 +112,21 @@ export class OpenAICompatibleEmbedder implements IEmbedder { const modelToUse = model || this.defaultModelId // Apply model-specific query prefix if required - const queryPrefix = getModelQueryPrefix("openai-compatible", modelToUse) - const processedTexts = queryPrefix - ? texts.map((text, index) => { - // Prevent double-prefixing - if (text.startsWith(queryPrefix)) { - return text - } - const prefixedText = `${queryPrefix}${text}` - const estimatedTokens = Math.ceil(prefixedText.length / 4) - if (estimatedTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) - // Return original text if adding prefix would exceed limit - return text - } - return prefixedText - }) - : texts + const processedTexts = applyQueryPrefix( + texts, + getModelQueryPrefix("openai-compatible", modelToUse), + MAX_ITEM_TOKENS, + (index, tokens) => + log.warn(`Text at index ${index} with prefix exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + ) - const allEmbeddings: number[][] = [] - const usage = { promptTokens: 0, totalTokens: 0 } - const remainingTexts = [...processedTexts] - - while (remainingTexts.length > 0) { - const currentBatch: string[] = [] - let currentBatchTokens = 0 - const processedIndices: number[] = [] - - for (let i = 0; i < remainingTexts.length; i++) { - const text = remainingTexts[i] - const itemTokens = Math.ceil(text.length / 4) - - if (itemTokens > this.maxItemTokens) { - log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${this.maxItemTokens})`) - processedIndices.push(i) - continue - } - - if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { - currentBatch.push(text) - currentBatchTokens += itemTokens - processedIndices.push(i) - } else { - break - } - } - - // Remove processed items from remainingTexts (in reverse order to maintain correct indices) - for (let i = processedIndices.length - 1; i >= 0; i--) { - remainingTexts.splice(processedIndices[i]!, 1) - } - - if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) - allEmbeddings.push(...batchResult.embeddings) - usage.promptTokens += batchResult.usage.promptTokens - usage.totalTokens += batchResult.usage.totalTokens - } - } - - return { embeddings: allEmbeddings, usage } + return embedBatches( + processedTexts, + this.maxItemTokens, + MAX_BATCH_TOKENS, + (batch) => this._embedBatchWithRetries(batch, modelToUse), + (index, tokens) => log.warn(`Text at index ${index} exceeds token limit (${tokens} > ${this.maxItemTokens})`), + ) } /** @@ -294,34 +249,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { })) as OpenAIEmbeddingResponse } - // Convert base64 embeddings to float32 arrays - const processedEmbeddings = response.data.map((item: EmbeddingItem) => { - if (typeof item.embedding === "string") { - const buffer = Buffer.from(item.embedding, "base64") - - // Create Float32Array view over the buffer - const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) - - return { - ...item, - embedding: Array.from(float32Array), - } - } - return item - }) - - // Replace the original data with processed embeddings - response.data = processedEmbeddings - - const embeddings = response.data.map((item) => item.embedding as number[]) - - return { - embeddings: embeddings, - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - totalTokens: response.usage?.total_tokens || 0, - }, - } + return projectEmbeddingResponse(response) } catch (error) { log.error("OpenAI Compatible embedder batch error", { err: error instanceof Error ? error.message : String(error), @@ -431,82 +359,20 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * Waits if there's an active global rate limit */ private async waitForGlobalRateLimit(): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - const waitTime = state.rateLimitResetTime - Date.now() - // Silent wait - no logging to prevent flooding - release() // Release mutex before waiting - await new Promise((resolve) => setTimeout(resolve, waitTime)) - return - } - - // Reset rate limit if time has passed - if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { - state.isRateLimited = false - state.consecutiveRateLimitErrors = 0 - } - } finally { - // Only release if we haven't already - try { - release() - } catch { - // Already released - } - } + return waitForRateLimit(OpenAICompatibleEmbedder.globalRateLimitState) } /** * Updates global rate limit state when a 429 error occurs */ - private async updateGlobalRateLimitState(error: HttpError): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - const now = Date.now() - - // Increment consecutive rate limit errors - if (now - state.lastRateLimitError < 60000) { - // Within 1 minute - state.consecutiveRateLimitErrors++ - } else { - state.consecutiveRateLimitErrors = 1 - } - - state.lastRateLimitError = now - - // Calculate exponential backoff based on consecutive errors - const baseDelay = 5000 // 5 seconds base - const maxDelay = 300000 // 5 minutes max - const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) - - // Set global rate limit - state.isRateLimited = true - state.rateLimitResetTime = now + exponentialDelay - - // Silent rate limit activation - no logging to prevent flooding - } finally { - release() - } + private async updateGlobalRateLimitState(_error: HttpError): Promise { + return updateRateLimitState(OpenAICompatibleEmbedder.globalRateLimitState) } /** * Gets the current global rate limit delay */ private async getGlobalRateLimitDelay(): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - return state.rateLimitResetTime - Date.now() - } - - return 0 - } finally { - release() - } + return getRateLimitDelay(OpenAICompatibleEmbedder.globalRateLimitState) } } diff --git a/packages/kilo-indexing/src/indexing/embedders/openai.ts b/packages/kilo-indexing/src/indexing/embedders/openai.ts index c9cc57c1807..4c1d4ccfa5e 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai.ts @@ -10,6 +10,7 @@ import { } from "../constants" import { getModelQueryPrefix } from "../model-registry" import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { embedBatches } from "../shared/embedder-helpers" import { Log } from "../../util/log" const log = Log.create({ service: "embedder-openai" }) @@ -45,67 +46,16 @@ export class OpenAiEmbedder implements IEmbedder { async createEmbeddings(texts: string[], model?: string): Promise { const modelToUse = model || this.defaultModelId - // Apply model-specific query prefix if required - const queryPrefix = getModelQueryPrefix("openai", modelToUse) - const processedTexts = queryPrefix - ? texts.map((text, index) => { - // Prevent double-prefixing - if (text.startsWith(queryPrefix)) { - return text - } - const prefixedText = `${queryPrefix}${text}` - const estimatedTokens = Math.ceil(prefixedText.length / 4) - if (estimatedTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) - // Return original text if adding prefix would exceed limit - return text - } - return prefixedText - }) - : texts - - const allEmbeddings: number[][] = [] - const usage = { promptTokens: 0, totalTokens: 0 } - const remainingTexts = [...processedTexts] - - while (remainingTexts.length > 0) { - const currentBatch: string[] = [] - let currentBatchTokens = 0 - const processedIndices: number[] = [] - - for (let i = 0; i < remainingTexts.length; i++) { - const text = remainingTexts[i] - const itemTokens = Math.ceil(text.length / 4) - - if (itemTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) - processedIndices.push(i) - continue - } - - if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { - currentBatch.push(text) - currentBatchTokens += itemTokens - processedIndices.push(i) - } else { - break - } - } - - // Remove processed items from remainingTexts (in reverse order to maintain correct indices) - for (let i = processedIndices.length - 1; i >= 0; i--) { - remainingTexts.splice(processedIndices[i], 1) - } - - if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) - allEmbeddings.push(...batchResult.embeddings) - usage.promptTokens += batchResult.usage.promptTokens - usage.totalTokens += batchResult.usage.totalTokens - } - } - - return { embeddings: allEmbeddings, usage } + return embedBatches( + texts, + MAX_ITEM_TOKENS, + MAX_BATCH_TOKENS, + (batch) => this._embedBatchWithRetries(batch, modelToUse), + (index, tokens) => log.warn(`Text at index ${index} exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + getModelQueryPrefix("openai", modelToUse), + (index, tokens) => + log.warn(`Text at index ${index} with prefix exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + ) } /** diff --git a/packages/kilo-indexing/src/indexing/embedders/openrouter.ts b/packages/kilo-indexing/src/indexing/embedders/openrouter.ts index bc8deea083a..49a8d7e94f2 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openrouter.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openrouter.ts @@ -10,7 +10,14 @@ import { } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" -import { Mutex } from "async-mutex" +import { applyQueryPrefix, embedBatches } from "../shared/embedder-helpers" +import { + createRateLimitState, + getRateLimitDelay, + projectEmbeddingResponse, + updateRateLimitState, + waitForRateLimit, +} from "../shared/openai-compatible-helpers" import { DEFAULT_HEADERS } from "../../headers" import { Log } from "../../util/log" @@ -48,14 +55,7 @@ export class OpenRouterEmbedder implements IEmbedder { private readonly dimensions?: number // Global rate limiting state shared across all instances - private static globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - // Mutex to ensure thread-safe access to rate limit state - mutex: new Mutex(), - } + private static globalRateLimitState = createRateLimitState() /** * Creates a new OpenRouter embedder @@ -106,73 +106,21 @@ export class OpenRouterEmbedder implements IEmbedder { const modelToUse = model || this.defaultModelId // Apply model-specific query prefix if required - const queryPrefix = getModelQueryPrefix("openrouter", modelToUse) - const processedTexts = queryPrefix - ? texts.map((text, index) => { - // Prevent double-prefixing - if (text.startsWith(queryPrefix)) { - return text - } - const prefixedText = `${queryPrefix}${text}` - const estimatedTokens = Math.ceil(prefixedText.length / 4) - if (estimatedTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) - // Return original text if adding prefix would exceed limit - return text - } - return prefixedText - }) - : texts + const processedTexts = applyQueryPrefix( + texts, + getModelQueryPrefix("openrouter", modelToUse), + MAX_ITEM_TOKENS, + (index, tokens) => + log.warn(`Text at index ${index} with prefix exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + ) - const allEmbeddings: number[][] = [] - const usage = { promptTokens: 0, totalTokens: 0 } - const remainingTexts = [...processedTexts] - - while (remainingTexts.length > 0) { - const currentBatch: string[] = [] - let currentBatchTokens = 0 - const processedIndices: number[] = [] - - for (let i = 0; i < remainingTexts.length; i++) { - const text = remainingTexts[i] - if (text === undefined) { - continue - } - const itemTokens = Math.ceil(text.length / 4) - - if (itemTokens > this.maxItemTokens) { - log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${this.maxItemTokens})`) - processedIndices.push(i) - continue - } - - if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { - currentBatch.push(text) - currentBatchTokens += itemTokens - processedIndices.push(i) - } else { - break - } - } - - // Remove processed items from remainingTexts (in reverse order to maintain correct indices) - for (let i = processedIndices.length - 1; i >= 0; i--) { - const idx = processedIndices[i] - if (idx === undefined) { - continue - } - remainingTexts.splice(idx, 1) - } - - if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) - allEmbeddings.push(...batchResult.embeddings) - usage.promptTokens += batchResult.usage.promptTokens - usage.totalTokens += batchResult.usage.totalTokens - } - } - - return { embeddings: allEmbeddings, usage } + return embedBatches( + processedTexts, + this.maxItemTokens, + MAX_BATCH_TOKENS, + (batch) => this._embedBatchWithRetries(batch, modelToUse), + (index, tokens) => log.warn(`Text at index ${index} exceeds token limit (${tokens} > ${this.maxItemTokens})`), + ) } /** @@ -228,34 +176,7 @@ export class OpenRouterEmbedder implements IEmbedder { throw invalid } - // Normalize base64 embeddings if OpenRouter returns them despite the float request. - const processedEmbeddings = response.data.map((item: EmbeddingItem) => { - if (typeof item.embedding === "string") { - const buffer = Buffer.from(item.embedding, "base64") - - // Create Float32Array view over the buffer - const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) - - return { - ...item, - embedding: Array.from(float32Array), - } - } - return item - }) - - // Replace the original data with processed embeddings - response.data = processedEmbeddings - - const embeddings = response.data.map((item) => item.embedding as number[]) - - return { - embeddings: embeddings, - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - totalTokens: response.usage?.total_tokens || 0, - }, - } + return projectEmbeddingResponse({ data: response.data, usage: response.usage }) } catch (error) { log.error("OpenRouter embedder batch error", { err: error instanceof Error ? error.message : String(error), @@ -371,83 +292,20 @@ export class OpenRouterEmbedder implements IEmbedder { * Waits if there's an active global rate limit */ private async waitForGlobalRateLimit(): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - let mutexReleased = false - - try { - const state = OpenRouterEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - const waitTime = state.rateLimitResetTime - Date.now() - // Silent wait - no logging to prevent flooding - release() - mutexReleased = true - await new Promise((resolve) => setTimeout(resolve, waitTime)) - return - } - - // Reset rate limit if time has passed - if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { - state.isRateLimited = false - state.consecutiveRateLimitErrors = 0 - } - } finally { - // Only release if we haven't already - if (!mutexReleased) { - release() - } - } + return waitForRateLimit(OpenRouterEmbedder.globalRateLimitState) } /** * Updates global rate limit state when a 429 error occurs */ - private async updateGlobalRateLimitState(error: HttpError): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenRouterEmbedder.globalRateLimitState - const now = Date.now() - - // Increment consecutive rate limit errors - if (now - state.lastRateLimitError < 60000) { - // Within 1 minute - state.consecutiveRateLimitErrors++ - } else { - state.consecutiveRateLimitErrors = 1 - } - - state.lastRateLimitError = now - - // Calculate exponential backoff based on consecutive errors - const baseDelay = 5000 // 5 seconds base - const maxDelay = 300000 // 5 minutes max - const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) - - // Set global rate limit - state.isRateLimited = true - state.rateLimitResetTime = now + exponentialDelay - - // Silent rate limit activation - no logging to prevent flooding - } finally { - release() - } + private async updateGlobalRateLimitState(_error: HttpError): Promise { + return updateRateLimitState(OpenRouterEmbedder.globalRateLimitState) } /** * Gets the current global rate limit delay */ private async getGlobalRateLimitDelay(): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenRouterEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - return state.rateLimitResetTime - Date.now() - } - - return 0 - } finally { - release() - } + return getRateLimitDelay(OpenRouterEmbedder.globalRateLimitState) } } diff --git a/packages/kilo-indexing/src/indexing/embedders/voyage.ts b/packages/kilo-indexing/src/indexing/embedders/voyage.ts index f1d2777b901..0e6cacff0fb 100644 --- a/packages/kilo-indexing/src/indexing/embedders/voyage.ts +++ b/packages/kilo-indexing/src/indexing/embedders/voyage.ts @@ -8,6 +8,7 @@ import { } from "../constants" import { getModelQueryPrefix } from "../model-registry" import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { embedBatches } from "../shared/embedder-helpers" import { Log } from "../../util/log" const log = Log.create({ service: "embedder-voyage" }) @@ -71,67 +72,16 @@ export class VoyageEmbedder implements IEmbedder { async createEmbeddings(texts: string[], model?: string): Promise { const modelToUse = model || this.modelId - // Apply model-specific query prefix if required - const queryPrefix = getModelQueryPrefix("voyage", modelToUse) - const processedTexts = queryPrefix - ? texts.map((text, index) => { - // Prevent double-prefixing - if (text.startsWith(queryPrefix)) { - return text - } - const prefixedText = `${queryPrefix}${text}` - const estimatedTokens = Math.ceil(prefixedText.length / 4) - if (estimatedTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) - // Return original text if adding prefix would exceed limit - return text - } - return prefixedText - }) - : texts - - const allEmbeddings: number[][] = [] - const usage = { promptTokens: 0, totalTokens: 0 } - const remainingTexts = [...processedTexts] - - while (remainingTexts.length > 0) { - const currentBatch: string[] = [] - let currentBatchTokens = 0 - const processedIndices: number[] = [] - - for (let i = 0; i < remainingTexts.length; i++) { - const text = remainingTexts[i]! - const itemTokens = Math.ceil(text.length / 4) - - if (itemTokens > MAX_ITEM_TOKENS) { - log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) - processedIndices.push(i) - continue - } - - if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { - currentBatch.push(text) - currentBatchTokens += itemTokens - processedIndices.push(i) - } else { - break - } - } - - // Remove processed items from remainingTexts (in reverse order to maintain correct indices) - for (let i = processedIndices.length - 1; i >= 0; i--) { - remainingTexts.splice(processedIndices[i]!, 1) - } - - if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) - allEmbeddings.push(...batchResult.embeddings) - usage.promptTokens += batchResult.usage.promptTokens - usage.totalTokens += batchResult.usage.totalTokens - } - } - - return { embeddings: allEmbeddings, usage } + return embedBatches( + texts, + MAX_ITEM_TOKENS, + MAX_BATCH_TOKENS, + (batch) => this._embedBatchWithRetries(batch, modelToUse), + (index, tokens) => log.warn(`Text at index ${index} exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + getModelQueryPrefix("voyage", modelToUse), + (index, tokens) => + log.warn(`Text at index ${index} with prefix exceeds token limit (${tokens} > ${MAX_ITEM_TOKENS})`), + ) } /** diff --git a/packages/kilo-indexing/src/indexing/shared/embedder-helpers.ts b/packages/kilo-indexing/src/indexing/shared/embedder-helpers.ts new file mode 100644 index 00000000000..3528e598fd3 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/embedder-helpers.ts @@ -0,0 +1,89 @@ +export function estimateTokenCount(text: string): number { + return Math.ceil(text.length / 4) +} + +export function applyQueryPrefix( + texts: string[], + prefix: string | undefined, + maxTokens: number, + onOverflow?: (index: number, tokens: number) => void, +): string[] { + if (!prefix) return texts + + return texts.map((text, index) => { + if (text.startsWith(prefix)) return text + + const prefixed = `${prefix}${text}` + const tokens = estimateTokenCount(prefixed) + if (tokens > maxTokens) { + onOverflow?.(index, tokens) + return text + } + + return prefixed + }) +} + +export function* batchTextsByTokenBudget( + texts: string[], + maxItemTokens: number, + maxBatchTokens: number, + onOversized?: (index: number, tokens: number) => void, +): Generator { + const remaining = [...texts] + + while (remaining.length > 0) { + const batch: string[] = [] + let batchTokens = 0 + const processed: number[] = [] + + for (let i = 0; i < remaining.length; i++) { + const text = remaining[i] + const tokens = estimateTokenCount(text) + + if (tokens > maxItemTokens) { + onOversized?.(i, tokens) + processed.push(i) + continue + } + + if (batchTokens + tokens <= maxBatchTokens) { + batch.push(text) + batchTokens += tokens + processed.push(i) + continue + } + + break + } + + for (let i = processed.length - 1; i >= 0; i--) { + remaining.splice(processed[i], 1) + } + + if (batch.length > 0) yield batch + } +} + +export async function embedBatches( + texts: string[], + maxItemTokens: number, + maxBatchTokens: number, + embed: (texts: string[]) => Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }>, + onOversized?: (index: number, tokens: number) => void, + prefix?: string, + onPrefixOverflow?: (index: number, tokens: number) => void, +): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + const embeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const processed = applyQueryPrefix(texts, prefix, maxItemTokens, onPrefixOverflow) + + for (const batch of batchTextsByTokenBudget(processed, maxItemTokens, maxBatchTokens, onOversized)) { + const result = await embed(batch) + embeddings.push(...result.embeddings) + usage.promptTokens += result.usage.promptTokens + usage.totalTokens += result.usage.totalTokens + } + + return { embeddings, usage } +} diff --git a/packages/kilo-indexing/src/indexing/shared/openai-compatible-helpers.ts b/packages/kilo-indexing/src/indexing/shared/openai-compatible-helpers.ts new file mode 100644 index 00000000000..447b22f04bf --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/openai-compatible-helpers.ts @@ -0,0 +1,80 @@ +import { Mutex } from "async-mutex" + +type EmbeddingItem = { embedding: string | number[] } + +type EmbeddingUsage = { + prompt_tokens?: number + total_tokens?: number +} + +export type RateLimitState = { + isRateLimited: boolean + rateLimitResetTime: number + consecutiveRateLimitErrors: number + lastRateLimitError: number + mutex: Mutex +} + +export function createRateLimitState(): RateLimitState { + return { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + mutex: new Mutex(), + } +} + +export function projectEmbeddingResponse(response: { data: EmbeddingItem[]; usage?: EmbeddingUsage }): { + embeddings: number[][] + usage: { promptTokens: number; totalTokens: number } +} { + return { + embeddings: response.data.map((item) => { + if (typeof item.embedding !== "string") return item.embedding + const buffer = Buffer.from(item.embedding, "base64") + return Array.from(new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4)) + }), + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + } +} + +export async function waitForRateLimit(state: RateLimitState): Promise { + const release = await state.mutex.acquire() + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + const wait = state.rateLimitResetTime - Date.now() + release() + await new Promise((resolve) => setTimeout(resolve, wait)) + return + } + + if (state.isRateLimited) { + state.isRateLimited = false + state.consecutiveRateLimitErrors = 0 + } + + release() +} + +export async function updateRateLimitState(state: RateLimitState): Promise { + const release = await state.mutex.acquire() + const now = Date.now() + + state.consecutiveRateLimitErrors = now - state.lastRateLimitError < 60000 ? state.consecutiveRateLimitErrors + 1 : 1 + state.lastRateLimitError = now + state.isRateLimited = true + state.rateLimitResetTime = now + Math.min(5000 * Math.pow(2, state.consecutiveRateLimitErrors - 1), 300000) + + release() +} + +export async function getRateLimitDelay(state: RateLimitState): Promise { + const release = await state.mutex.acquire() + const delay = state.isRateLimited && state.rateLimitResetTime > Date.now() ? state.rateLimitResetTime - Date.now() : 0 + release() + return delay +} diff --git a/packages/kilo-ui/src/components/code.tsx b/packages/kilo-ui/src/components/code.tsx index cbf22e2d4c5..6bf05a87f68 100644 --- a/packages/kilo-ui/src/components/code.tsx +++ b/packages/kilo-ui/src/components/code.tsx @@ -14,6 +14,7 @@ import { Portal } from "solid-js/web" import { createDefaultOptions, styleVariables } from "@opencode-ai/ui/pierre" import { getWorkerPool } from "@opencode-ai/ui/pierre/worker" import { Icon } from "@opencode-ai/ui/icon" +import { attachLineSelectionListeners, readSelectedLineRange } from "../pierre/selection" const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { @@ -568,6 +569,14 @@ export function Code(props: CodeProps) { }) }) + const text = () => { + const value = local.file.contents as unknown + if (typeof value === "string") return value + if (Array.isArray(value)) return value.join("\n") + if (value == null) return "" + return String(value) + } + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -603,14 +612,6 @@ export function Code(props: CodeProps) { } } - const text = () => { - const value = local.file.contents as unknown - if (typeof value === "string") return value - if (Array.isArray(value)) return value.join("\n") - if (value == null) return "" - return String(value) - } - const lineCount = () => { const value = text() const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0) @@ -733,42 +734,8 @@ export function Code(props: CodeProps) { const updateSelection = () => { const root = getRoot() if (!root) return - - const selection = - (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() - if (!selection || selection.isCollapsed) return - - const domRange = - ( - selection as unknown as { - getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] - } - ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? - (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) - - const startNode = domRange?.startContainer ?? selection.anchorNode - const endNode = domRange?.endContainer ?? selection.focusNode - if (!startNode || !endNode) return - - if (!root.contains(startNode) || !root.contains(endNode)) return - - const start = findLineNumber(startNode) - const end = findLineNumber(endNode) - if (start === undefined || end === undefined) return - - const startSide = findSide(startNode) - const endSide = findSide(endNode) - const side = startSide ?? endSide - - const selected: SelectedLineRange = { - start, - end, - } - - if (side) selected.side = side - if (endSide && side && endSide !== side) selected.endSide = endSide - - setSelectedLines(selected) + const selected = readSelectedLineRange(root, findLineNumber, findSide) + if (selected) setSelectedLines(selected) } const setSelectedLines = (range: SelectedLineRange | null) => { @@ -979,19 +946,14 @@ export function Code(props: CodeProps) { }) createEffect(() => { - if (props.enableLineSelection !== true) return - - container.addEventListener("mousedown", handleMouseDown) - container.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - document.addEventListener("selectionchange", handleSelectionChange) - - onCleanup(() => { - container.removeEventListener("mousedown", handleMouseDown) - container.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - document.removeEventListener("selectionchange", handleSelectionChange) - }) + onCleanup( + attachLineSelectionListeners(container, props.enableLineSelection === true, { + mousedown: handleMouseDown, + mousemove: handleMouseMove, + mouseup: handleMouseUp, + selectionchange: handleSelectionChange, + }), + ) }) onCleanup(() => { diff --git a/packages/kilo-ui/src/components/diff-ssr.tsx b/packages/kilo-ui/src/components/diff-ssr.tsx index 087bf8e0599..3fe819dadea 100644 --- a/packages/kilo-ui/src/components/diff-ssr.tsx +++ b/packages/kilo-ui/src/components/diff-ssr.tsx @@ -5,6 +5,8 @@ import { Dynamic, isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" import { acquireVirtualizer, virtualMetrics } from "@opencode-ai/ui/pierre/virtualizer" import { useWorkerPool } from "@opencode-ai/ui/context/worker-pool" +import { applyDiffCommentedLines, diffRowIndex } from "../pierre/diff-dom" +import { fixDiffSelection } from "../pierre/selection-range" export type SSRDiffProps = DiffProps & { preloadedDiff: PreloadMultiFileDiffResult @@ -54,61 +56,10 @@ export function Diff(props: SSRDiffProps) { fileDiffRef.removeAttribute("data-color-scheme") } - const lineIndex = (split: boolean, element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(split, node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) - } - } - const fixSelection = (range: SelectedLineRange | null) => { - if (!range) return range const root = getRoot() if (!root) return - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const start = rowIndex(root, split, range.start, range.side) - const end = rowIndex(root, split, range.end, range.endSide ?? range.side) - - if (start === undefined || end === undefined) { - if (root.querySelector("[data-line], [data-alt-line]") == null) return - return null - } - if (start <= end) return range - - const side = range.endSide ?? range.side - const swapped: SelectedLineRange = { - start: range.end, - end: range.start, - } - if (side) swapped.side = side - if (range.endSide && range.side) swapped.endSide = range.side - - return swapped + return fixDiffSelection(root, range, diffRowIndex) } const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => { @@ -125,100 +76,6 @@ export function Diff(props: SSRDiffProps) { diff.setSelectedLines(fixed) } - const findSide = (element: HTMLElement): "additions" | "deletions" => { - const line = element.closest("[data-line], [data-alt-line]") - if (line instanceof HTMLElement) { - const type = line.dataset.lineType - if (type === "change-deletion") return "deletions" - if (type === "change-addition" || type === "change-additions") return "additions" - } - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return "additions" - return code.hasAttribute("data-deletions") ? "deletions" : "additions" - } - - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (rows.length === 0) return - - const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - const lineIndex = (element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node) - } - } - - for (const range of ranges) { - const start = rowIndex(range.start, range.side) - if (start === undefined) continue - - const end = (() => { - const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) - if (same) return start - return rowIndex(range.end, range.endSide ?? range.side) - })() - if (end === undefined) continue - - const first = Math.min(start, end) - const last = Math.max(start, end) - - for (const row of rows) { - const idx = lineIndex(row) - if (idx === undefined) continue - if (idx < first || idx > last) continue - row.setAttribute("data-comment-selected", "") - } - - for (const annotation of annotations) { - const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(idx)) continue - if (idx < first || idx > last) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - onMount(() => { if (isServer || !props.preloadedDiff) return @@ -277,7 +134,8 @@ export function Diff(props: SSRDiffProps) { createEffect(() => { const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) + const root = getRoot() + if (root) requestAnimationFrame(() => applyDiffCommentedLines(root, ranges)) }) // Hydrate annotation slots with interactive SolidJS components diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index 941fa872021..054680a27d9 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -12,6 +12,9 @@ import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untr import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" import { acquireVirtualizer, virtualMetrics } from "@opencode-ai/ui/pierre/virtualizer" import { getWorkerPool } from "@opencode-ai/ui/pierre/worker" +import { attachLineSelectionListeners, readSelectedLineRange } from "../pierre/selection" +import { applyDiffCommentedLines, diffRowIndex } from "../pierre/diff-dom" +import { fixDiffSelection } from "../pierre/selection-range" type SelectionSide = "additions" | "deletions" @@ -335,61 +338,10 @@ export function Diff(props: DiffProps) { root.adoptedStyleSheets = [...root.adoptedStyleSheets, separatorPatchSheet] } - const lineIndex = (split: boolean, element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(split, node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) - } - } - const fixSelection = (range: SelectedLineRange | null) => { - if (!range) return range const root = getRoot() if (!root) return - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const start = rowIndex(root, split, range.start, range.side) - const end = rowIndex(root, split, range.end, range.endSide ?? range.side) - if (start === undefined || end === undefined) { - if (root.querySelector("[data-line], [data-alt-line]") == null) return - return null - } - if (start <= end) return range - - const side = range.endSide ?? range.side - const swapped: SelectedLineRange = { - start: range.end, - end: range.start, - } - - if (side) swapped.side = side - if (range.endSide && range.side) swapped.endSide = range.side - - return swapped + return fixDiffSelection(root, range, diffRowIndex) } const notifyRendered = () => { @@ -476,60 +428,6 @@ export function Diff(props: DiffProps) { observer.observe(container, { childList: true, subtree: true }) } - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (rows.length === 0) return - - const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - for (const range of ranges) { - const start = rowIndex(root, split, range.start, range.side) - if (start === undefined) continue - - const end = (() => { - const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) - if (same) return start - return rowIndex(root, split, range.end, range.endSide ?? range.side) - })() - if (end === undefined) continue - - const first = Math.min(start, end) - const last = Math.max(start, end) - - for (const row of rows) { - const idx = lineIndex(split, row) - if (idx === undefined) continue - if (idx < first || idx > last) continue - row.setAttribute("data-comment-selected", "") - } - - for (const annotation of annotations) { - const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(idx)) continue - if (idx < first || idx > last) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() if (!active) { @@ -550,42 +448,8 @@ export function Diff(props: DiffProps) { const updateSelection = () => { const root = getRoot() if (!root) return - - const selection = - (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() - if (!selection || selection.isCollapsed) return - - const domRange = - ( - selection as unknown as { - getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] - } - ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? - (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) - - const startNode = domRange?.startContainer ?? selection.anchorNode - const endNode = domRange?.endContainer ?? selection.focusNode - if (!startNode || !endNode) return - - if (!root.contains(startNode) || !root.contains(endNode)) return - - const start = findLineNumber(startNode) - const end = findLineNumber(endNode) - if (start === undefined || end === undefined) return - - const startSide = findSide(startNode) - const endSide = findSide(endNode) - const side = startSide ?? endSide - - const selected: SelectedLineRange = { - start, - end, - } - - if (side) selected.side = side - if (endSide && side && endSide !== side) selected.endSide = endSide - - setSelectedLines(selected) + const selected = readSelectedLineRange(root, findLineNumber, findSide) + if (selected) setSelectedLines(selected) } const scheduleSelectionUpdate = () => { @@ -841,7 +705,8 @@ export function Diff(props: DiffProps) { createEffect(() => { rendered() const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) + const root = getRoot() + if (root) requestAnimationFrame(() => applyDiffCommentedLines(root, ranges)) }) createEffect(() => { @@ -850,19 +715,14 @@ export function Diff(props: DiffProps) { }) createEffect(() => { - if (props.enableLineSelection !== true) return - - container.addEventListener("mousedown", handleMouseDown) - container.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - document.addEventListener("selectionchange", handleSelectionChange) - - onCleanup(() => { - container.removeEventListener("mousedown", handleMouseDown) - container.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - document.removeEventListener("selectionchange", handleSelectionChange) - }) + onCleanup( + attachLineSelectionListeners(container, props.enableLineSelection === true, { + mousedown: handleMouseDown, + mousemove: handleMouseMove, + mouseup: handleMouseUp, + selectionchange: handleSelectionChange, + }), + ) }) onCleanup(() => { diff --git a/packages/kilo-ui/src/components/prompt-input.css b/packages/kilo-ui/src/components/prompt-input.css index 0d429623dff..15c51e4d50c 100644 --- a/packages/kilo-ui/src/components/prompt-input.css +++ b/packages/kilo-ui/src/components/prompt-input.css @@ -1,7 +1,8 @@ /* Kilo Prompt Input overrides – styled to match legacy ChatTextArea look */ /* Form wrapper: VS Code native input look instead of pill shape */ -[data-component="prompt-input-form"] { +[data-component="prompt-input-form"], +.prompt-input-container { border-radius: 0.25rem; background-color: var(--input-base, var(--vscode-input-background, #3c3c3c)); border: 1px solid var(--border-weak-base, var(--vscode-input-border, #3c3c3c)); @@ -49,62 +50,63 @@ * and need to look like the legacy `SelectDropdown` triggers. */ -[data-component="prompt-input-form"] [data-slot="prompt-input-toolbar"] { - > div:first-child { - [data-component="button"] { - height: auto !important; - min-height: 22px; - padding: 4px 6px !important; - font-size: var(--kilo-font-size-12); - line-height: normal; - border-radius: 6px; - background: var(--surface-base); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: none; - opacity: 1; - gap: 6px; - white-space: nowrap; - transition: all 150ms; - color: var(--text-base, var(--vscode-foreground)); +[data-component="prompt-input-form"] [data-slot="prompt-input-toolbar"] > div:first-child, +.prompt-input-hint-selectors { + [data-component="button"] { + height: auto !important; + min-height: 22px; + padding: 4px 6px !important; + font-size: var(--kilo-font-size-12); + line-height: normal; + border-radius: 6px; + background: var(--surface-base); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: none; + opacity: 1; + gap: 6px; + white-space: nowrap; + transition: all 150ms; + color: var(--text-base, var(--vscode-foreground)); - &[data-expanded] { - background: var(--surface-base-hover) !important; - background-color: var(--surface-base-hover) !important; - } + &[data-expanded] { + background: var(--surface-base-hover) !important; + background-color: var(--surface-base-hover) !important; + } - &:hover:not(:disabled) { - background: var(--surface-base-hover); - background-color: var(--surface-base-hover); - } + &:hover:not(:disabled) { + background: var(--surface-base-hover); + background-color: var(--surface-base-hover); + } - &:focus-visible { - outline: none; - box-shadow: 0 0 0 1px var(--border-focus); - } + &:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--border-focus); } } - /* Icon buttons in toolbar (attach file, submit) keep their own styling */ - > div:nth-child(2) { - [data-component="button"] { - background: transparent; - border: none; +} - [data-slot="icon-svg"] { - color: var(--icon-base); - } +/* Icon buttons in toolbar (attach file, submit) keep their own styling */ +[data-component="prompt-input-form"] [data-slot="prompt-input-toolbar"] > div:nth-child(2), +.prompt-input-hint-actions { + [data-component="button"] { + background: transparent; + border: none; - [data-slot="progress-circle-background"] { - stroke: var(--icon-base); - } - - [data-slot="progress-circle-progress"] { - stroke: var(--border-focus, var(--vscode-focusBorder, #007fd4)); - } + [data-slot="icon-svg"] { + color: var(--icon-base); } - [data-component="icon-button"] { - [data-slot="icon-svg"] { - color: var(--icon-base) !important; - } + + [data-slot="progress-circle-background"] { + stroke: var(--icon-base); + } + + [data-slot="progress-circle-progress"] { + stroke: var(--border-focus, var(--vscode-focusBorder, #007fd4)); + } + } + [data-component="icon-button"] { + [data-slot="icon-svg"] { + color: var(--icon-base) !important; } } } diff --git a/packages/kilo-ui/src/pierre/diff-dom.ts b/packages/kilo-ui/src/pierre/diff-dom.ts new file mode 100644 index 00000000000..01dede27057 --- /dev/null +++ b/packages/kilo-ui/src/pierre/diff-dom.ts @@ -0,0 +1,93 @@ +import type { SelectedLineRange } from "@pierre/diffs" + +export type DiffSide = "additions" | "deletions" + +export function findDiffSide(element: HTMLElement): DiffSide { + const line = element.closest("[data-line], [data-alt-line]") + if (line instanceof HTMLElement) { + const type = line.dataset.lineType + if (type === "change-deletion") return "deletions" + if (type === "change-addition" || type === "change-additions") return "additions" + } + + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + return code.hasAttribute("data-deletions") ? "deletions" : "additions" +} + +export function diffLineIndex(split: boolean, element: HTMLElement): number | undefined { + const raw = element.dataset.lineIndex + if (!raw) return + const values = raw + .split(",") + .map((value) => parseInt(value, 10)) + .filter((value) => !Number.isNaN(value)) + if (values.length === 0) return + if (!split) return values[0] + if (values.length === 2) return values[1] + return values[0] +} + +export function diffRowIndex( + root: ShadowRoot, + split: boolean, + line: number, + side: DiffSide | undefined, +): number | undefined { + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (nodes.length === 0) return + + const targetSide = side ?? "additions" + for (const node of nodes) { + if (findDiffSide(node) === targetSide) return diffLineIndex(split, node) + if (parseInt(node.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, node) + } +} + +export function applyDiffCommentedLines(root: ShadowRoot, ranges: SelectedLineRange[]): void { + const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) + for (const node of existing) { + if (!(node instanceof HTMLElement)) continue + node.removeAttribute("data-comment-selected") + } + + const diffs = root.querySelector("[data-diff]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.diffType === "split" + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return + + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const range of ranges) { + const start = diffRowIndex(root, split, range.start, range.side) + if (start === undefined) continue + + const end = + range.end === range.start && (range.endSide == null || range.endSide === range.side) + ? start + : diffRowIndex(root, split, range.end, range.endSide ?? range.side) + if (end === undefined) continue + + const first = Math.min(start, end) + const last = Math.max(start, end) + for (const row of rows) { + const index = diffLineIndex(split, row) + if (index === undefined || index < first || index > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const index = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(index) || index < first || index > last) continue + annotation.setAttribute("data-comment-selected", "") + } + } +} diff --git a/packages/kilo-ui/src/pierre/selection-range.ts b/packages/kilo-ui/src/pierre/selection-range.ts new file mode 100644 index 00000000000..c4e76c49042 --- /dev/null +++ b/packages/kilo-ui/src/pierre/selection-range.ts @@ -0,0 +1,28 @@ +import type { SelectedLineRange } from "@pierre/diffs" + +type Side = "additions" | "deletions" + +export function fixDiffSelection( + root: ShadowRoot, + range: SelectedLineRange | null, + row: (root: ShadowRoot, split: boolean, line: number, side: Side | undefined) => number | undefined, +): SelectedLineRange | null | undefined { + if (!range) return range + const diffs = root.querySelector("[data-diff]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.diffType === "split" + const start = row(root, split, range.start, range.side) + const end = row(root, split, range.end, range.endSide ?? range.side) + if (start === undefined || end === undefined) { + if (root.querySelector("[data-line], [data-alt-line]") == null) return + return null + } + if (start <= end) return range + + const side = range.endSide ?? range.side + const swapped: SelectedLineRange = { start: range.end, end: range.start } + if (side) swapped.side = side + if (range.endSide && range.side) swapped.endSide = range.side + return swapped +} diff --git a/packages/kilo-ui/src/pierre/selection.ts b/packages/kilo-ui/src/pierre/selection.ts new file mode 100644 index 00000000000..867ac64600f --- /dev/null +++ b/packages/kilo-ui/src/pierre/selection.ts @@ -0,0 +1,62 @@ +import type { SelectedLineRange } from "@pierre/diffs" + +type Side = "additions" | "deletions" + +export function readSelectedLineRange( + root: ShadowRoot, + line: (node: Node | null) => number | undefined, + side: (node: Node | null) => Side | undefined, +): SelectedLineRange | undefined { + const selection = + (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() + if (!selection || selection.isCollapsed) return + + const domRange = + ( + selection as unknown as { + getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] + } + ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? + (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) + + const startNode = domRange?.startContainer ?? selection.anchorNode + const endNode = domRange?.endContainer ?? selection.focusNode + if (!startNode || !endNode) return + if (!root.contains(startNode) || !root.contains(endNode)) return + + const start = line(startNode) + const end = line(endNode) + if (start === undefined || end === undefined) return + + const startSide = side(startNode) + const endSide = side(endNode) + const selected: SelectedLineRange = { start, end } + const selectedSide = startSide ?? endSide + if (selectedSide) selected.side = selectedSide + if (endSide && selectedSide && endSide !== selectedSide) selected.endSide = endSide + return selected +} + +export function attachLineSelectionListeners( + container: HTMLElement, + enabled: boolean, + handlers: { + mousedown: (event: MouseEvent) => void + mousemove: (event: MouseEvent) => void + mouseup: () => void + selectionchange: () => void + }, +): () => void { + if (!enabled) return () => {} + + container.addEventListener("mousedown", handlers.mousedown) + container.addEventListener("mousemove", handlers.mousemove) + window.addEventListener("mouseup", handlers.mouseup) + document.addEventListener("selectionchange", handlers.selectionchange) + return () => { + container.removeEventListener("mousedown", handlers.mousedown) + container.removeEventListener("mousemove", handlers.mousemove) + window.removeEventListener("mouseup", handlers.mouseup) + document.removeEventListener("selectionchange", handlers.selectionchange) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts index fea08acbc43..cfe9289d657 100644 --- a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts +++ b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts @@ -1,384 +1,16 @@ -/** - * Event Service WebSocket client for the VS Code extension host. - * - * Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects - * to the kilo events Cloudflare Worker using a two-step ticket flow: - * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a - * single-use ticket (30 s TTL). - * 2. Open WebSocket to `/connect?ticket=` with subprotocol - * `kilo.events.v1`. - * - * Runs in Node.js (the VS Code extension host). `WebSocket` is available in - * Node 22+ without any import, matching the environment used elsewhere in - * this extension (see `src/services/cli-backend/sdk-sse-adapter.ts`). - */ - +import { EventServiceClient as SharedEventServiceClient } from "@kilocode/kilo-gateway/event-service" import type { KiloChatEventMap, KiloChatEventName } from "./types" -const WS_SUBPROTOCOL = "kilo.events.v1" -const HANDSHAKE_TIMEOUT_MS = 10_000 -const PING_INTERVAL_MS = 15_000 -const TICKET_FETCH_TIMEOUT_MS = 10_000 +export { HandshakeTimeoutError, WebSocketAuthError, WebSocketConnectError } from "@kilocode/kilo-gateway/event-service" +export type { EventHandler, EventServiceConfig } from "@kilocode/kilo-gateway/event-service" -export class WebSocketAuthError extends Error { - constructor(message = "WebSocket authentication failed") { - super(message) - this.name = "WebSocketAuthError" - } -} - -export class WebSocketConnectError extends Error { - constructor( - message: string, - public readonly code: number, - ) { - super(message) - this.name = "WebSocketConnectError" - } -} - -export class HandshakeTimeoutError extends Error { - constructor() { - super("WebSocket handshake timed out") - this.name = "HandshakeTimeoutError" - } -} - -// Close codes that signal the server rejected us for auth/policy reasons -// and reconnecting with the same token is pointless. Everything else -// (including 1006 "abnormal closure" from flaky networks) is transient. -function isAuthCloseCode(code: number): boolean { - if (code === 1008) return true // Policy Violation - if (code === 4401 || code === 4403) return true // Custom auth rejection - return false -} - -export type EventHandler = (context: string, payload: unknown) => void - -export type EventServiceConfig = { - url: string - getToken: () => Promise - onUnauthorized?: () => void -} - -/** - * The event-service base URL is configured as a WebSocket URL (`wss://…` / - * `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip - * the trailing slash and swap the protocol so `fetch()` accepts the URL. - */ -function toHttpBase(wsBase: string): string { - const trimmed = wsBase.replace(/\/$/, "") - if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) - if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) - return trimmed -} - -export class EventServiceClient { - private readonly url: string - private readonly getToken: () => Promise - private readonly onUnauthorized: (() => void) | undefined - - private ws: WebSocket | null = null - private connected = false - private destroyed = false - private reconnectAttempts = 0 - private hasConnectedBefore = false - private reconnectTimer: ReturnType | null = null - private pingTimer: ReturnType | null = null - private handshakeTimer: ReturnType | null = null - private abortHandshake: ((err: Error) => void) | null = null - - private eventHandlers = new Map>() - private activeContexts = new Set() - private reconnectHandlers = new Set<() => void>() - - constructor(config: EventServiceConfig) { - this.url = config.url - this.getToken = config.getToken - this.onUnauthorized = config.onUnauthorized - } - - async connect(): Promise { - this.destroyed = false - this.reconnectAttempts = 0 - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - try { - await this.connectOnce() - } catch (err) { - if (this.handleAuthFailure(err)) return - if (!this.destroyed) this.scheduleReconnect() - } - } - - disconnect(): void { - this.destroyed = true - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - this.clearHandshakeTimer() - if (this.abortHandshake) { - this.abortHandshake(new Error("disconnected")) - } - if (this.ws) { - this.ws.close() - this.ws = null - } - this.stopPing() - this.connected = false - } - - isConnected(): boolean { - return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN - } - - subscribe(contexts: string[]): void { - for (const ctx of contexts) this.activeContexts.add(ctx) - if (this.isConnected()) { - this.sendJson({ type: "context.subscribe", contexts }) - } - } - - unsubscribe(contexts: string[]): void { - for (const ctx of contexts) this.activeContexts.delete(ctx) - if (this.isConnected()) { - this.sendJson({ type: "context.unsubscribe", contexts }) - } - } - - on(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void { - const set = this.eventHandlers.get(event) ?? new Set() - // The raw dispatcher receives `unknown` payloads; the caller supplied a - // typed handler. We trust server payloads here — they're validated at the - // kilo-chat worker edge before broadcast. - const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N]) - set.add(wrapped) - this.eventHandlers.set(event, set) - return () => { - set.delete(wrapped) - if (set.size === 0) this.eventHandlers.delete(event) - } - } - - onReconnect(handler: () => void): () => void { - this.reconnectHandlers.add(handler) - return () => this.reconnectHandlers.delete(handler) - } - - // ── private ──────────────────────────────────────────────────────── - - private handleAuthFailure(err: unknown): boolean { - if (err instanceof WebSocketAuthError) { - this.destroyed = true - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - this.onUnauthorized?.() - return true - } - return false - } - - private async connectOnce(): Promise { - if (this.ws) { - const old = this.ws - this.ws = null - old.close() - } - - const token = await this.getToken() - const ticket = await this.fetchTicket(token) - - return new Promise((resolve, reject) => { - const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) - this.ws = ws - - let settled = false - const settleResolve = () => { - if (settled) return - settled = true - this.clearHandshakeTimer() - this.abortHandshake = null - resolve() - } - const settleReject = (err: Error) => { - if (settled) return - settled = true - this.clearHandshakeTimer() - this.abortHandshake = null - reject(err) - } - this.abortHandshake = settleReject - - this.handshakeTimer = setTimeout(() => { - this.handshakeTimer = null - if (this.ws === ws) ws.close(1000, "handshake-timeout") - settleReject(new HandshakeTimeoutError()) - }, HANDSHAKE_TIMEOUT_MS) - - ws.addEventListener("open", () => { - const isReconnect = this.hasConnectedBefore - this.connected = true - this.hasConnectedBefore = true - this.reconnectAttempts = 0 - this.resubscribeContexts() - if (isReconnect) { - for (const h of this.reconnectHandlers) h() - } - settleResolve() - this.startPing() - }) - - ws.addEventListener("message", (event: MessageEvent) => { - this.handleMessage(String(event.data)) - }) - - ws.addEventListener("close", (event: CloseEvent) => { - if (this.ws !== ws) return - const wasConnected = this.connected - this.connected = false - this.stopPing() - this.clearHandshakeTimer() - // A handshake failure always fires `close` after `error`, so we - // settle here with a classification based on the close code: - // explicit auth/policy codes → fatal; anything else → transient - // and the caller (`connect`) will schedule a reconnect. - if (!wasConnected) { - if (isAuthCloseCode(event.code)) { - settleReject(new WebSocketAuthError()) - } else { - settleReject( - new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), - ) - } - return - } - if (!this.destroyed) this.scheduleReconnect() - }) - - ws.addEventListener("error", () => { - // Swallowed: the `close` event fires right after and carries the - // close code we need to distinguish auth failures from network - // blips. Settling here loses that context. - }) - }) - } - - /** - * Mint a single-use connection ticket. The event-service issues a 30 s ticket - * scoped to the bearer JWT; the WebSocket upgrade then consumes it. We - * surface 401/403 as `WebSocketAuthError` so the caller can drop the cached - * token and prompt re-auth. - * - * `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only - * accepts `http(s)`, so we rewrite the protocol before the HTTP call. - */ - private async fetchTicket(token: string): Promise { - const ctrl = new AbortController() - const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) - try { - const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - signal: ctrl.signal, - }) - if (res.status === 401 || res.status === 403) { - throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) - } - if (!res.ok) { - throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) - } - const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null - if (!body || typeof body.ticket !== "string" || !body.ticket) { - throw new WebSocketConnectError("Malformed event-service ticket response", 0) - } - return body.ticket - } catch (err) { - if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err - if ((err as { name?: string })?.name === "AbortError") { - throw new HandshakeTimeoutError() - } - throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) - } finally { - clearTimeout(timer) - } - } - - private clearHandshakeTimer(): void { - if (this.handshakeTimer !== null) { - clearTimeout(this.handshakeTimer) - this.handshakeTimer = null - } - } - - private sendJson(msg: unknown): void { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(msg)) - } - } - - private handleMessage(data: string): void { - if (data === "pong") return - let parsed: unknown - try { - parsed = JSON.parse(data) - } catch { - return - } - if (!parsed || typeof parsed !== "object") return - const m = parsed as Record - if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { - const handlers = this.eventHandlers.get(m.event) - if (handlers) { - for (const h of handlers) h(m.context, m.payload) - } - return - } - if (m.type === "error") { - console.warn("[Kilo New] event-service server error", m) - } - } - - private startPing(): void { - this.stopPing() - this.pingTimer = setInterval(() => { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send("ping") - } - }, PING_INTERVAL_MS) - } - - private stopPing(): void { - if (this.pingTimer !== null) { - clearInterval(this.pingTimer) - this.pingTimer = null - } - } - - private resubscribeContexts(): void { - if (this.activeContexts.size > 0) { - this.sendJson({ - type: "context.subscribe", - contexts: Array.from(this.activeContexts), - }) - } - } - - private scheduleReconnect(): void { - if (this.reconnectTimer !== null) return - const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) - const delay = base * (0.5 + Math.random() * 0.5) - this.reconnectAttempts++ - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null - this.connectOnce().catch((err) => { - if (this.handleAuthFailure(err)) return - if (!this.destroyed) this.scheduleReconnect() - }) - }, delay) +export class EventServiceClient extends SharedEventServiceClient { + on( + event: N, + handler: (context: string, payload: KiloChatEventMap[N]) => void, + ): () => void + on(event: string, handler: (context: string, payload: T) => void): () => void + on(event: string, handler: (context: string, payload: unknown) => void): () => void { + return super.on(event, handler) } } diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts index 8d402dafe81..61ae12011bb 100644 --- a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -1,266 +1,7 @@ -/** - * HTTP client for the kilo-chat Cloudflare Worker. - * - * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) tailored - * to what the VS Code extension needs: conversation list + details, message - * CRUD, reactions, typing, and action execution. No zod runtime validation — - * the kilo-chat worker is the source of truth and validates at its edge. - */ +import { KiloChatClient as SharedKiloChatClient } from "@kilocode/kilo-gateway/claw" +import type { ConversationDetail } from "@kilocode/kilo-gateway/claw" -import type { - BotStatusRecord, - ContentBlock, - ConversationDetail, - ConversationListItem, - ConversationStatusRecord, - ExecApprovalDecision, - Message, -} from "./types" +export { KiloChatApiError } from "@kilocode/kilo-gateway/claw" +export type { KiloChatClientConfig } from "@kilocode/kilo-gateway/claw" -export type KiloChatClientConfig = { - baseUrl: string - getToken: () => Promise - onUnauthorized?: () => void -} - -export class KiloChatApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - ) { - super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) - this.name = "KiloChatApiError" - } -} - -function formatBodyDetail(body: unknown): string { - if (body === null || body === undefined) return "" - if (typeof body === "string") return ` - ${body}` - if (typeof body === "object") { - const err = (body as Record).error - if (typeof err === "string") return ` - ${err}` - // Fall back to a compact JSON dump so validation errors (zod issues, etc.) - // show up in the extension's Output channel without a separate logging hop. - try { - return ` - ${JSON.stringify(body)}` - } catch { - return "" - } - } - return "" -} - -type HttpOpts = { - method?: string - body?: unknown - query?: Record -} - -// Per-conversation send queues. sendMessage chains onto the tail of its -// conversation's queue so concurrent callers can't race ahead and get a lower -// server-assigned ULID than a later send. -type SendQueue = Map> - -export class KiloChatClient { - private readonly baseUrl: string - private readonly getToken: () => Promise - private readonly onUnauthorized: (() => void) | undefined - private readonly sendQueues: SendQueue = new Map() - - constructor(config: KiloChatClientConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, "") - this.getToken = config.getToken - this.onUnauthorized = config.onUnauthorized - } - - // ── Conversations ──────────────────────────────────────────────── - - listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ - conversations: ConversationListItem[] - hasMore: boolean - nextCursor: string | null - }> { - return this.request("/v1/conversations", { - query: { - sandboxId: opts?.sandboxId, - limit: opts?.limit, - cursor: opts?.cursor ?? undefined, - }, - }) - } - - createConversation(req: { - sandboxId: string - title?: string - }): Promise<{ conversationId: string; conversation?: ConversationDetail }> { - return this.request("/v1/conversations", { method: "POST", body: req }) - } - - renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { - return this.request(`/v1/conversations/${conversationId}`, { - method: "PATCH", - body: { title }, - }) - } - - async leaveConversation(conversationId: string): Promise { - // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. - await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) - } - - /** - * Mark messages up to `lastSeenMessageId` as read for the current user. - * The server enforces monotonic `lastReadAt` and returns whether the read - * pointer advanced plus whether the badge bucket was cleared. - */ - markConversationRead( - conversationId: string, - req: { lastSeenMessageId: string }, - ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { - return this.request(`/v1/conversations/${conversationId}/mark-read`, { - method: "POST", - body: req, - }) - } - - // ── Messages ───────────────────────────────────────────────────── - - sendMessage(req: { - conversationId: string - content: ContentBlock[] - inReplyToMessageId?: string - clientId?: string - }): Promise<{ messageId: string; clientId?: string; message?: Message }> { - const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() - const send = () => - this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { - method: "POST", - body: req, - }) - const next = prev.then(send, send) - this.sendQueues.set(req.conversationId, next) - const cleanup = () => { - if (this.sendQueues.get(req.conversationId) === next) { - this.sendQueues.delete(req.conversationId) - } - } - void next.then(cleanup, cleanup) - return next - } - - editMessage( - messageId: string, - req: { conversationId: string; content: ContentBlock[]; timestamp: number }, - ): Promise<{ messageId?: string; message?: Message }> { - return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) - } - - async deleteMessage(messageId: string, conversationId: string): Promise { - // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. - await this.request(`/v1/messages/${messageId}`, { - method: "DELETE", - query: { conversationId }, - }) - } - - listMessages( - conversationId: string, - opts?: { before?: string; limit?: number }, - ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { - return this.request(`/v1/conversations/${conversationId}/messages`, { - query: { before: opts?.before, limit: opts?.limit }, - }) - } - - executeAction( - conversationId: string, - messageId: string, - req: { groupId: string; value: ExecApprovalDecision }, - ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { - return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { - method: "POST", - body: req, - }) - } - - // ── Reactions ──────────────────────────────────────────────────── - - addReaction( - messageId: string, - req: { conversationId: string; emoji: string }, - ): Promise<{ id: string; operationId?: string }> { - return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) - } - - async removeReaction( - messageId: string, - req: { conversationId: string; emoji: string }, - ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { - return this.request<{ removed: boolean; id: string | null; operationId?: string }>( - `/v1/messages/${messageId}/reactions`, - { - method: "DELETE", - query: req, - }, - ) - } - - // ── Typing ─────────────────────────────────────────────────────── - - async sendTyping(conversationId: string): Promise { - await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) - } - - async sendTypingStop(conversationId: string): Promise { - await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) - } - - // ── Bot / conversation status ──────────────────────────────────── - - getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { - return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) - } - - async requestBotStatus(sandboxId: string): Promise { - await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) - } - - getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { - return this.request(`/v1/conversations/${conversationId}/conversation-status`) - } - - // ── private ────────────────────────────────────────────────────── - - private async request(path: string, opts: HttpOpts = {}): Promise { - const token = await this.getToken() - let url = `${this.baseUrl}${path}` - - if (opts.query) { - const params = new URLSearchParams() - for (const [k, v] of Object.entries(opts.query)) { - if (v === undefined || v === null) continue - params.set(k, String(v)) - } - const qs = params.toString() - if (qs) url += `?${qs}` - } - - const headers: Record = { Authorization: `Bearer ${token}` } - if (opts.body !== undefined) headers["Content-Type"] = "application/json" - - const res = await fetch(url, { - method: opts.method ?? "GET", - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }) - - if (!res.ok) { - if (res.status === 401 || res.status === 403) this.onUnauthorized?.() - const body: unknown = await res.json().catch(() => null) - throw new KiloChatApiError(res.status, body) - } - - if (res.status === 204) return undefined as unknown as T - return (await res.json()) as T - } -} +export class KiloChatClient extends SharedKiloChatClient {} diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 20b796a67d4..af50baf5c78 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -1,242 +1,52 @@ -/** - * KiloClaw VS Code extension message types. - * - * Defines the postMessage protocol between the extension host (Node.js) - * and the KiloClaw webview (SolidJS). The extension host owns all network - * connections (Kilo Chat HTTP + event-service WebSocket) and relays data - * to the webview. - * - * SYNC: Shared types are mirrored in webview-ui/kiloclaw/lib/types.ts — - * keep both in sync. - */ +export type { + ActionDeliveryFailedEvent, + ActionExecutedEvent, + ActionItem, + ActionsBlock, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationDetail, + ConversationLeftEvent, + ConversationListItem, + ConversationMember, + ConversationReadEvent, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, + KiloChatEventMap, + KiloChatEventName, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + ReactionSummary, + ReplyToSnapshot, + TextBlock, + TypingEvent, + TypingMember, + TypingStopEvent, +} from "@kilocode/kilo-gateway/claw" -// ── Instance status (KiloClaw worker) ─────────────────────────────── +import type { + BotStatusRecord, + ClawStatus, + ContentBlock, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, + TypingMember, +} from "@kilocode/kilo-gateway/claw" -export type ClawStatus = { - // `recovering` and `restoring` are transitional states the worker reports - // while bringing an instance back from an unexpected stop or a snapshot - // restore (cloud: `services/kiloclaw/src/index.ts`). - status: - | "provisioned" - | "starting" - | "restarting" - | "recovering" - | "running" - | "stopped" - | "destroying" - | "restoring" - | null - sandboxId?: string - flyRegion?: string - machineSize?: { cpus: number; memory_mb: number } - openclawVersion?: string | null - lastStartedAt?: string | null - lastStoppedAt?: string | null - channelCount?: number - secretCount?: number - userId?: string - botName?: string | null -} - -// ── Kilo Chat token envelope (gateway response) ───────────────────── - -export type ChatToken = { - token: string - expiresAt: string // ISO timestamp - kiloChatUrl: string - eventServiceUrl: string -} - -// ── Kilo Chat content blocks ──────────────────────────────────────── -// Mirrors `@kilocode/kilo-chat` schemas. See cloud/packages/kilo-chat/src/schemas.ts. - -export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" - -export type TextBlock = { type: "text"; text: string } - -export type ActionItem = { - label: string - style: "primary" | "danger" | "secondary" - value: ExecApprovalDecision -} - -export type ActionsBlock = { - type: "actions" - groupId: string - actions: ActionItem[] - resolved?: { - value: ExecApprovalDecision - resolvedBy: string - resolvedAt: number - } -} - -export type ContentBlock = TextBlock | ActionsBlock - -// ── Kilo Chat reactions ───────────────────────────────────────────── - -export type ReactionSummary = { - emoji: string - count: number - memberIds: string[] -} - -// ── Kilo Chat message ─────────────────────────────────────────────── - -export type Message = { - id: string - senderId: string - content: ContentBlock[] - inReplyToMessageId: string | null - updatedAt: number | null - clientUpdatedAt: number | null - deleted: boolean - deliveryFailed: boolean - reactions: ReactionSummary[] -} - -// ── Conversations ─────────────────────────────────────────────────── - -export type ConversationListItem = { - conversationId: string - title: string | null - lastActivityAt: number | null - lastReadAt: number | null - joinedAt: number -} - -export type ConversationMember = { id: string; kind: "user" | "bot" } - -export type ConversationDetail = { - id: string - title: string | null - createdBy: string - createdAt: number - members: ConversationMember[] -} - -// ── Bot / conversation status (telemetry) ─────────────────────────── - -export type BotStatusRecord = { - online: boolean - at: number - updatedAt: number -} - -export type ConversationStatusRecord = { - conversationId: string - contextTokens: number - contextWindow: number - model: string | null - provider: string | null - at: number - updatedAt: number -} - -// ── Typed Kilo Chat events (server → client) ─────────────────────── -// Event names mirror `@kilocode/kilo-chat/events`. - -/** - * Snapshot of the message that was replied to. Server includes this on - * `message.created` so clients can render a reply preview without a follow-up - * fetch. `deleted` mirrors the soft-deletion state at the time of replying. - */ -export type ReplyToSnapshot = { - messageId: string - senderId: string - content: ContentBlock[] - deleted?: boolean -} - -export type MessageCreatedEvent = { - messageId: string - senderId: string - content: ContentBlock[] - inReplyToMessageId: string | null - clientId?: string - replyTo?: ReplyToSnapshot | null -} - -export type MessageUpdatedEvent = { - messageId: string - content: ContentBlock[] - clientUpdatedAt: number | null -} - -export type MessageDeletedEvent = { messageId: string } -export type MessageDeliveryFailedEvent = { messageId: string } - -export type TypingEvent = { memberId: string } -export type TypingStopEvent = { memberId: string } - -export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } -export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } - -/** - * Server fans out the full conversation snapshot on `conversation.created` so - * clients can append to their list without a follow-up fetch. Older servers may - * still send only the `conversationId`, so the snapshot is optional. - */ -export type ConversationCreatedEvent = { - conversationId: string - conversation?: ConversationListItem -} -export type ConversationRenamedEvent = { conversationId: string; title: string } -export type ConversationLeftEvent = { conversationId: string } -export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } -export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } - -export type ActionExecutedEvent = { - conversationId: string - messageId: string - groupId: string - value: ExecApprovalDecision - executedBy: string -} -export type ActionDeliveryFailedEvent = { - conversationId: string - messageId: string - groupId: string -} - -export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } -export type ConversationStatusEvent = { - conversationId: string - contextTokens: number - contextWindow: number - model: string | null - provider: string | null - at: number -} - -export type KiloChatEventMap = { - "message.created": MessageCreatedEvent - "message.updated": MessageUpdatedEvent - "message.deleted": MessageDeletedEvent - "message.delivery_failed": MessageDeliveryFailedEvent - typing: TypingEvent - "typing.stop": TypingStopEvent - "reaction.added": ReactionAddedEvent - "reaction.removed": ReactionRemovedEvent - "conversation.created": ConversationCreatedEvent - "conversation.renamed": ConversationRenamedEvent - "conversation.left": ConversationLeftEvent - "conversation.read": ConversationReadEvent - "conversation.activity": ConversationActivityEvent - "action.executed": ActionExecutedEvent - "action.delivery_failed": ActionDeliveryFailedEvent - "bot.status": BotStatusEvent - "conversation.status": ConversationStatusEvent -} - -export type KiloChatEventName = keyof KiloChatEventMap - -// ── Webview ↔ extension state ─────────────────────────────────────── - -export type TypingMember = { memberId: string; at: number } - -// Full state snapshot pushed to the webview -// Every phase carries `locale` so the webview can resolve translations immediately. export type KiloClawState = | { phase: "loading"; locale: string } | { phase: "noInstance"; locale: string } @@ -258,8 +68,6 @@ export type KiloClawState = typingMembers: TypingMember[] } -// ── Messages: Webview → Extension Host ────────────────────────────── - export type KiloClawInMessage = | { type: "kiloclaw.ready" } | { type: "kiloclaw.openExternal"; url: string } @@ -290,8 +98,6 @@ export type KiloClawInMessage = | { type: "kiloclaw.sendTypingStop"; conversationId: string } | { type: "kiloclaw.markRead"; conversationId: string } -// ── Messages: Extension Host → Webview ────────────────────────────── - export type KiloClawOutMessage = | { type: "kiloclaw.state"; state: KiloClawState } | { type: "kiloclaw.status"; data: ClawStatus | null } diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 9bf00ea1aed..a3783aae171 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -22,6 +22,7 @@ Object.assign(globalThis, { MutationObserver: window.MutationObserver, IntersectionObserver: window.IntersectionObserver, ResizeObserver: window.ResizeObserver, + IntersectionObserver: window.IntersectionObserver, CustomEvent: window.CustomEvent, Event: window.Event, MessageEvent: window.MessageEvent, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 601d2c192c4..91e1ed27363 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -33,6 +33,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"), path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/ReviewDiffItem.tsx"), path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"), path.join(ROOT, "webview-ui/diff-viewer/MarkdownDiffView.tsx"), path.join(ROOT, "webview-ui/diff-viewer/VirtualDiffView.tsx"), diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 49cb6875c74..d4ac545e4c5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,85 +1,36 @@ -import { - type Component, - createSignal, - createMemo, - Show, - createEffect, - createRenderEffect, - on, - untrack, - type JSXElement, -} from "solid-js" +import { type Component, createSignal, createMemo, Show, type JSXElement } from "solid-js" import type { VirtualizerHandle } from "virtua/solid" -import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" -import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" -import { FileIcon } from "@kilocode/kilo-ui/file-icon" -import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" import type { WorktreeFileDiff } from "../src/types/messages" -import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" import { DiffStyleSelect } from "../diff-viewer/InlineSelect" -import { useVSCode } from "../src/context/vscode" -import { useServer } from "../src/context/server" -import { useProvider } from "../src/context/provider" -import { useConfig } from "../src/context/config" -import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" -import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" -import { useSpeechToTextModels } from "../src/context/speech-to-text-models" -import { - getDirectory, - getFilename, - lineCount, - sanitizeReviewComments, - type ReviewComment, -} from "../diff-viewer/review-comments" -import { - buildFileAnnotations, - buildReviewAnnotation, - clearReviewComposer, - createReviewComposer, - reviewComposerDraft, - reviewComposerEdit, - reviewDraftSpeechKey, - reviewEditSpeechKey, - sendReviewComments, - labels, - type AnnotationMeta, - type ReviewComposer, - type ReviewDraft, -} from "../diff-viewer/review-annotations" -import { createReviewAnnotationSpeechRenderer } from "../diff-viewer/review-annotation-speech" +import type { ReviewComment } from "../diff-viewer/review-comments" +import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, - initialOpenFiles, isDiffExpandable, isLargeDiffFile, - reconcileOpenFiles, sanitizeOpenFiles, - shouldVirtualizeDiff, toggleOpenFiles, } from "../diff-viewer/diff-open-policy" import { DiffEndMarker } from "../diff-viewer/DiffEndMarker" import { VirtualDiffList } from "../diff-viewer/VirtualDiffList" import { treeOrder } from "../diff-viewer/file-tree-utils" -import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView" -import { ImageDiffView } from "../diff-viewer/ImageDiffView" -import { createDiffRows, diffSizeKey } from "../diff-viewer/diff-state" +import { createDiffRows } from "../diff-viewer/diff-state" import { createDiffRequests, createDiffViewport } from "../diff-viewer/diff-requests" +import { ReviewDiffItem } from "../diff-viewer/ReviewDiffItem" +import { createReviewOpenState } from "../diff-viewer/review-state" +import { createReviewScrollPreserver } from "../diff-viewer/review-scroll" +import { createReviewController } from "../diff-viewer/review-controller" +import { keepsNativeFocus, notice, reviewFocus, reviewSendAllKeybind } from "../diff-viewer/review-setup" // --- Data model --- -/** Well-known diff source notices → i18n keys (mirrors the standalone viewer). */ -const DIFF_NOTICE_KEYS: Record = { - "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", -} - interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean @@ -114,102 +65,23 @@ interface DiffPanelProps { export const DiffPanel: Component = (props) => { const { t } = useLanguage() - const noticeText = () => { - const n = props.notice - if (!n) return "" - return t(DIFF_NOTICE_KEYS[n] ?? n) - } - const vscode = useVSCode() - const server = useServer() - const provider = useProvider() - const { config } = useConfig() - const speech = useSpeechToText(vscode, server, { t }) - const speechModels = useSpeechToTextModels() - const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) - const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) - const sendAllKeybind = () => - isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") + const noticeText = () => notice(t, props.notice) + const sendAllKeybind = () => reviewSendAllKeybind(t) const localComposer = createReviewComposer() const composer = () => props.composer ?? localComposer - const [manualOpen, setManualOpen] = createSignal>({}) - const [knownFiles, setKnownFiles] = createSignal>({}) - const open = createMemo(() => { - const key = props.sessionKey ?? "" - const diffs = props.diffs - if (diffs.length === 0) return [] - const manual = manualOpen()[key] - if (manual) return sanitizeOpenFiles(diffs, manual) - return initialOpenFiles(diffs) - }) - createEffect( - on( - () => [props.sessionKey, props.diffs] as const, - ([key, diffs]) => { - if (diffs.length === 0) return - const id = key ?? "" - const manual = manualOpen()[id] - const result = reconcileOpenFiles(diffs, manual, knownFiles()[id] ?? []) - setKnownFiles((prev) => ({ ...prev, [id]: result.known })) - if (!manual || !result.open) return - if (result.open.length === manual.length && result.open.every((file, index) => file === manual[index])) return - setManualOpen((prev) => ({ ...prev, [id]: result.open! })) - }, - ), + const reviewOpen = createReviewOpenState( + () => props.diffs, + () => props.sessionKey, ) - - const setOpen = (files: string[] | ((prev: string[]) => string[])) => { - const key = props.sessionKey ?? "" - const current = open() - const next = typeof files === "function" ? files(current) : files - setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) })) - } - const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) - const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) - const speechKeys = createMemo(() => { - const keys = new Set() - const current = draft() - const edit = editing() - if (current) keys.add(reviewDraftSpeechKey(current)) - if (edit) keys.add(reviewEditSpeechKey(edit)) - return keys - }) - const reviewSpeech = createReviewAnnotationSpeechRenderer({ - speech, - enabled: canUseSpeech, - model: speechModel, - label: t, - keys: speechKeys, - }) - let nextId = 0 - + const open = reviewOpen.open + const setOpen = reviewOpen.setOpen // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the accordion matches the tree grouping. const sorted = createMemo(() => treeOrder(props.diffs)) const rows = createDiffRows(sorted, () => props.sessionKey) - const comments = () => props.comments const setComments = (next: ReviewComment[]) => props.onCommentsChange(next) - const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments())) - - // Stable composer metadata refs avoid recreating the object on every signal read - // so pierre's annotation cache doesn't invalidate and destroy the textarea. - let draftMeta: AnnotationMeta | null = composer().draft - let editMeta: AnnotationMeta | null = composer().edit - createRenderEffect( - on( - () => props.active, - (active) => { - if (!active) return - const value = reviewComposerDraft(composer()) - const edit = reviewComposerEdit(composer()) - setDraft(value) - setEditing(edit) - draftMeta = composer().draft - editMeta = composer().edit - }, - ), - ) + const comments = () => props.comments // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs @@ -217,61 +89,35 @@ export const DiffPanel: Component = (props) => { const [scroller, setScroller] = createSignal() const [virtualizer, setVirtualizer] = createSignal() - const focusRoot = () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - rootRef?.focus() - }) - }) - } - - const keepNativeFocus = (target: EventTarget | null) => { - if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true - if (target instanceof HTMLElement && target.isContentEditable) return true - return false - } + const focusRoot = () => reviewFocus(() => rootRef) // Preserve the visible file and its intra-row offset while Pierre rebuilds a // row. Raw scrollTop is not stable once the virtualizer remeasures dynamic rows. - const preserveScroll = (fn: () => void) => { - const handle = virtualizer() - const index = handle?.findItemIndex(handle.scrollOffset) - const file = index === undefined ? undefined : rows()[index]?.file - const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0) - fn() - if (!file) return - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const next = rows().findIndex((diff) => diff.file === file) - if (next < 0) return - virtualizer()?.scrollToIndex(next, { offset }) - }) - }) - } - - const cancelDraft = () => { - preserveScroll(() => { - setDraft(null) - draftMeta = null - composer().draft = null - }) - focusRoot() - } - - createEffect( - on( - () => props.sessionKey, - () => { - if (props.active === false) return - setDraft(null) - draftMeta = null - setEditing(null) - editMeta = null - clearReviewComposer(composer()) - }, - { defer: true }, - ), - ) + const preserveScroll = createReviewScrollPreserver(rows, virtualizer) + const review = createReviewController({ + diffs: () => props.diffs, + rows, + comments: () => props.comments, + setComments, + composer, + key: () => props.sessionKey, + preserveScroll, + focus: focusRoot, + label: t, + activeTerminalId: () => props.activeTerminalId, + active: () => props.active !== false, + onSendClick: props.onSendClick, + onSendAll: props.onSendAll, + }) + const { + pinned, + commentsByFile, + annotationsForFile, + buildAnnotation, + handleGutterClick, + sendAllToChat, + sendAllClick, + } = review const request = createDiffRequests({ key: () => props.sessionKey, @@ -282,192 +128,16 @@ export const DiffPanel: Component = (props) => { eager: false, }) - // --- CRUD --- - - const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { - preserveScroll(() => { - const id = `c-${++nextId}-${Date.now()}` - updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) - setDraft(null) - draftMeta = null - composer().draft = null - }) - focusRoot() - } - - const sendComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { - const comment = { id: `c-${++nextId}-${Date.now()}`, file, side, line, comment: text, selectedText } - sendReviewComments([comment], props.activeTerminalId) - preserveScroll(() => { - setDraft(null) - draftMeta = null - composer().draft = null - }) - props.onSendClick?.() - focusRoot() - } - - const updateComment = (id: string, text: string) => { - preserveScroll(() => { - updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) - setEditing(null) - editMeta = null - composer().edit = null - }) - focusRoot() - } - - const deleteComment = (id: string) => { - preserveScroll(() => { - updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) { - setEditing(null) - editMeta = null - composer().edit = null - } - }) - focusRoot() - } - - const setEditState = (id: string | null) => { - if (editing() !== id) { - editMeta = null - composer().edit = null - } - preserveScroll(() => setEditing(id)) - if (id === null) focusRoot() - } - - createEffect( - on( - () => [props.diffs, comments()] as const, - ([diffs, current]) => { - if (props.active === false) return - const valid = sanitizeReviewComments(current, diffs) - if (valid.length !== current.length) { - setComments(valid) - } - - const edit = editing() - if (edit && !valid.some((comment) => comment.id === edit)) { - setEditing(null) - editMeta = null - composer().edit = null - } - - const currentDraft = draft() - if (!currentDraft) return - const diff = diffs.find((item) => item.file === currentDraft.file) - if (!diff) { - setDraft(null) - draftMeta = null - composer().draft = null - return - } - const content = currentDraft.side === "deletions" ? diff.before : diff.after - const max = lineCount(content) - if (currentDraft.line < 1 || currentDraft.line > max) { - setDraft(null) - draftMeta = null - composer().draft = null - return - } - if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { - setDraft(null) - draftMeta = null - composer().draft = null - } - }, - ), - ) - - // --- Per-file memoized annotations --- - - const commentsByFile = createMemo(() => { - const map = new Map() - for (const c of comments()) { - const arr = map.get(c.file) ?? [] - arr.push(c) - map.set(c.file, arr) - } - return map - }) - const pinned = createMemo(() => { - const files = new Set() - const current = draft() - if (current) files.add(current.file) - const edit = editing() - if (edit) { - const comment = comments().find((item) => item.id === edit) - if (comment) files.add(comment.file) - } - return rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : [])) - }) - - const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) - draftMeta = result.draftMeta - editMeta = result.editMeta - if (untrack(() => props.active) !== false) { - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null - } - return result.annotations - } - - const buildAnnotation = (annotation: DiffLineAnnotation): HTMLElement | undefined => { - return buildReviewAnnotation(annotation, { - diffs: props.diffs, - editing: editing(), - setEditing: setEditState, - addComment, - sendComment, - updateComment, - deleteComment, - cancelDraft, - labels: labels(t), - activeTerminalId: props.activeTerminalId, - speech: reviewSpeech, - }) - } - const handleRootMouseDown = (e: MouseEvent) => { - if (keepNativeFocus(e.target)) return + if (keepsNativeFocus(e.target)) return focusRoot() } - // --- Gutter utility click --- - const handleGutterClick = (file: string, range: SelectedLineRange) => { - // Don't open a second draft while one is active - if (draft()) return - const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" - preserveScroll(() => { - const next = { file, side, line: range.start, endLine: range.end } - draftMeta = { type: "draft", comment: null, ...next } - composer().draft = draftMeta - setDraft(next) - }) - } - - // --- Send all --- - const sendAllToChat = () => { - const all = comments() - if (all.length === 0) return - sendReviewComments(all, props.activeTerminalId) - preserveScroll(() => setComments([])) - props.onSendAll?.() - } - - const sendAllClick = () => { - props.onSendClick?.() - sendAllToChat() - } - const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Enter") return if (!(e.metaKey || e.ctrlKey)) return const target = e.target - if (keepNativeFocus(target)) return + if (keepsNativeFocus(target)) return if (comments().length === 0) return e.preventDefault() e.stopPropagation() @@ -582,199 +252,30 @@ export const DiffPanel: Component = (props) => { keep={pinned()} onReady={setVirtualizer} render={(diff) => { - const isAdded = () => diff.status === "added" - const isDeleted = () => diff.status === "deleted" - const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file) - const isLoadingDetail = () => props.loadingFiles?.has(diff.file) ?? false - const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length const viewport = createDiffViewport(scroller) - - createEffect(() => { - if (props.active === false || !viewport.visible() || !open().includes(diff.file)) return - request(diff, viewport.intersects) - }) - return ( - - - -
-
{ - e.dataTransfer?.setData(KILO_FILE_PATH_MIME, diff.file) - e.dataTransfer?.setData("text/plain", diff.file) - e.stopPropagation() - }} - > - -
- - {`\u2066${getDirectory(diff.file)}\u2069`} - - {getFilename(diff.file)} - 0}> - {fileCommentCount()} - -
-
-
- - - {t("ui.sessionReview.change.added")} - - - - - {t("ui.sessionReview.change.removed")} - - - - - {t("agentManager.review.image")} - - - {t("agentManager.review.largeFileCollapsed")} - - - untracked - - - generated - - - - { - e.stopPropagation() - props.onOpenFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onOpenDocument?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onRevertFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onMarkdownRenderChange?.(!props.markdownRender) - }} - /> - - - - - - - -
-
-
-
- - - - Diff preview loads on demand.}> - Loading diff... - - - } - > - - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - patch={diff.patch} - diffStyle={props.diffStyle ?? "unified"} - sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle ?? "unified")} - virtualized={shouldVirtualizeDiff(diff)} - visible={viewport.visible() && props.active !== false} - annotations={annotationsForFile(diff.file)} - renderAnnotation={buildAnnotation} - enableGutterUtility={true} - onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - } - > - handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - - } - > - - - - - -
+ props.active !== false} + loading={() => props.loadingFiles?.has(diff.file) ?? false} + comments={() => (commentsByFile().get(diff.file) ?? []).length} + diffStyle={() => props.diffStyle ?? "unified"} + markdownRender={() => props.markdownRender ?? false} + annotations={() => annotationsForFile(diff.file)} + renderAnnotation={buildAnnotation} + onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} + onOpenFile={props.onOpenFile} + onOpenDocument={props.onOpenDocument} + onRevertFile={props.canRevert !== false ? props.onRevertFile : undefined} + reverting={() => props.revertingFiles?.has(diff.file) ?? false} + onMarkdownRenderChange={props.onMarkdownRenderChange} + canComment={() => true} + sessionKey={props.sessionKey} + sessionReviewSlot + /> ) }} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css index 1cd57e1ad6b..002f1aed459 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css @@ -1,443 +1,11 @@ -/* Review tab + file tree + default base branch picker styles. - Extracted from the main agent-manager stylesheet to keep files under 3000 lines. */ - -/* Review tab in tab bar */ - -.am-tab-review { - display: flex; - align-items: center; - gap: 4px; - border-left: 1px solid var(--border-weak-base); -} - -.am-tab-review [data-component="icon"] { - flex-shrink: 0; - opacity: 0.7; -} - -/* Full-screen review tab layout */ - -.am-review-layout { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - min-width: 0; - --am-diff-count-size: 12px; -} - -/* Review toolbar */ - -.am-review-toolbar { - display: flex; - align-items: center; - justify-content: flex-start; - padding: 4px 8px; - flex-shrink: 0; - min-width: 0; - border-bottom: 1px solid var(--border-weak-base); - background: var(--surface-base); - gap: 8px; -} - -.am-review-toolbar-left { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; - overflow: hidden; -} - -/* Keep the radio group from being the tallest thing in the row so it matches - the 22px selector chips and the small ghost buttons. The inline scope/base - controls are styled in banners.css (non-am- prefixed, shared with the - standalone diff viewer). */ .am-review-toolbar [data-component="radio-group"] { font-size: var(--font-size-small); } -.am-review-toolbar-right { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - margin-left: auto; -} - -.am-review-toolbar-right > * { - flex-shrink: 0; -} - -.am-review-toolbar [data-component="radio-group"], -.am-review-toolbar [data-component="radio-group"] [data-slot="radio-group-item-label"], -.am-review-toolbar [data-component="radio-group"] [data-slot="radio-group-item-control"] { - cursor: pointer; -} - -.am-review-toolbar - [data-component="radio-group"] - [data-slot="radio-group-item-label"]:active - [data-slot="radio-group-item-control"] { - transform: translateY(1px); +.am-review-toolbar-left { + gap: 10px; } .am-review-toolbar-stats { - display: flex; - align-items: center; flex: 0 100 auto; - gap: 8px; - font-size: var(--font-size-small); - color: var(--text-weak); - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.am-review-toolbar-adds { - color: var(--syntax-diff-add, #318430); - font-weight: 500; -} - -.am-review-toolbar-dels { - color: var(--syntax-diff-delete, #da3319); - font-weight: 500; -} - -/* Review body: file tree + diff viewer */ - -.am-review-body { - display: flex; - flex: 1; - min-height: 0; - min-width: 0; - overflow: hidden; -} - -.am-review-host { - display: flex; - flex: 1; - min-height: 0; - min-width: 0; - overflow: hidden; -} - -.am-review-tree-resize { - position: relative; - display: flex; - flex-shrink: 0; - min-width: 0; -} - -.am-review-tree-wrapper { - flex: 1; - height: 100%; - min-width: 0; - overflow: hidden; - border-right: 1px solid var(--border-weak-base); -} - -.am-review-tree-resize > [data-component="resize-handle"]::after { - background: var(--surface-interactive-base); -} - -.am-review-diff { - flex: 1; - width: 0; - min-width: 0; - overflow-y: auto; - overflow-x: hidden; -} - -.am-review-diff-content[data-component="session-review"] { - height: auto; - contain: none; - scrollbar-width: auto; - width: 100%; - min-width: 0; -} - -.am-review-diff-content[data-component="session-review"] [data-component="sticky-accordion-header"] { - --sticky-accordion-top: 0px; -} - -/* Ensure the diff container fills the available width. - pierre's diffs-container needs explicit width for split mode. */ -.am-review-diff diffs-container, -.am-review-diff [data-component="diff"] { - width: 100%; - display: block; -} - -/* Tighter diff line height in review mode */ -.am-review-layout { - --diffs-line-height: var(--kilo-font-size-20); -} - -/* ─── File tree (GitHub-style) ─── */ - -.am-file-tree { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - font-size: var(--kilo-font-size-13); - font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, sans-serif); -} - -.am-file-tree-list { - flex: 1; - overflow-y: auto; - padding: 4px 6px; -} - -/* Shared row base for files and directories */ -.am-file-tree-dir, -.am-file-tree-file { - display: flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 0 8px; - border: none; - background: none; - color: var(--text-base); - font-size: inherit; - font-family: inherit; - cursor: pointer; - width: 100%; - text-align: left; - white-space: nowrap; - border-radius: 5px; - position: relative; -} - -.am-file-tree-dir:hover, -.am-file-tree-file:hover { - background: var(--surface-inset-base-hover, rgba(128, 128, 128, 0.1)); -} - -/* Directory-specific: slightly muted text */ -.am-file-tree-dir { - color: var(--text-weak); -} - -/* Shrink icons to 16px */ -.am-file-tree [data-component="file-icon"], -.am-file-tree [data-component="icon"] { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -/* Directory chevron + folder icon color */ -.am-file-tree-dir [data-component="icon"] { - flex-shrink: 0; - color: var(--text-weaker); -} - -.am-file-tree-dir-highlight > [data-component="icon"]:last-of-type { - color: var(--surface-interactive-strong); -} - -/* Active file — subtle background + left accent bar */ -.am-file-tree-active { - background: var(--surface-base-active, rgba(128, 128, 128, 0.15)); -} - -.am-file-tree-active::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 18px; - border-radius: 3px; - background: var(--surface-interactive-strong, var(--vscode-focusBorder, #007fd4)); -} - -.am-file-tree-active:hover { - background: var(--surface-base-active, rgba(128, 128, 128, 0.15)); -} - -.am-file-tree-name { - overflow: hidden; - text-overflow: ellipsis; - flex: 1; - min-width: 0; -} - -/* Color the name for added / deleted files */ -.am-file-tree-status-added .am-file-tree-name { - color: var(--syntax-diff-add, #318430); -} - -.am-file-tree-status-deleted .am-file-tree-name { - color: var(--syntax-diff-delete, #da3319); -} - -.am-file-tree-status-modified .am-file-tree-name { - color: var(--text-base); -} - -.am-file-tree-check { - width: 14px; - height: 14px; - border: 1px solid var(--border-weak-base); - border-radius: 3px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: transparent; - background: var(--surface-base); -} - -.am-file-tree-check-on { - border-color: var(--surface-interactive-strong); - background: color-mix(in oklab, var(--surface-interactive-strong) 18%, var(--surface-base)); - color: var(--surface-interactive-strong); -} - -.am-file-tree-check [data-component="icon"] { - width: 10px; - height: 10px; -} - -.am-file-tree-selected { - background: color-mix(in oklab, var(--surface-base-active, rgba(128, 128, 128, 0.15)) 72%, transparent); -} - -/* Compact +N -N stats pushed to the right */ -.am-file-tree-changes { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; - font-size: var(--am-diff-count-size); - font-family: var(--vscode-editor-font-family, monospace); - line-height: 1; - color: var(--text-weaker); -} - -.am-file-tree-stat-add { - color: var(--syntax-diff-add, #318430); -} - -.am-file-tree-stat-del { - color: var(--syntax-diff-delete, #da3319); -} - -/* Status badges for added / deleted files */ -.am-file-tree-badge-added { - font-size: var(--am-diff-count-size); - font-weight: 600; - padding: 1px 5px; - border-radius: 4px; - background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent); - color: var(--syntax-diff-add, #318430); -} - -.am-file-tree-comment-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 8px; - background: var(--surface-interactive-strong); - color: var(--text-on-fill); - font-size: var(--kilo-font-size-10); - font-weight: 600; - line-height: 1; - margin-left: 6px; - flex-shrink: 0; -} - -.am-file-tree-badge-deleted { - font-size: var(--am-diff-count-size); - font-weight: 600; - padding: 1px 5px; - border-radius: 4px; - background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent); - color: var(--syntax-diff-delete, #da3319); -} - -/* File tree revert button — always visible */ - -.am-file-tree-file-content { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; - border: none; - background: none; - color: inherit; - font-size: inherit; - font-family: inherit; - cursor: pointer; - padding: 0; - text-align: left; - white-space: nowrap; -} - -.am-file-tree-revert-btn { - flex-shrink: 0; - color: var(--text-weaker); -} - -.am-file-tree-revert-btn:not(:disabled):hover { - color: var(--syntax-diff-delete, #da3319); -} - -/* Diff header revert button — always visible */ - -.am-diff-revert-btn { - color: var(--text-weaker); -} - -.am-diff-revert-btn:not(:disabled):hover { - color: var(--syntax-diff-delete, #da3319); -} - -/* File tree summary footer */ - -.am-file-tree-summary { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - border-top: 1px solid var(--border-weak-base); - font-size: var(--kilo-font-size-11); - color: var(--text-weaker); - flex-shrink: 0; -} - -.am-file-tree-summary-adds { - color: var(--syntax-diff-add, #318430); -} - -.am-file-tree-summary-dels { - color: var(--syntax-diff-delete, #da3319); -} - -/* ============ Default base branch picker dialog ============ */ - -.am-default-base-branch { - min-width: 360px; -} - -.am-default-base-branch .am-dropdown-list { - max-height: 320px; -} - -.am-branch-hint { - margin-left: auto; - color: var(--text-weaker); - font-size: var(--kilo-font-size-11); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index fae341978dd..43062ac434b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4529,6 +4529,40 @@ body.am-wt-dragging-active * { color: var(--syntax-diff-delete, #da3319); } +.am-file-tree-file-content { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + border: none; + background: none; + color: inherit; + font-size: inherit; + font-family: inherit; + cursor: pointer; + padding: 0; + text-align: left; + white-space: nowrap; +} + +.am-file-tree-revert-btn { + flex-shrink: 0; + color: var(--text-weaker); +} + +.am-file-tree-revert-btn:not(:disabled):hover { + color: var(--syntax-diff-delete, #da3319); +} + +.am-diff-revert-btn { + color: var(--text-weaker); +} + +.am-diff-revert-btn:not(:disabled):hover { + color: var(--syntax-diff-delete, #da3319); +} + /* File tree summary footer */ .am-file-tree-summary { diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index bb761dfa452..b5437f663dc 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -5,73 +5,40 @@ import type { VirtualizerHandle } from "virtua/solid" // see tests/unit/diff-viewer-css-arch.test.ts for the invariant. import "../agent-manager/agent-manager.css" import "../agent-manager/agent-manager-review.css" -import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" -import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" -import { FileIcon } from "@kilocode/kilo-ui/file-icon" -import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { RadioGroup } from "@kilocode/kilo-ui/radio-group" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" -import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { WorktreeFileDiff } from "../src/types/messages" -import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" -import { useVSCode } from "../src/context/vscode" -import { useServer } from "../src/context/server" -import { useProvider } from "../src/context/provider" -import { useConfig } from "../src/context/config" -import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" -import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" -import { useSpeechToTextModels } from "../src/context/speech-to-text-models" import { FileTree } from "./FileTree" import { treeOrder } from "./file-tree-utils" -import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" -import { - buildFileAnnotations, - buildReviewAnnotation, - clearReviewComposer, - createReviewComposer, - reviewComposerDraft, - reviewComposerEdit, - reviewDraftSpeechKey, - reviewEditSpeechKey, - sendReviewComments, - labels, - type AnnotationMeta, - type ReviewComposer, - type ReviewDraft, -} from "./review-annotations" -import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" +import type { ReviewComment } from "./review-comments" +import { createReviewComposer, type ReviewComposer } from "./review-annotations" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, - initialOpenFiles, isDiffExpandable, isLargeDiffFile, - reconcileOpenFiles, sanitizeOpenFiles, - shouldVirtualizeDiff, toggleOpenFiles, } from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" import { VirtualDiffList } from "./VirtualDiffList" -import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { ImageDiffView } from "./ImageDiffView" -import { createDiffRows, diffSizeKey } from "./diff-state" +import { createDiffRows } from "./diff-state" import { createDiffRequests, createDiffViewport } from "./diff-requests" +import { ReviewDiffItem } from "./ReviewDiffItem" +import { createReviewOpenState } from "./review-state" +import { createReviewScrollPreserver } from "./review-scroll" +import { createReviewController } from "./review-controller" +import { keepsNativeFocus, notice, reviewFocus, reviewSendAllKeybind } from "./review-setup" type DiffStyle = "unified" | "split" -/** Well-known diff source notices → i18n keys (mirrors the standalone viewer). */ -const DIFF_NOTICE_KEYS: Record = { - "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", -} - interface FullScreenDiffViewProps { diffs: WorktreeFileDiff[] loading: boolean @@ -106,55 +73,16 @@ interface FullScreenDiffViewProps { export const FullScreenDiffView: Component = (props) => { const { t } = useLanguage() - const noticeText = () => { - const n = props.notice - if (!n) return "" - return t(DIFF_NOTICE_KEYS[n] ?? n) - } - const vscode = useVSCode() - const server = useServer() - const provider = useProvider() - const { config } = useConfig() - const speech = useSpeechToText(vscode, server, { t }) - const speechModels = useSpeechToTextModels() - const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) - const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) - const sendAllKeybind = () => - isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") + const noticeText = () => notice(t, props.notice) + const sendAllKeybind = () => reviewSendAllKeybind(t) const localComposer = createReviewComposer() const composer = () => props.composer ?? localComposer - const [manualOpen, setManualOpen] = createSignal>({}) - const [knownFiles, setKnownFiles] = createSignal>({}) - const open = createMemo(() => { - const key = props.sessionKey ?? "" - const diffs = props.diffs - if (diffs.length === 0) return [] - const manual = manualOpen()[key] - if (manual) return sanitizeOpenFiles(diffs, manual) - return initialOpenFiles(diffs) - }) - createEffect( - on( - () => [props.sessionKey, props.diffs] as const, - ([key, diffs]) => { - if (diffs.length === 0) return - const id = key ?? "" - const manual = manualOpen()[id] - const result = reconcileOpenFiles(diffs, manual, knownFiles()[id] ?? []) - setKnownFiles((prev) => ({ ...prev, [id]: result.known })) - if (!manual || !result.open) return - if (result.open.length === manual.length && result.open.every((file, index) => file === manual[index])) return - setManualOpen((prev) => ({ ...prev, [id]: result.open! })) - }, - ), + const reviewOpen = createReviewOpenState( + () => props.diffs, + () => props.sessionKey, ) - const setOpen = (files: string[] | ((prev: string[]) => string[])) => { - const key = props.sessionKey ?? "" - const current = open() - const next = typeof files === "function" ? files(current) : files - setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) })) - } + const open = reviewOpen.open + const setOpen = reviewOpen.setOpen const [manualActiveFile, setManualActiveFile] = createSignal>({}) const activeFile = createMemo(() => { @@ -170,27 +98,7 @@ export const FullScreenDiffView: Component = (props) => setManualActiveFile((prev) => ({ ...prev, [key]: file })) } - const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) - const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) - const speechKeys = createMemo(() => { - const keys = new Set() - const current = draft() - const edit = editing() - if (current) keys.add(reviewDraftSpeechKey(current)) - if (edit) keys.add(reviewEditSpeechKey(edit)) - return keys - }) - const reviewSpeech = createReviewAnnotationSpeechRenderer({ - speech, - enabled: canUseSpeech, - model: speechModel, - label: t, - keys: speechKeys, - }) const [treeWidth, setTreeWidth] = createSignal(240) - let nextId = 0 - let draftMeta: AnnotationMeta | null = composer().draft - let editMeta: AnnotationMeta | null = composer().edit let initialFileKey: string | undefined let rootRef: HTMLDivElement | undefined const [scroller, setScroller] = createSignal() @@ -202,48 +110,37 @@ export const FullScreenDiffView: Component = (props) => const sorted = createMemo(() => treeOrder(props.diffs)) const rows = createDiffRows(sorted, () => props.sessionKey) - const comments = () => props.comments const setComments = (next: ReviewComment[]) => props.onCommentsChange(next) - const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments())) + const comments = () => props.comments - const focusRoot = () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - rootRef?.focus() - }) - }) - } + const focusRoot = () => reviewFocus(() => rootRef) - const keepNativeFocus = (target: EventTarget | null) => { - if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true - if (target instanceof HTMLElement && target.isContentEditable) return true - return false - } + const preserveScroll = createReviewScrollPreserver(rows, virtualizer) - const preserveScroll = (fn: () => void) => { - const handle = virtualizer() - const index = handle?.findItemIndex(handle.scrollOffset) - const file = index === undefined ? undefined : rows()[index]?.file - const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0) - fn() - if (!file) return - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const next = rows().findIndex((diff) => diff.file === file) - if (next < 0) return - virtualizer()?.scrollToIndex(next, { offset }) - }) - }) - } - - const cancelDraft = () => { - preserveScroll(() => { - setDraft(null) - draftMeta = null - composer().draft = null - }) - focusRoot() - } + const review = createReviewController({ + diffs: () => props.diffs, + rows, + comments: () => props.comments, + setComments, + composer, + key: () => props.sessionKey, + preserveScroll, + focus: focusRoot, + label: t, + activeTerminalId: () => props.activeTerminalId, + canComment: () => props.canComment !== false, + onSendClick: props.onSendClick, + onSendAll: props.onSendAll, + }) + const { + pinned, + commentsByFile, + annotationsForFile, + buildAnnotation, + handleGutterClick, + sendAllToChat, + sendAllClick, + } = review createEffect( on( @@ -257,20 +154,6 @@ export const FullScreenDiffView: Component = (props) => }, ), ) - createEffect( - on( - () => props.sessionKey, - () => { - setDraft(null) - draftMeta = null - setEditing(null) - editMeta = null - clearReviewComposer(composer()) - }, - { defer: true }, - ), - ) - const request = createDiffRequests({ key: () => props.sessionKey, diffs: () => props.diffs, @@ -280,187 +163,16 @@ export const FullScreenDiffView: Component = (props) => eager: false, }) - // --- CRUD --- - - const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { - preserveScroll(() => { - const id = `c-${++nextId}-${Date.now()}` - updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) - setDraft(null) - draftMeta = null - composer().draft = null - }) - focusRoot() - } - - const sendComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { - const comment = { id: `c-${++nextId}-${Date.now()}`, file, side, line, comment: text, selectedText } - sendReviewComments([comment], props.activeTerminalId) - preserveScroll(() => { - setDraft(null) - draftMeta = null - composer().draft = null - }) - props.onSendClick?.() - focusRoot() - } - - const updateComment = (id: string, text: string) => { - preserveScroll(() => { - updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) - setEditing(null) - editMeta = null - composer().edit = null - }) - focusRoot() - } - - const deleteComment = (id: string) => { - preserveScroll(() => { - updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) { - setEditing(null) - editMeta = null - composer().edit = null - } - }) - focusRoot() - } - - const setEditState = (id: string | null) => { - if (editing() !== id) { - editMeta = null - composer().edit = null - } - preserveScroll(() => setEditing(id)) - if (id === null) focusRoot() - } - const handleRootMouseDown = (e: MouseEvent) => { - if (keepNativeFocus(e.target)) return + if (keepsNativeFocus(e.target)) return focusRoot() } - createEffect( - on( - () => [props.diffs, comments()] as const, - ([diffs, current]) => { - const valid = sanitizeReviewComments(current, diffs) - if (valid.length !== current.length) { - setComments(valid) - } - - const edit = editing() - if (edit && !valid.some((comment) => comment.id === edit)) { - setEditing(null) - editMeta = null - composer().edit = null - } - - const currentDraft = draft() - if (!currentDraft) return - const diff = diffs.find((item) => item.file === currentDraft.file) - if (!diff) { - setDraft(null) - draftMeta = null - composer().draft = null - return - } - const content = currentDraft.side === "deletions" ? diff.before : diff.after - const max = lineCount(content) - if (currentDraft.line < 1 || currentDraft.line > max) { - setDraft(null) - draftMeta = null - composer().draft = null - return - } - if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { - setDraft(null) - draftMeta = null - composer().draft = null - } - }, - ), - ) - - // --- Per-file memoized annotations --- - - const commentsByFile = createMemo(() => { - const map = new Map() - for (const c of comments()) { - const arr = map.get(c.file) ?? [] - arr.push(c) - map.set(c.file, arr) - } - return map - }) - const pinned = createMemo(() => { - const files = new Set() - const current = draft() - if (current) files.add(current.file) - const edit = editing() - if (edit) { - const comment = comments().find((item) => item.id === edit) - if (comment) files.add(comment.file) - } - return rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : [])) - }) - - const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) - draftMeta = result.draftMeta - editMeta = result.editMeta - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null - return result.annotations - } - - const buildAnnotation = (annotation: DiffLineAnnotation): HTMLElement | undefined => { - return buildReviewAnnotation(annotation, { - diffs: props.diffs, - editing: editing(), - setEditing: setEditState, - addComment, - sendComment, - updateComment, - deleteComment, - cancelDraft, - labels: labels(t), - activeTerminalId: props.activeTerminalId, - speech: reviewSpeech, - }) - } - - const handleGutterClick = (file: string, range: SelectedLineRange) => { - if (props.canComment === false) return - if (draft()) return - const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" - preserveScroll(() => { - const next = { file, side, line: range.start, endLine: range.end } - draftMeta = { type: "draft", comment: null, ...next } - composer().draft = draftMeta - setDraft(next) - }) - } - - const sendAllToChat = () => { - const all = comments() - if (all.length === 0) return - sendReviewComments(all, props.activeTerminalId) - preserveScroll(() => setComments([])) - props.onSendAll?.() - } - - const sendAllClick = () => { - props.onSendClick?.() - sendAllToChat() - } - const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Enter") return if (!(e.metaKey || e.ctrlKey)) return const target = e.target - if (keepNativeFocus(target)) return + if (keepsNativeFocus(target)) return if (props.canComment === false) return if (comments().length === 0) return e.preventDefault() @@ -653,183 +365,28 @@ export const FullScreenDiffView: Component = (props) => keep={pinned()} onReady={setVirtualizer} render={(diff) => { - const isAdded = () => diff.status === "added" - const isDeleted = () => diff.status === "deleted" - const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file) - const isLoadingDetail = () => props.loadingFiles?.has(diff.file) ?? false - const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length const viewport = createDiffViewport(scroller) - - createEffect(() => { - if (!viewport.visible() || !open().includes(diff.file)) return - request(diff, viewport.intersects) - }) - return ( - - - -
-
{ - e.dataTransfer?.setData(KILO_FILE_PATH_MIME, diff.file) - e.dataTransfer?.setData("text/plain", diff.file) - e.stopPropagation() - }} - > - -
- - {`\u2066${getDirectory(diff.file)}\u2069`} - - {getFilename(diff.file)} - 0}> - {fileCommentCount()} - -
-
-
- - - {t("ui.sessionReview.change.added")} - - - - - {t("ui.sessionReview.change.removed")} - - - - - {t("agentManager.review.image")} - - - {t("agentManager.review.largeFileCollapsed")} - - - untracked - - - generated - - - - { - e.stopPropagation() - props.onOpenFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onRevertFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onMarkdownRenderChange?.(!props.markdownRender) - }} - /> - - - - - - - -
-
-
-
- - - - Diff preview loads on demand.}> - <> - - Loading diff... - - - - } - > - - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - patch={diff.patch} - diffStyle={props.diffStyle} - sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle)} - virtualized={shouldVirtualizeDiff(diff)} - visible={viewport.visible()} - annotations={annotationsForFile(diff.file)} - renderAnnotation={buildAnnotation} - enableGutterUtility={props.canComment !== false} - onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - } - > - handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - - } - > - - - - - -
+ props.loadingFiles?.has(diff.file) ?? false} + comments={() => (commentsByFile().get(diff.file) ?? []).length} + diffStyle={() => props.diffStyle} + markdownRender={() => props.markdownRender ?? false} + annotations={() => annotationsForFile(diff.file)} + renderAnnotation={buildAnnotation} + onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} + onOpenFile={props.onOpenFile} + onRevertFile={props.canRevert !== false ? props.onRevertFile : undefined} + reverting={() => props.revertingFiles?.has(diff.file) ?? false} + onMarkdownRenderChange={props.onMarkdownRenderChange} + canComment={() => props.canComment !== false} + sessionKey={props.sessionKey} + showLoadingSpinner + /> ) }} /> diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/ReviewDiffItem.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/ReviewDiffItem.tsx new file mode 100644 index 00000000000..2d2b162b801 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/ReviewDiffItem.tsx @@ -0,0 +1,250 @@ +import { createEffect, type Accessor, type Component, Show } from "solid-js" +import { Accordion } from "@kilocode/kilo-ui/accordion" +import { Diff } from "@kilocode/kilo-ui/diff" +import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" +import { FileIcon } from "@kilocode/kilo-ui/file-icon" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs" +import type { WorktreeFileDiff } from "../src/types/messages" +import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" +import { useLanguage } from "../src/context/language" +import { diffSizeKey } from "./diff-state" +import type { DiffViewport } from "./diff-requests" +import { isDiffExpandable, isLargeDiffFile, shouldVirtualizeDiff } from "./diff-open-policy" +import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" +import { ImageDiffView } from "./ImageDiffView" +import type { AnnotationMeta } from "./review-annotations" + +type Props = { + diff: WorktreeFileDiff + open: Accessor + viewport: DiffViewport + request: (diff: WorktreeFileDiff, visible?: () => boolean) => void + active?: Accessor + loading: Accessor + comments: Accessor + diffStyle: Accessor<"unified" | "split"> + markdownRender: Accessor + annotations: () => DiffLineAnnotation[] + renderAnnotation: (annotation: DiffLineAnnotation) => HTMLElement | undefined + onGutterUtilityClick: (range: SelectedLineRange) => void + onOpenFile?: (file: string, line?: number) => void + onOpenDocument?: (file: string) => void + onRevertFile?: (file: string) => void + reverting: Accessor + onMarkdownRenderChange?: (render: boolean) => void + canComment: Accessor + sessionKey?: string + sessionReviewSlot?: boolean + showLoadingSpinner?: boolean +} + +export const ReviewDiffItem: Component = (props) => { + const { t } = useLanguage() + const isAdded = () => props.diff.status === "added" + const isDeleted = () => props.diff.status === "deleted" + const isLargeCollapsed = () => isLargeDiffFile(props.diff) && !props.open().includes(props.diff.file) + const active = () => props.active?.() ?? true + + createEffect(() => { + if (!props.viewport.visible() || !props.open().includes(props.diff.file) || !active()) return + props.request(props.diff, props.viewport.intersects) + }) + + return ( + + + +
+
{ + event.dataTransfer?.setData(KILO_FILE_PATH_MIME, props.diff.file) + event.dataTransfer?.setData("text/plain", props.diff.file) + event.stopPropagation() + }} + > + +
+ + {`\u2066${getDirectory(props.diff.file)}\u2069`} + + {getFilename(props.diff.file)} + 0}> + {props.comments()} + +
+
+
+ + + {t("ui.sessionReview.change.added")} + + + + + {t("ui.sessionReview.change.removed")} + + + + + {t("agentManager.review.image")} + + + {t("agentManager.review.largeFileCollapsed")} + + + untracked + + + generated + + + + { + event.stopPropagation() + props.onOpenFile?.(props.diff.file) + }} + /> + + + + + { + event.stopPropagation() + props.onOpenDocument?.(props.diff.file) + }} + /> + + + + + { + event.stopPropagation() + props.onRevertFile?.(props.diff.file) + }} + /> + + + + + { + event.stopPropagation() + props.onMarkdownRenderChange?.(!props.markdownRender()) + }} + /> + + + + + + + +
+
+
+
+ + + + Diff preview loads on demand.}> + + + + Loading diff... + + + } + > + + before={{ name: props.diff.file, contents: props.diff.before }} + after={{ name: props.diff.file, contents: props.diff.after }} + patch={props.diff.patch} + diffStyle={props.diffStyle()} + sizeKey={diffSizeKey(props.sessionKey, props.diff, props.diffStyle())} + virtualized={shouldVirtualizeDiff(props.diff)} + visible={props.viewport.visible() && active()} + annotations={props.annotations()} + renderAnnotation={props.renderAnnotation} + enableGutterUtility={props.canComment()} + onGutterUtilityClick={props.onGutterUtilityClick} + onLineNumberClick={(event) => { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(props.diff.file, event.lineNumber) + }} + /> + } + > + { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(props.diff.file, event.lineNumber) + }} + /> + + } + > + + + + + +
+ ) +} + +function getDirectory(path: string): string { + const index = path.lastIndexOf("/") + return index === -1 ? "" : path.slice(0, index + 1) +} + +function getFilename(path: string): string { + const index = path.lastIndexOf("/") + return index === -1 ? path : path.slice(index + 1) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index ed0a76f52e5..d25eef084e1 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -69,6 +69,8 @@ export function createDiffViewport(root: Accessor) { return { ref: (node: Element) => setElement(node), visible, intersects } } +export type DiffViewport = ReturnType + export function createDiffRequests(opts: DiffRequestOptions) { const requested = new Map() let active = false diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts index e5b3e90de90..63494230338 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts @@ -1,4 +1,5 @@ import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs" +import type { UiI18nParams } from "@kilocode/kilo-ui/context" import type { WorktreeFileDiff } from "../src/types/messages" import { extractLines, type ReviewComment } from "./review-comments" import type { ReviewCommentEntry } from "../src/types/messages" @@ -16,7 +17,7 @@ export interface AnnotationLabels { delete: string } -export function labels(t: (key: string, params?: Record) => string): AnnotationLabels { +export function labels(t: (key: string, params?: UiI18nParams) => string): AnnotationLabels { return { commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }), editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }), @@ -98,7 +99,7 @@ interface AnnotationHandlers { deleteComment: (id: string) => void cancelDraft: () => void labels: AnnotationLabels - activeTerminalId?: string + activeTerminalId: () => string | undefined speech?: { active: () => boolean render: (meta: AnnotationMeta, textarea: HTMLTextAreaElement) => HTMLElement | undefined @@ -443,7 +444,7 @@ export function buildReviewAnnotation( actions.appendChild( makeActionButton(handlers.labels.sendToChat, makeIcon("M1 1l14 7-14 7V9l10-1L1 7z"), () => { - sendReviewComments([comment], handlers.activeTerminalId) + sendReviewComments([comment], handlers.activeTerminalId()) handlers.deleteComment(comment.id) }), ) diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts new file mode 100644 index 00000000000..38099d88a13 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts @@ -0,0 +1,279 @@ +import { createEffect, createMemo, createRenderEffect, createSignal, on, untrack, type Accessor } from "solid-js" +import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" +import type { UiI18nParams } from "@kilocode/kilo-ui/context" +import type { WorktreeFileDiff } from "../src/types/messages" +import { lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" +import { + buildFileAnnotations, + buildReviewAnnotation, + clearReviewComposer, + reviewComposerDraft, + reviewComposerEdit, + reviewDraftSpeechKey, + reviewEditSpeechKey, + sendReviewComments, + labels, + type AnnotationMeta, + type ReviewComposer, +} from "./review-annotations" +import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" +import { createReviewSpeech } from "./review-setup" + +type Props = { + diffs: Accessor + rows: Accessor + comments: Accessor + setComments: (comments: ReviewComment[]) => void + composer: () => ReviewComposer + key: Accessor + preserveScroll: (run: () => void) => void + focus: () => void + label: (key: string, params?: UiI18nParams) => string + activeTerminalId: Accessor + active?: Accessor + canComment?: Accessor + onSendClick?: () => void + onSendAll?: () => void +} + +export function createReviewController(props: Props) { + const active = props.active ?? (() => true) + const canComment = props.canComment ?? (() => true) + const [draft, setDraft] = createSignal(reviewComposerDraft(props.composer())) + const [editing, setEditing] = createSignal(reviewComposerEdit(props.composer())) + const [speechKeys, setSpeechKeys] = createSignal(new Set()) + const voice = createReviewSpeech(props.label) + const speech = createReviewAnnotationSpeechRenderer({ + speech: voice.speech, + enabled: voice.enabled, + model: voice.model, + label: props.label, + keys: speechKeys, + }) + let nextId = 0 + let draftMeta: AnnotationMeta | null = props.composer().draft + let editMeta: AnnotationMeta | null = props.composer().edit + + createEffect( + on( + () => [draft(), editing()] as const, + ([current, edit]) => { + const keys = new Set() + if (current) keys.add(reviewDraftSpeechKey(current)) + if (edit) keys.add(reviewEditSpeechKey(edit)) + setSpeechKeys(keys) + }, + ), + ) + + createRenderEffect( + on(active, (value) => { + if (!value) return + const current = reviewComposerDraft(props.composer()) + const edit = reviewComposerEdit(props.composer()) + setDraft(current) + setEditing(edit) + draftMeta = props.composer().draft + editMeta = props.composer().edit + }), + ) + + createEffect( + on( + props.key, + () => { + if (!active()) return + setDraft(null) + draftMeta = null + setEditing(null) + editMeta = null + clearReviewComposer(props.composer()) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [props.diffs(), props.comments()] as const, + ([diffs, current]) => { + if (!active()) return + const valid = sanitizeReviewComments(current, diffs) + if (valid.length !== current.length) props.setComments(valid) + + const edit = editing() + if (edit && !valid.some((comment) => comment.id === edit)) { + setEditing(null) + editMeta = null + props.composer().edit = null + } + + const currentDraft = draft() + if (!currentDraft) return + const diff = diffs.find((item) => item.file === currentDraft.file) + if (!diff) return cancelDraft() + const max = lineCount(currentDraft.side === "deletions" ? diff.before : diff.after) + if ( + currentDraft.line < 1 || + currentDraft.line > max || + (currentDraft.endLine !== undefined && currentDraft.endLine > max) + ) { + cancelDraft() + } + }, + ), + ) + + const commentsByFile = createMemo(() => { + const map = new Map() + for (const comment of props.comments()) { + const list = map.get(comment.file) ?? [] + list.push(comment) + map.set(comment.file, list) + } + return map + }) + + const pinned = createMemo(() => { + const files = new Set() + const current = draft() + if (current) files.add(current.file) + const edit = editing() + if (edit) { + const comment = props.comments().find((item) => item.id === edit) + if (comment) files.add(comment.file) + } + return props.rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : [])) + }) + + const cancelDraft = () => { + props.preserveScroll(() => { + setDraft(null) + draftMeta = null + props.composer().draft = null + }) + props.focus() + } + + const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { + props.preserveScroll(() => { + const id = `c-${++nextId}-${Date.now()}` + props.setComments([...props.comments(), { id, file, side, line, comment: text, selectedText }]) + setDraft(null) + draftMeta = null + props.composer().draft = null + }) + props.focus() + } + + const sendComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { + const comment = { id: `c-${++nextId}-${Date.now()}`, file, side, line, comment: text, selectedText } + sendReviewComments([comment], props.activeTerminalId()) + props.preserveScroll(() => { + setDraft(null) + draftMeta = null + props.composer().draft = null + }) + props.onSendClick?.() + props.focus() + } + + const updateComment = (id: string, text: string) => { + props.preserveScroll(() => { + props.setComments( + props.comments().map((comment) => (comment.id === id ? { ...comment, comment: text } : comment)), + ) + setEditing(null) + editMeta = null + props.composer().edit = null + }) + props.focus() + } + + const deleteComment = (id: string) => { + props.preserveScroll(() => { + props.setComments(props.comments().filter((comment) => comment.id !== id)) + if (editing() === id) { + setEditing(null) + editMeta = null + props.composer().edit = null + } + }) + props.focus() + } + + const setEditState = (id: string | null) => { + if (editing() !== id) { + editMeta = null + props.composer().edit = null + } + props.preserveScroll(() => setEditing(id)) + if (id === null) props.focus() + } + + const annotationsForFile = (file: string): DiffLineAnnotation[] => { + const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) + draftMeta = result.draftMeta + editMeta = result.editMeta + if (untrack(() => active())) { + props.composer().draft = draft() ? draftMeta : null + props.composer().edit = editing() ? editMeta : null + } + return result.annotations + } + + const buildAnnotation = (annotation: DiffLineAnnotation): HTMLElement | undefined => + buildReviewAnnotation(annotation, { + diffs: props.diffs(), + editing: editing(), + setEditing: setEditState, + addComment, + sendComment, + updateComment, + deleteComment, + cancelDraft, + labels: labels(props.label), + activeTerminalId: props.activeTerminalId, + speech, + }) + + const handleGutterClick = (file: string, range: SelectedLineRange) => { + if (!canComment() || draft()) return + const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" + props.preserveScroll(() => { + const next = { file, side, line: range.start, endLine: range.end } + draftMeta = { type: "draft", comment: null, ...next } + props.composer().draft = draftMeta + setDraft(next) + }) + } + + const sendAllToChat = () => { + const comments = props.comments() + if (comments.length === 0) return + sendReviewComments(comments, props.activeTerminalId()) + props.preserveScroll(() => props.setComments([])) + props.onSendAll?.() + } + + const sendAllClick = () => { + props.onSendClick?.() + sendAllToChat() + } + + return { + pinned, + commentsByFile, + annotationsForFile, + buildAnnotation, + cancelDraft, + addComment, + sendComment, + updateComment, + deleteComment, + setEditState, + handleGutterClick, + sendAllToChat, + sendAllClick, + } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-scroll.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-scroll.ts new file mode 100644 index 00000000000..34db3f83d8a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-scroll.ts @@ -0,0 +1,24 @@ +import type { Accessor } from "solid-js" +import type { VirtualizerHandle } from "virtua/solid" +import type { WorktreeFileDiff } from "../src/types/messages" + +export function createReviewScrollPreserver( + rows: Accessor, + virtualizer: Accessor, +) { + return (run: () => void) => { + const handle = virtualizer() + const index = handle?.findItemIndex(handle.scrollOffset) + const file = index === undefined ? undefined : rows()[index]?.file + const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0) + run() + if (!file) return + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const next = rows().findIndex((diff) => diff.file === file) + if (next < 0) return + virtualizer()?.scrollToIndex(next, { offset }) + }) + }) + } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-setup.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-setup.ts new file mode 100644 index 00000000000..1675055c88a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-setup.ts @@ -0,0 +1,53 @@ +import type { UiI18nParams } from "@kilocode/kilo-ui/context" +import { useConfig } from "../src/context/config" +import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" +import { useProvider } from "../src/context/provider" +import { useServer } from "../src/context/server" +import { useSpeechToText, type SpeechToText } from "../src/components/speech-to-text/useSpeechToText" +import { useSpeechToTextModels } from "../src/context/speech-to-text-models" +import { useVSCode } from "../src/context/vscode" + +type T = (key: string, params?: UiI18nParams) => string + +const notices: Record = { + "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", +} + +export function notice(t: T, kind?: string) { + return kind ? t(notices[kind] ?? kind) : "" +} + +export function reviewSendAllKeybind(t: T): string { + return typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) + ? t("agentManager.review.sendAllShortcut.mac") + : t("agentManager.review.sendAllShortcut.other") +} + +export function createReviewSpeech(t: T): { + speech: SpeechToText + enabled: () => boolean + model: () => string +} { + const vscode = useVSCode() + const server = useServer() + const provider = useProvider() + const { config } = useConfig() + const speech = useSpeechToText(vscode, server, { t }) + const models = useSpeechToTextModels() + return { + speech, + enabled: () => canUseSpeechToText(config(), provider.authStates()), + model: () => selectedSpeechToTextModel(config(), models.models()), + } +} + +export function reviewFocus(root: () => HTMLElement | undefined): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => root()?.focus()) + }) +} + +export function keepsNativeFocus(target: EventTarget | null): boolean { + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true + return target instanceof HTMLElement && target.isContentEditable +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-state.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-state.ts new file mode 100644 index 00000000000..575b07e89ce --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-state.ts @@ -0,0 +1,40 @@ +import { createEffect, createMemo, createSignal, on, type Accessor } from "solid-js" +import type { WorktreeFileDiff } from "../src/types/messages" +import { initialOpenFiles, reconcileOpenFiles, sanitizeOpenFiles } from "./diff-open-policy" + +export function createReviewOpenState(diffs: Accessor, key: Accessor) { + const [manual, setManual] = createSignal>({}) + const [known, setKnown] = createSignal>({}) + const open = createMemo(() => { + const id = key() ?? "" + const files = diffs() + if (files.length === 0) return [] + const value = manual()[id] + return value ? sanitizeOpenFiles(files, value) : initialOpenFiles(files) + }) + + createEffect( + on( + () => [key(), diffs()] as const, + ([current, files]) => { + if (files.length === 0) return + const id = current ?? "" + const value = manual()[id] + const result = reconcileOpenFiles(files, value, known()[id] ?? []) + setKnown((prev) => ({ ...prev, [id]: result.known })) + if (!value || !result.open) return + if (result.open.length === value.length && result.open.every((file, index) => file === value[index])) return + setManual((prev) => ({ ...prev, [id]: result.open! })) + }, + ), + ) + + const setOpen = (files: string[] | ((prev: string[]) => string[])) => { + const id = key() ?? "" + const current = open() + const next = typeof files === "function" ? files(current) : files + setManual((prev) => ({ ...prev, [id]: sanitizeOpenFiles(diffs(), next) })) + } + + return { open, setOpen } +} diff --git a/packages/kilo-vscode/webview-ui/documents/DocumentPanel.tsx b/packages/kilo-vscode/webview-ui/documents/DocumentPanel.tsx index 771e744b433..643c015ce84 100644 --- a/packages/kilo-vscode/webview-ui/documents/DocumentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/documents/DocumentPanel.tsx @@ -161,7 +161,7 @@ export const DocumentPanel: Component = (props) => { deleteComment, cancelDraft, labels: labels(t), - activeTerminalId: props.activeTerminalId, + activeTerminalId: () => props.activeTerminalId, }) const gutter = (range: SelectedLineRange) => { if (draft()) return diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/lib/types.ts b/packages/kilo-vscode/webview-ui/kiloclaw/lib/types.ts index 098f0d1a3f6..aaebea55dab 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/lib/types.ts +++ b/packages/kilo-vscode/webview-ui/kiloclaw/lib/types.ts @@ -1,160 +1,16 @@ -/** - * KiloClaw webview types. - * - * Mirrors the extension host types for use in the SolidJS webview. - * All data arrives via postMessage — no direct network access. - * - * SYNC: These types are mirrored from src/kiloclaw/types.ts — keep both in sync. - */ +export type { + ActionItem, + ActionsBlock, + BotStatusRecord, + ClawStatus, + ContentBlock, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, + ReactionSummary, + TextBlock, + TypingMember, +} from "@kilocode/kilo-gateway/claw" -// ── Instance status ───────────────────────────────────────────────── - -export type ClawStatus = { - // Mirrors src/kiloclaw/types.ts. `recovering` / `restoring` are transitional - // states the cloud worker reports when bringing an instance back online. - status: - | "provisioned" - | "starting" - | "restarting" - | "recovering" - | "running" - | "stopped" - | "destroying" - | "restoring" - | null - sandboxId?: string - flyRegion?: string - machineSize?: { cpus: number; memory_mb: number } - openclawVersion?: string | null - lastStartedAt?: string | null - lastStoppedAt?: string | null - channelCount?: number - secretCount?: number - userId?: string - botName?: string | null -} - -// ── Kilo Chat content blocks ──────────────────────────────────────── - -export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" - -export type TextBlock = { type: "text"; text: string } - -export type ActionItem = { - label: string - style: "primary" | "danger" | "secondary" - value: ExecApprovalDecision -} - -export type ActionsBlock = { - type: "actions" - groupId: string - actions: ActionItem[] - resolved?: { - value: ExecApprovalDecision - resolvedBy: string - resolvedAt: number - } -} - -export type ContentBlock = TextBlock | ActionsBlock - -// ── Reactions ─────────────────────────────────────────────────────── - -export type ReactionSummary = { - emoji: string - count: number - memberIds: string[] -} - -// ── Messages ──────────────────────────────────────────────────────── - -export type Message = { - id: string - senderId: string - content: ContentBlock[] - inReplyToMessageId: string | null - updatedAt: number | null - clientUpdatedAt: number | null - deleted: boolean - deliveryFailed: boolean - reactions: ReactionSummary[] -} - -// ── Conversations ─────────────────────────────────────────────────── - -export type ConversationListItem = { - conversationId: string - title: string | null - lastActivityAt: number | null - lastReadAt: number | null - joinedAt: number -} - -// ── Bot / conversation status ─────────────────────────────────────── - -export type BotStatusRecord = { - online: boolean - at: number - updatedAt: number -} - -export type ConversationStatusRecord = { - conversationId: string - contextTokens: number - contextWindow: number - model: string | null - provider: string | null - at: number - updatedAt: number -} - -// ── Typing ────────────────────────────────────────────────────────── - -export type TypingMember = { memberId: string; at: number } - -// ── Webview state ────────────────────────────────────────────────── - -export type KiloClawState = - | { phase: "loading"; locale: string } - | { phase: "noInstance"; locale: string } - | { phase: "needsUpgrade"; locale: string } - | { phase: "error"; locale: string; error: string } - | { - phase: "ready" - locale: string - status: ClawStatus | null - currentUserId: string - sandboxId: string - conversations: ConversationListItem[] - hasMoreConversations: boolean - activeConversationId: string | null - messages: Message[] - hasMoreMessages: boolean - botStatus: BotStatusRecord | null - conversationStatus: ConversationStatusRecord | null - typingMembers: TypingMember[] - } - -// ── Messages: Extension Host → Webview ────────────────────────────── - -export type KiloClawOutMessage = - | { type: "kiloclaw.state"; state: KiloClawState } - | { type: "kiloclaw.status"; data: ClawStatus | null } - | { type: "kiloclaw.locale"; locale: string } - | { type: "kiloclaw.error"; error: string } - | { type: "kiloclaw.conversations"; conversations: ConversationListItem[]; hasMore: boolean; replace: boolean } - | { type: "kiloclaw.activeConversation"; conversationId: string | null } - | { type: "kiloclaw.messages"; conversationId: string; messages: Message[]; hasMore: boolean; replace: boolean } - | { type: "kiloclaw.messageOptimistic"; conversationId: string; message: Message } - | { type: "kiloclaw.messageReplaced"; conversationId: string; pendingId: string; message: Message } - | { type: "kiloclaw.messageRemoved"; conversationId: string; messageId: string } - | { type: "kiloclaw.botStatus"; status: BotStatusRecord | null } - | { type: "kiloclaw.conversationStatus"; status: ConversationStatusRecord | null } - | { type: "kiloclaw.typing"; conversationId: string; memberId: string } - | { type: "kiloclaw.typingStop"; conversationId: string; memberId: string } - | { type: "fontSizeChanged"; fontSize: number } - -// Note: messages sent from the webview to the extension host are typed in -// src/kiloclaw/types.ts (KiloClawInMessage). The webview dispatches them -// inline via vscode.postMessage and does not import the type. +export type { KiloClawOutMessage, KiloClawState } from "../../../src/kiloclaw/types" diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css index 047c14a8b3c..224db0f7172 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css @@ -9,15 +9,6 @@ .prompt-input-container { position: relative; margin: var(--prompt-min-gutter); - border-radius: 0.25rem; - background-color: var(--input-base, var(--vscode-input-background, #3c3c3c)); - border: 1px solid var(--border-weak-base, var(--vscode-input-border, #3c3c3c)); - box-shadow: none; - - &:focus-within { - border-color: var(--border-focus, var(--vscode-focusBorder, #007fd4)); - box-shadow: none; - } } .sr-only { @@ -494,38 +485,6 @@ gap: 4px; flex: 1; min-width: 0; - - [data-component="button"] { - height: auto !important; - min-height: 22px; - padding: 4px 6px !important; - font-size: var(--kilo-font-size-12); - line-height: normal; - border-radius: 6px; - background: var(--surface-base); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: none; - opacity: 1; - gap: 6px; - white-space: nowrap; - transition: all 150ms; - color: var(--text-base, var(--vscode-foreground)); - - &[data-expanded] { - background: var(--surface-base-hover) !important; - background-color: var(--surface-base-hover) !important; - } - - &:hover:not(:disabled) { - background: var(--surface-base-hover); - background-color: var(--surface-base-hover); - } - - &:focus-visible { - outline: none; - box-shadow: 0 0 0 1px var(--border-focus); - } - } } /* Remote Settings */ @@ -595,26 +554,7 @@ flex-shrink: 0; [data-component="button"] { - background: transparent; - border: none; padding: 4px; - - [data-slot="icon-svg"] { - color: var(--icon-base); - } - - [data-slot="progress-circle-background"] { - stroke: var(--icon-base); - } - - [data-slot="progress-circle-progress"] { - stroke: var(--border-focus, var(--vscode-focusBorder, #007fd4)); - } - } - [data-component="icon-button"] { - [data-slot="icon-svg"] { - color: var(--icon-base) !important; - } } } diff --git a/packages/opencode/src/kilocode/claw/kilo-chat-client.ts b/packages/opencode/src/kilocode/claw/kilo-chat-client.ts index 4a02b03150c..d9bf849e1d4 100644 --- a/packages/opencode/src/kilocode/claw/kilo-chat-client.ts +++ b/packages/opencode/src/kilocode/claw/kilo-chat-client.ts @@ -1,261 +1,6 @@ -// kilocode_change - new file +import { KiloChatClient as SharedKiloChatClient } from "@kilocode/kilo-gateway/claw" -/** - * HTTP client for the kilo-chat Cloudflare Worker. - * - * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) - * tailored to what the TUI needs. The kilo-chat worker validates payloads - * at its edge so we don't run zod here. - */ +export { KiloChatApiError } from "@kilocode/kilo-gateway/claw" +export type { KiloChatClientConfig } from "@kilocode/kilo-gateway/claw" -import type { - BotStatusRecord, - ContentBlock, - ConversationListItem, - ConversationStatusRecord, - ExecApprovalDecision, - Message, -} from "./types" - -export type KiloChatClientConfig = { - baseUrl: string - getToken: () => Promise - onUnauthorized?: () => void -} - -export class KiloChatApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - ) { - super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) - this.name = "KiloChatApiError" - } -} - -function formatBodyDetail(body: unknown): string { - if (body === null || body === undefined) return "" - if (typeof body === "string") return ` - ${body}` - if (typeof body === "object") { - const err = (body as Record).error - if (typeof err === "string") return ` - ${err}` - try { - return ` - ${JSON.stringify(body)}` - } catch { - return "" - } - } - return "" -} - -type HttpOpts = { - method?: string - body?: unknown - query?: Record -} - -type SendQueue = Map> - -export class KiloChatClient { - private readonly baseUrl: string - private readonly getToken: () => Promise - private readonly onUnauthorized: (() => void) | undefined - private readonly sendQueues: SendQueue = new Map() - - constructor(config: KiloChatClientConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, "") - this.getToken = config.getToken - this.onUnauthorized = config.onUnauthorized - } - - // ── Conversations ──────────────────────────────────────────────── - - listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ - conversations: ConversationListItem[] - hasMore: boolean - nextCursor: string | null - }> { - return this.request("/v1/conversations", { - query: { - sandboxId: opts?.sandboxId, - limit: opts?.limit, - cursor: opts?.cursor ?? undefined, - }, - }) - } - - createConversation(req: { - sandboxId: string - title?: string - }): Promise<{ conversationId: string; conversation?: unknown }> { - return this.request("/v1/conversations", { method: "POST", body: req }) - } - - renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { - return this.request(`/v1/conversations/${conversationId}`, { - method: "PATCH", - body: { title }, - }) - } - - async leaveConversation(conversationId: string): Promise { - // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. - await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) - } - - /** - * Mark messages up to `lastSeenMessageId` as read. The server enforces - * monotonic `lastReadAt` and reports whether it advanced plus whether - * the badge bucket was cleared. - */ - markConversationRead( - conversationId: string, - req: { lastSeenMessageId: string }, - ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { - return this.request(`/v1/conversations/${conversationId}/mark-read`, { - method: "POST", - body: req, - }) - } - - // ── Messages ───────────────────────────────────────────────────── - - sendMessage(req: { - conversationId: string - content: ContentBlock[] - inReplyToMessageId?: string - clientId?: string - }): Promise<{ messageId: string; clientId?: string; message?: Message }> { - const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() - const send = () => - this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { - method: "POST", - body: req, - }) - const next = prev.then(send, send) - this.sendQueues.set(req.conversationId, next) - const cleanup = () => { - if (this.sendQueues.get(req.conversationId) === next) { - this.sendQueues.delete(req.conversationId) - } - } - void next.then(cleanup, cleanup) - return next - } - - editMessage( - messageId: string, - req: { conversationId: string; content: ContentBlock[]; timestamp: number }, - ): Promise<{ messageId?: string; message?: Message }> { - return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) - } - - async deleteMessage(messageId: string, conversationId: string): Promise { - // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. - await this.request(`/v1/messages/${messageId}`, { - method: "DELETE", - query: { conversationId }, - }) - } - - listMessages( - conversationId: string, - opts?: { before?: string; limit?: number }, - ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { - return this.request(`/v1/conversations/${conversationId}/messages`, { - query: { before: opts?.before, limit: opts?.limit }, - }) - } - - executeAction( - conversationId: string, - messageId: string, - req: { groupId: string; value: ExecApprovalDecision }, - ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { - return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { - method: "POST", - body: req, - }) - } - - // ── Reactions ──────────────────────────────────────────────────── - - addReaction( - messageId: string, - req: { conversationId: string; emoji: string }, - ): Promise<{ id: string; operationId?: string }> { - return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) - } - - async removeReaction( - messageId: string, - req: { conversationId: string; emoji: string }, - ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { - return this.request<{ removed: boolean; id: string | null; operationId?: string }>( - `/v1/messages/${messageId}/reactions`, - { - method: "DELETE", - query: req, - }, - ) - } - - // ── Typing ─────────────────────────────────────────────────────── - - async sendTyping(conversationId: string): Promise { - await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) - } - - async sendTypingStop(conversationId: string): Promise { - await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) - } - - // ── Bot / conversation status ──────────────────────────────────── - - getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { - return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) - } - - async requestBotStatus(sandboxId: string): Promise { - await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) - } - - getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { - return this.request(`/v1/conversations/${conversationId}/conversation-status`) - } - - // ── private ────────────────────────────────────────────────────── - - private async request(path: string, opts: HttpOpts = {}): Promise { - const token = await this.getToken() - let url = `${this.baseUrl}${path}` - - if (opts.query) { - const params = new URLSearchParams() - for (const [k, v] of Object.entries(opts.query)) { - if (v === undefined || v === null) continue - params.set(k, String(v)) - } - const qs = params.toString() - if (qs) url += `?${qs}` - } - - const headers: Record = { Authorization: `Bearer ${token}` } - if (opts.body !== undefined) headers["Content-Type"] = "application/json" - - const res = await fetch(url, { - method: opts.method ?? "GET", - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }) - - if (!res.ok) { - if (res.status === 401 || res.status === 403) this.onUnauthorized?.() - const body: unknown = await res.json().catch(() => null) - throw new KiloChatApiError(res.status, body) - } - - if (res.status === 204) return undefined as unknown as T - return (await res.json()) as T - } -} +export class KiloChatClient extends SharedKiloChatClient {} diff --git a/packages/opencode/src/kilocode/claw/types.ts b/packages/opencode/src/kilocode/claw/types.ts index 70aebaf386a..0fe266d230d 100644 --- a/packages/opencode/src/kilocode/claw/types.ts +++ b/packages/opencode/src/kilocode/claw/types.ts @@ -1,211 +1,39 @@ -// kilocode_change - new file +export type { + ActionDeliveryFailedEvent, + ActionExecutedEvent, + ActionItem, + ActionsBlock, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationLeftEvent, + ConversationListItem, + ConversationReadEvent, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, + KiloChatEventMap, + KiloChatEventName, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + ReactionSummary, + ReplyToSnapshot, + TextBlock, + TypingEvent, + TypingMember, + TypingStopEvent, +} from "@kilocode/kilo-gateway/claw" -/** - * KiloClaw TUI types — Kilo Chat protocol. - * - * Mirrors `@kilocode/kilo-chat` from the cloud monorepo. We carry our own - * copy here so the CLI can be built without an external dependency. - */ - -export type ClawStatus = { - // `recovering` and `restoring` are transitional states the worker reports - // while bringing an instance back from an unexpected stop or snapshot - // restore (cloud: `services/kiloclaw/src/index.ts`). - status: - | "provisioned" - | "starting" - | "restarting" - | "recovering" - | "running" - | "stopped" - | "destroying" - | "restoring" - | null - sandboxId?: string - flyRegion?: string - machineSize?: { cpus: number; memory_mb: number } - openclawVersion?: string | null - lastStartedAt?: string | null - lastStoppedAt?: string | null - channelCount?: number - secretCount?: number - userId?: string - // User-chosen name for the KiloClaw bot, set during onboarding via the - // `patchBotIdentity` mutation. May be null for fresh instances that - // skipped the bot-identity step. The chat UI should fall back to the - // literal string "KiloClaw" when null. - botName?: string | null -} - -// ── Kilo Chat token envelope (gateway response) ───────────────────── - -export type ChatToken = { - token: string - expiresAt: string // ISO timestamp - kiloChatUrl: string - eventServiceUrl: string -} - -// ── Content blocks ────────────────────────────────────────────────── - -export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" - -export type TextBlock = { type: "text"; text: string } - -export type ActionItem = { - label: string - style: "primary" | "danger" | "secondary" - value: ExecApprovalDecision -} - -export type ActionsBlock = { - type: "actions" - groupId: string - actions: ActionItem[] - resolved?: { - value: ExecApprovalDecision - resolvedBy: string - resolvedAt: number - } -} - -export type ContentBlock = TextBlock | ActionsBlock - -// ── Reactions ─────────────────────────────────────────────────────── - -export type ReactionSummary = { - emoji: string - count: number - memberIds: string[] -} - -// ── Messages ──────────────────────────────────────────────────────── - -export type Message = { - id: string - senderId: string - content: ContentBlock[] - inReplyToMessageId: string | null - updatedAt: number | null - clientUpdatedAt: number | null - deleted: boolean - deliveryFailed: boolean - reactions: ReactionSummary[] -} - -// ── Conversations ─────────────────────────────────────────────────── - -export type ConversationListItem = { - conversationId: string - title: string | null - lastActivityAt: number | null - lastReadAt: number | null - joinedAt: number -} - -// ── Bot / conversation status ─────────────────────────────────────── - -export type BotStatusRecord = { - online: boolean - at: number - updatedAt: number -} - -export type ConversationStatusRecord = { - conversationId: string - contextTokens: number - contextWindow: number - model: string | null - provider: string | null - at: number - updatedAt: number -} - -// ── Events ────────────────────────────────────────────────────────── - -/** - * Snapshot of the message that was replied to. Server includes this on - * `message.created` so clients can render a reply preview without a follow-up - * fetch. `deleted` mirrors the soft-deletion state at the time of replying. - */ -export type ReplyToSnapshot = { - messageId: string - senderId: string - content: ContentBlock[] - deleted?: boolean -} - -export type MessageCreatedEvent = { - messageId: string - senderId: string - content: ContentBlock[] - inReplyToMessageId: string | null - clientId?: string - replyTo?: ReplyToSnapshot | null -} - -export type MessageUpdatedEvent = { - messageId: string - content: ContentBlock[] - clientUpdatedAt: number | null -} - -export type MessageDeletedEvent = { messageId: string } -export type MessageDeliveryFailedEvent = { messageId: string } -export type TypingEvent = { memberId: string } - -export type TypingMember = { memberId: string; at: number } -export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } -export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } -/** - * Server fans out the full conversation snapshot on `conversation.created` so - * clients can append to their list without a follow-up fetch. - */ -export type ConversationCreatedEvent = { - conversationId: string - conversation?: ConversationListItem -} -export type ConversationRenamedEvent = { conversationId: string; title: string } -export type ConversationLeftEvent = { conversationId: string } -export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } -export type ActionDeliveryFailedEvent = { conversationId: string; messageId: string; groupId: string } -export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } -export type ConversationStatusEvent = { - conversationId: string - contextTokens: number - contextWindow: number - model: string | null - provider: string | null - at: number -} - -export type KiloChatEventMap = { - "message.created": MessageCreatedEvent - "message.updated": MessageUpdatedEvent - "message.deleted": MessageDeletedEvent - "message.delivery_failed": MessageDeliveryFailedEvent - typing: TypingEvent - "typing.stop": TypingEvent - "reaction.added": ReactionAddedEvent - "reaction.removed": ReactionRemovedEvent - "conversation.created": ConversationCreatedEvent - "conversation.renamed": ConversationRenamedEvent - "conversation.left": ConversationLeftEvent - "conversation.activity": ConversationActivityEvent - "action.delivery_failed": ActionDeliveryFailedEvent - "bot.status": BotStatusEvent - "conversation.status": ConversationStatusEvent -} - -export type KiloChatEventName = keyof KiloChatEventMap - -// ── Legacy display message (for CLI rendering) ────────────────────── - -/** - * Lightweight chat-message shape used by the existing CLI rendering layer. - * We keep this for backwards compatibility with components that already - * render text + bot flag. New code should prefer `Message`. - */ export type ChatMessage = { id: string text: string diff --git a/packages/opencode/src/kilocode/event-service/client.ts b/packages/opencode/src/kilocode/event-service/client.ts index 717b75f2150..1653f580232 100644 --- a/packages/opencode/src/kilocode/event-service/client.ts +++ b/packages/opencode/src/kilocode/event-service/client.ts @@ -1,382 +1 @@ -/** - * Generic Event Service WebSocket client. - * - * Connects via a two-step ticket flow: - * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a - * single-use ticket (30 s TTL). - * 2. Open WebSocket to `/connect?ticket=` with subprotocol - * `kilo.events.v1`. - * - * Uses the global `WebSocket` constructor (Bun, Node 22+, browsers). - * - * Disconnect invalidation: every `connect()` and `disconnect()` bumps a - * generation counter. `connectOnce()` captures the generation at entry and, - * after the ticket mint resolves, refuses to construct a socket if the - * generation changed or the client was disposed. `disconnect()` also aborts - * an in-flight ticket request and the pending handshake, so a ticket response - * arriving after disposal can never create a socket. - */ - -const WS_SUBPROTOCOL = "kilo.events.v1" -const HANDSHAKE_TIMEOUT_MS = 10_000 -const PING_INTERVAL_MS = 15_000 -const TICKET_FETCH_TIMEOUT_MS = 10_000 - -export class WebSocketAuthError extends Error { - constructor(message = "WebSocket authentication failed") { - super(message) - this.name = "WebSocketAuthError" - } -} - -export class WebSocketConnectError extends Error { - constructor( - message: string, - public readonly code: number, - ) { - super(message) - this.name = "WebSocketConnectError" - } -} - -export class HandshakeTimeoutError extends Error { - constructor() { - super("WebSocket handshake timed out") - this.name = "HandshakeTimeoutError" - } -} - -function isAuthCloseCode(code: number): boolean { - if (code === 1008) return true - if (code === 4401 || code === 4403) return true - return false -} - -export type EventHandler = (context: string, payload: unknown) => void - -export type EventServiceConfig = { - url: string - getToken: () => Promise - onUnauthorized?: () => void - onServerError?: (error: unknown) => void - handshakeTimeoutMs?: number -} - -function toHttpBase(wsBase: string): string { - const trimmed = wsBase.replace(/\/$/, "") - if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) - if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) - return trimmed -} - -export class EventServiceClient { - private readonly url: string - private readonly getToken: () => Promise - private readonly onUnauthorized: (() => void) | undefined - private readonly onServerError: ((error: unknown) => void) | undefined - private readonly handshakeTimeoutMs: number - - private ws: WebSocket | null = null - private connected = false - private destroyed = false - private generation = 0 - private reconnectAttempts = 0 - private hasConnectedBefore = false - private reconnectTimer: ReturnType | null = null - private pingTimer: ReturnType | null = null - private handshakeTimer: ReturnType | null = null - private abortHandshake: ((err: Error) => void) | null = null - private tickets = new Set() - - private eventHandlers = new Map>() - private activeContexts = new Set() - private reconnectHandlers = new Set<() => void>() - - constructor(config: EventServiceConfig) { - this.url = config.url - this.getToken = config.getToken - this.onUnauthorized = config.onUnauthorized - this.onServerError = config.onServerError - this.handshakeTimeoutMs = config.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS - } - - async connect(): Promise { - const gen = ++this.generation - this.destroyed = false - this.reconnectAttempts = 0 - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - try { - await this.connectOnce() - } catch (err) { - if (this.destroyed || this.generation !== gen) return - if (this.handleAuthFailure(err)) return - if (!this.destroyed) this.scheduleReconnect() - } - } - - disconnect(): void { - this.generation++ - this.destroyed = true - for (const ctrl of this.tickets) ctrl.abort() - this.tickets.clear() - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - this.clearHandshakeTimer() - if (this.abortHandshake) { - this.abortHandshake(new Error("disconnected")) - } - if (this.ws) { - this.ws.close() - this.ws = null - } - this.stopPing() - this.connected = false - } - - isConnected(): boolean { - return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN - } - - subscribe(contexts: string[]): void { - for (const ctx of contexts) this.activeContexts.add(ctx) - if (this.isConnected()) { - this.sendJson({ type: "context.subscribe", contexts }) - } - } - - unsubscribe(contexts: string[]): void { - for (const ctx of contexts) this.activeContexts.delete(ctx) - if (this.isConnected()) { - this.sendJson({ type: "context.unsubscribe", contexts }) - } - } - - on(event: string, handler: (context: string, payload: T) => void): () => void { - const set = this.eventHandlers.get(event) ?? new Set() - const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as T) - set.add(wrapped) - this.eventHandlers.set(event, set) - return () => { - set.delete(wrapped) - if (set.size === 0) this.eventHandlers.delete(event) - } - } - - onReconnect(handler: () => void): () => void { - this.reconnectHandlers.add(handler) - return () => this.reconnectHandlers.delete(handler) - } - - // ── private ──────────────────────────────────────────────────────── - - private handleAuthFailure(err: unknown): boolean { - if (err instanceof WebSocketAuthError) { - this.destroyed = true - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - this.onUnauthorized?.() - return true - } - return false - } - - private async connectOnce(): Promise { - const gen = this.generation - if (this.ws) { - const old = this.ws - this.ws = null - old.close() - } - - const token = await this.getToken() - if (this.destroyed || this.generation !== gen) return - const ticket = await this.fetchTicket(token) - if (this.destroyed || this.generation !== gen) return - - return new Promise((resolve, reject) => { - const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) - this.ws = ws - - let settled = false - const settleResolve = () => { - if (settled) return - settled = true - this.clearHandshakeTimer() - this.abortHandshake = null - resolve() - } - const settleReject = (err: Error) => { - if (settled) return - settled = true - this.clearHandshakeTimer() - this.abortHandshake = null - reject(err) - } - this.abortHandshake = settleReject - - this.handshakeTimer = setTimeout(() => { - this.handshakeTimer = null - if (this.ws === ws) ws.close(1000, "handshake-timeout") - settleReject(new HandshakeTimeoutError()) - }, this.handshakeTimeoutMs) - - ws.addEventListener("open", () => { - if (this.ws !== ws) return - const isReconnect = this.hasConnectedBefore - this.connected = true - this.hasConnectedBefore = true - this.reconnectAttempts = 0 - this.resubscribeContexts() - if (isReconnect) { - for (const h of this.reconnectHandlers) h() - } - settleResolve() - this.startPing() - }) - - ws.addEventListener("message", (event: MessageEvent) => { - if (this.ws !== ws) return - this.handleMessage(String(event.data)) - }) - - ws.addEventListener("close", (event: CloseEvent) => { - if (this.ws !== ws) return - const wasConnected = this.connected - this.connected = false - this.stopPing() - this.clearHandshakeTimer() - if (!wasConnected) { - if (isAuthCloseCode(event.code)) { - settleReject(new WebSocketAuthError()) - } else { - settleReject( - new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), - ) - } - return - } - if (!this.destroyed) this.scheduleReconnect() - }) - - ws.addEventListener("error", () => {}) - }) - } - - private async fetchTicket(token: string): Promise { - const ctrl = new AbortController() - this.tickets.add(ctrl) - const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) - try { - const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - signal: ctrl.signal, - }) - if (res.status === 401 || res.status === 403) { - throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) - } - if (!res.ok) { - throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) - } - const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null - if (!body || typeof body.ticket !== "string" || !body.ticket) { - throw new WebSocketConnectError("Malformed event-service ticket response", 0) - } - return body.ticket - } catch (err) { - if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err - if ((err as { name?: string })?.name === "AbortError") { - throw new HandshakeTimeoutError() - } - throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) - } finally { - clearTimeout(timer) - this.tickets.delete(ctrl) - } - } - - private clearHandshakeTimer(): void { - if (this.handshakeTimer !== null) { - clearTimeout(this.handshakeTimer) - this.handshakeTimer = null - } - } - - private sendJson(msg: unknown): void { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(msg)) - } - } - - private handleMessage(data: string): void { - if (data === "pong") return - let parsed: unknown - try { - parsed = JSON.parse(data) - } catch { - return - } - if (!parsed || typeof parsed !== "object") return - const m = parsed as Record - if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { - const handlers = this.eventHandlers.get(m.event) - if (handlers) { - for (const h of handlers) h(m.context, m.payload) - } - return - } - if (m.type === "error") { - console.warn("[Kilo] event-service server error", m) - this.onServerError?.(m) - } - } - - private startPing(): void { - this.stopPing() - this.pingTimer = setInterval(() => { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send("ping") - } - }, PING_INTERVAL_MS) - } - - private stopPing(): void { - if (this.pingTimer !== null) { - clearInterval(this.pingTimer) - this.pingTimer = null - } - } - - private resubscribeContexts(): void { - if (this.activeContexts.size > 0) { - this.sendJson({ - type: "context.subscribe", - contexts: Array.from(this.activeContexts), - }) - } - } - - private scheduleReconnect(): void { - if (this.reconnectTimer !== null) return - const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) - const delay = base * (0.5 + Math.random() * 0.5) - this.reconnectAttempts++ - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null - if (this.destroyed) return - const gen = this.generation - this.connectOnce().catch((err) => { - if (this.destroyed || this.generation !== gen) return - if (this.handleAuthFailure(err)) return - if (!this.destroyed) this.scheduleReconnect() - }) - }, delay) - } -} +export * from "@kilocode/kilo-gateway/event-service" diff --git a/packages/opencode/src/kilocode/session-import/service.ts b/packages/opencode/src/kilocode/session-import/service.ts index a38d92b143a..54a2ae16dca 100644 --- a/packages/opencode/src/kilocode/session-import/service.ts +++ b/packages/opencode/src/kilocode/session-import/service.ts @@ -49,51 +49,35 @@ export namespace SessionImportService { partID: input.revert.partID ? PartID.make(input.revert.partID) : undefined, } : undefined + const data = { + project_id: ProjectV2.ID.make(input.projectID), + workspace_id: input.workspaceID ? WorkspaceV2.ID.make(input.workspaceID) : undefined, + parent_id: input.parentID ? SessionID.make(input.parentID) : undefined, + slug: input.slug, + directory: input.directory, + title: input.title, + version: input.version, + share_url: input.shareURL, + summary_additions: input.summary?.additions, + summary_deletions: input.summary?.deletions, + summary_files: input.summary?.files, + summary_diffs: input.summary?.diffs as never, + revert, + permission: input.permission as never, + time_created: input.timeCreated, + time_updated: input.timeUpdated, + time_compacting: input.timeCompacting, + time_archived: input.timeArchived, + } yield* db .insert(SessionTable) .values({ id: SessionID.make(input.id), - project_id: ProjectV2.ID.make(input.projectID), - workspace_id: input.workspaceID ? WorkspaceV2.ID.make(input.workspaceID) : undefined, - parent_id: input.parentID ? SessionID.make(input.parentID) : undefined, - slug: input.slug, - directory: input.directory, - title: input.title, - version: input.version, - share_url: input.shareURL, - summary_additions: input.summary?.additions, - summary_deletions: input.summary?.deletions, - summary_files: input.summary?.files, - summary_diffs: input.summary?.diffs as never, - revert, - permission: input.permission as never, - time_created: input.timeCreated, - time_updated: input.timeUpdated, - time_compacting: input.timeCompacting, - time_archived: input.timeArchived, + ...data, }) .onConflictDoUpdate({ target: key(SessionTable.id), - set: { - project_id: ProjectV2.ID.make(input.projectID), - workspace_id: input.workspaceID ? WorkspaceV2.ID.make(input.workspaceID) : undefined, - parent_id: input.parentID ? SessionID.make(input.parentID) : undefined, - slug: input.slug, - directory: input.directory, - title: input.title, - version: input.version, - share_url: input.shareURL, - summary_additions: input.summary?.additions, - summary_deletions: input.summary?.deletions, - summary_files: input.summary?.files, - summary_diffs: input.summary?.diffs as never, - revert, - permission: input.permission as never, - time_created: input.timeCreated, - time_updated: input.timeUpdated, - time_compacting: input.timeCompacting, - time_archived: input.timeArchived, - }, + set: data, }) .run() return { ok: true, id: input.id } diff --git a/packages/opencode/test/kilocode/session-import-service.test.ts b/packages/opencode/test/kilocode/session-import-service.test.ts index 91bd463ae1e..d702c85db9c 100644 --- a/packages/opencode/test/kilocode/session-import-service.test.ts +++ b/packages/opencode/test/kilocode/session-import-service.test.ts @@ -22,7 +22,10 @@ async function prepare() { await db( Effect.gen(function* () { const { db } = yield* Database.Service - yield* db.delete(SessionTable).where(eq(SessionTable.id, SessionID.make(input().id))).run() + yield* db + .delete(SessionTable) + .where(eq(SessionTable.id, SessionID.make(input().id))) + .run() yield* db.delete(ProjectTable).where(eq(ProjectTable.id, projectID)).run() yield* db .insert(ProjectTable) @@ -89,6 +92,46 @@ describe("SessionImportService.session", () => { expect(result).toEqual({ ok: true, id: "ses_migrated_test", skipped: true }) }) + test.each([false, true])("preserves imported fields with force=%s", async (force) => { + if (force) await SessionImportService.session(input()) + const value = { + ...input(force), + shareURL: "https://example.test/session", + summary: { additions: 3, deletions: 2, files: 1, diffs: [] }, + timeCreated: 11, + timeUpdated: 22, + timeCompacting: 33, + timeArchived: 44, + } + await SessionImportService.session(value) + const row = await db( + Database.Service.use(({ db }) => + db + .select() + .from(SessionTable) + .where(eq(SessionTable.id, SessionID.make(value.id))) + .get(), + ), + ) + expect(row).toMatchObject({ + id: value.id, + project_id: value.projectID, + slug: value.slug, + directory: value.directory, + title: value.title, + version: value.version, + share_url: value.shareURL, + summary_additions: 3, + summary_deletions: 2, + summary_files: 1, + summary_diffs: [], + time_created: 11, + time_updated: 22, + time_compacting: 33, + time_archived: 44, + }) + }) + test("deletes and recreates the session when force is true", async () => { await SessionImportService.session(input()) diff --git a/packages/script/tests/check-kilocode-duplication.test.ts b/packages/script/tests/check-kilocode-duplication.test.ts new file mode 100644 index 00000000000..ab34b996fc2 --- /dev/null +++ b/packages/script/tests/check-kilocode-duplication.test.ts @@ -0,0 +1,318 @@ +import { expect, test } from "bun:test" +import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { compare, parse, prune, scan, type Exception, type Finding } from "../../../script/check-kilocode-duplication" + +const source = `export function summarize(input: readonly number[]) { + const positive = input.filter((value) => Number.isFinite(value) && value > 0) + const negative = input.filter((value) => Number.isFinite(value) && value < 0) + const total = positive.reduce((sum, value) => sum + value, 0) + const sorted = positive.toSorted((left, right) => left - right) + const first = sorted.at(0) ?? 0 + const last = sorted.at(-1) ?? 0 + const average = positive.length ? total / positive.length : 0 + const result = { + count: input.length, + positive: positive.length, + negative: negative.length, + total, + first, + last, + average, + range: last - first, + valid: input.every((value) => Number.isFinite(value)), + } + return Object.freeze(result) +} +` +const first = "packages/kilo-example/src/first.ts" +const second = "packages/kilo-example/src/second.tsx" + +async function fixture(files: Record, run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "kilo-duplication-test-")) + try { + for (const [name, content] of Object.entries(files)) { + const file = join(root, name) + await mkdir(dirname(file), { recursive: true }) + await Bun.write(file, content) + } + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +function allowance(finding: Finding): Exception { + return { + files: finding.files, + fingerprint: finding.fingerprint, + maxMatches: finding.matches, + maxTokens: finding.tokens, + kind: "intentional", + owner: "scanner-tests", + reason: "Copied fixture verifies bounded exceptions against the real detector.", + } +} + +function exception(findings: Finding[]) { + const finding = findings.at(0) + if (!finding) throw new Error("Expected the real scanner to find the copied fixture") + return allowance(finding) +} + +const timeout = 60_000 + +test( + "detects cross-format copies despite comments and spacing and requires a bounded exception", + async () => { + await fixture( + { [first]: source, [second]: source.replace(" const total", " /* ignored comment */\n const total") }, + async (root) => { + const result = await scan(root) + expect(result.pairs).toBe(1) + expect(result.findings.at(0)?.files).toEqual([first, second]) + const exceptions = parse({ version: 1, scanner: result.scanner, exceptions: [exception(result.findings)] }) + expect(compare(result.findings, [])).toEqual([expect.stringContaining("Unclassified duplication")]) + expect(compare(result.findings, exceptions)).toEqual([]) + const finding = result.findings.at(0)! + expect(compare([{ ...finding, matches: finding.matches + 1 }], exceptions)).toEqual([ + expect.stringContaining("Duplication grew"), + ]) + expect(compare([{ ...finding, tokens: finding.tokens + 1 }], exceptions)).toEqual([ + expect.stringContaining("Duplication grew"), + ]) + const replacement = compare([{ ...finding, fingerprint: "ffffffffffffffff" }], exceptions) + expect(replacement).toContainEqual(expect.stringContaining("Unclassified duplication")) + expect(replacement).toContainEqual(expect.stringContaining("Stale exception")) + }, + ) + }, + timeout, +) + +test( + "line shifts preserve identity but a third copy fails", + async () => { + await fixture({ [first]: source, [second]: source }, async (root) => { + const initial = await scan(root) + const exceptions = initial.findings.map(allowance) + await Bun.write(join(root, first), `\n\n\n${source}`) + const shifted = await scan(root) + expect(compare(shifted.findings, exceptions)).toEqual([]) + const third = "packages/kilo-example/src/third.ts" + await Bun.write(join(root, third), source) + const copied = await scan(root) + expect(compare(copied.findings, exceptions)).toContainEqual(expect.stringContaining("Unclassified duplication")) + expect(copied.findings.some((finding) => finding.files.includes(third))).toBe(true) + expect(compare(copied.findings, prune(copied.findings, exceptions))).toContainEqual( + expect.stringContaining("Unclassified duplication"), + ) + }) + }, + timeout, +) + +test( + "detects duplicated blocks within one file", + async () => { + await fixture({ [first]: `${source}\n${source.replace("summarize", "describe")}` }, async (root) => { + const result = await scan(root) + expect(result.pairs).toBeGreaterThan(0) + expect(result.findings.at(0)?.files).toEqual([first, first]) + expect(compare(result.findings, [])).toContainEqual(expect.stringContaining("Unclassified duplication")) + }) + }, + timeout, +) + +test( + "cleanup requires removing stale debt and pruning never raises limits", + async () => { + await fixture({ [first]: source, [second]: source }, async (root) => { + const initial = await scan(root) + const exceptions = initial.findings.map(allowance) + await rm(join(root, second)) + const cleaned = await scan(root) + expect(cleaned.pairs).toBe(0) + expect(compare(cleaned.findings, exceptions)).toContainEqual(expect.stringContaining("Stale exception")) + expect(prune(cleaned.findings, exceptions)).toEqual([]) + expect(compare(cleaned.findings, prune(cleaned.findings, exceptions))).toEqual([]) + const finding = initial.findings.at(0)! + const smaller = { ...finding, tokens: finding.tokens - 1 } + expect(prune([smaller], exceptions).at(0)?.maxTokens).toBe(smaller.tokens) + expect(prune([{ ...finding, tokens: finding.tokens + 1 }], exceptions).at(0)?.maxTokens).toBe(finding.tokens) + }) + }, + timeout, +) + +test( + "includes new Kilo packages without counting upstream, generated, locale or fixture files", + async () => { + await fixture( + { + [first]: source, + "packages/ui/src/upstream.ts": source, + "packages/kilo-example/src/fixtures/copy.ts": source, + "packages/kilo-example/src/i18n/en.ts": source, + "packages/kilo-example/src/copy.test.ts": source, + "packages/kilo-example/src/copy.gen.ts": source, + "packages/kilo-example/src/copy.d.ts": source, + "packages/kilo-i18n/src/en.ts": source, + "packages/kilo-docs/src/copy.ts": source, + "packages/kilo-vscode/src/services/autocomplete/continuedev/copy.ts": source, + }, + async (root) => { + const result = await scan(root) + expect(result.files).toBe(1) + expect(result.pairs).toBe(0) + }, + ) + }, + timeout, +) + +test( + "fails closed for an empty scope or inline suppression", + async () => { + await fixture({}, async (root) => { + expect( + await scan(root).then( + () => "", + (err: unknown) => String(err), + ), + ).toContain("No Kilo-owned source files") + }) + await fixture({ [first]: `/* jscpd:ignore-start */\n${source}\n/* jscpd:ignore-end */\n` }, async (root) => { + expect( + await scan(root).then( + () => "", + (err: unknown) => String(err), + ), + ).toContain("Inline duplication suppression is not allowed") + }) + }, + timeout, +) + +test( + "detects CSS and handwritten JetBrains Kotlin", + async () => { + const css = `.box {\n${Array.from({ length: 20 }, (_, index) => ` --shade-${index}: rgb(${index}, 0, 0);`).join("\n")}\n}\n` + const kotlin = `fun summarize(input: List): Map { + val positive = input.filter { it > 0 } + val negative = input.filter { it < 0 } + val total = positive.sum() + val sorted = positive.sorted() + val first = sorted.firstOrNull() ?: 0 + val last = sorted.lastOrNull() ?: 0 + val average = if (positive.isEmpty()) 0 else total / positive.size + return mapOf( + "count" to input.size, + "positive" to positive.size, + "negative" to negative.size, + "total" to total, + "first" to first, + "last" to last, + "average" to average, + "range" to last - first, + ) +} +` + await fixture( + { + "packages/kilo-example/src/first.css": css, + "packages/kilo-example/src/second.css": css, + "packages/kilo-jetbrains/frontend/src/main/kotlin/First.kt": kotlin, + "packages/kilo-jetbrains/backend/src/main/kotlin/Second.kt": kotlin, + }, + async (root) => { + const result = await scan(root) + expect(result.findings.some((finding) => finding.files.every((file) => file.endsWith(".css")))).toBe(true) + expect(result.findings.some((finding) => finding.files.every((file) => file.endsWith(".kt")))).toBe(true) + }, + ) + }, + timeout, +) + +test( + "the CLI rejects new copies and only prunes resolved debt", + async () => { + await fixture({ [first]: source, [second]: source }, async (root) => { + const script = "script/check-kilocode-duplication.ts" + await mkdir(join(root, "script")) + await copyFile(resolve(import.meta.dir, "../../../", script), join(root, script)) + const invoke = async (args: string[] = []) => { + const proc = Bun.spawn([process.execPath, script, ...args], { + cwd: root, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + return { code, text: stdout + stderr } + } + expect((await invoke(["--init"])).code).toBe(0) + const baseline = Bun.file(join(root, "script/kilocode-duplication-allowlist.json")) + const initial = await baseline.text() + expect((await invoke()).code).toBe(0) + const third = join(root, "packages/kilo-example/src/third.ts") + await Bun.write(third, source) + const failed = await invoke() + expect(failed.code).toBe(1) + expect(failed.text).toContain("Unclassified duplication") + expect((await invoke(["--prune"])).code).toBe(1) + expect(await baseline.text()).toBe(initial) + expect((await invoke(["--init"])).code).toBe(1) + expect(await baseline.text()).toBe(initial) + await Promise.all([rm(join(root, second)), rm(third)]) + const stale = await invoke() + expect(stale.code).toBe(1) + expect(stale.text).toContain("Stale exception") + expect((await invoke(["--prune"])).code).toBe(0) + expect(parse(await baseline.json())).toEqual([]) + expect((await invoke()).code).toBe(0) + const compact = JSON.stringify(await baseline.json()) + await Bun.write(baseline, compact) + expect((await invoke(["--prune"])).code).toBe(0) + expect(await baseline.text()).toBe(compact) + }) + }, + timeout, +) + +test("rejects malformed and duplicate exception entries", () => { + const entry: Exception = { + files: [first, second], + fingerprint: "0123456789abcdef", + maxMatches: 1, + maxTokens: 100, + kind: "legacy", + owner: "scanner-tests", + reason: "Existing fixture duplication awaiting cleanup.", + } + const data = { version: 1, scanner: "jscpd@5.0.16", exceptions: [entry] } + expect(parse(data)).toEqual([entry]) + for (const change of [ + { reason: " " }, + { owner: "" }, + { fingerprint: "missing" }, + { maxMatches: 0 }, + { maxTokens: -1 }, + { kind: "ignore" }, + { files: [second, first] }, + { files: ["../outside.ts", second] }, + { files: [first] }, + ]) { + expect(() => parse({ ...data, exceptions: [{ ...entry, ...change }] })).toThrow() + } + expect(() => parse({ ...data, exceptions: [entry, entry] })).toThrow("Duplicate exception") + expect(() => parse({ ...data, scanner: "jscpd@latest" })).toThrow("Unsupported duplication allowlist") + expect(() => parse({ ...data, exceptions: undefined })).toThrow() +}) diff --git a/script/check-kilocode-duplication.ts b/script/check-kilocode-duplication.ts new file mode 100644 index 00000000000..06fe81f2909 --- /dev/null +++ b/script/check-kilocode-duplication.ts @@ -0,0 +1,351 @@ +import { lstat, mkdtemp, realpath, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { isAbsolute, join, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const scanner = "jscpd@5.0.16" +const root = resolve(import.meta.dir, "..") +const filename = "script/kilocode-duplication-allowlist.json" +const roots = [ + "packages/kilo-*/src", + "packages/kilo-vscode/webview-ui", + "packages/kilo-jetbrains/*/src/main/kotlin", + "packages/plugin-atomic-chat/src", + "packages/*/src/kilocode", + "packages/*/src/kilo-*", +] +const excluded = { + "packages/kilo-i18n/**": "Locale dictionaries", + "packages/kilo-docs/**": "Documentation", + "**/node_modules/**": "Dependencies", + "**/dist/**": "Build output", + "**/build/**": "Build output", + "**/out/**": "Build output", + "**/coverage/**": "Test output", + "**/__tests__/**": "Tests", + "**/tests/**": "Tests", + "**/test/**": "Tests", + "**/fixture/**": "Fixtures", + "**/fixtures/**": "Fixtures", + "**/__fixtures__/**": "Fixtures", + "**/recordings/**": "Recorded test data", + "**/__snapshots__/**": "Test snapshots", + "**/testdata/**": "Test data", + "**/stories/**": "Component examples", + "**/*.test.*": "Tests", + "**/*.spec.*": "Tests", + "**/*.stories.*": "Component examples", + "**/*.d.ts": "Ambient declarations", + "**/*.gen.ts": "Generated source", + "**/i18n/**": "Locale dictionaries", + "**/locales/**": "Locale dictionaries", + "**/translations/**": "Locale dictionaries", + "packages/kilo-vscode/src/services/autocomplete/continuedev/**": "Vendored Continue implementation", + "**/examples/**": "Examples", +} +const ignored = Object.keys(excluded).map((pattern) => new Bun.Glob(pattern)) + +export type Finding = { + files: string[] + fingerprint: string + matches: number + tokens: number + locations: { file: string; start: number; end: number }[] +} + +export type Exception = { + files: string[] + fingerprint: string + maxMatches: number + maxTokens: number + kind: "legacy" | "intentional" + owner: string + reason: string +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function object(value: unknown) { + if (!record(value)) throw new Error("Expected a JSON object") + return value +} + +function array(value: unknown) { + if (!Array.isArray(value)) throw new Error("Expected a JSON array") + return value as unknown[] +} + +function text(value: unknown) { + if (typeof value !== "string" || !value.trim()) throw new Error("Expected a non-empty string") + return value +} + +function integer(value: unknown, minimum = 0) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { + throw new Error(`Expected an integer of at least ${minimum}`) + } + return value +} + +function path(value: unknown) { + const file = text(value) + if ( + isAbsolute(file) || + file.includes("\\") || + file.split("/").some((part) => !part || part === ".." || part === ".") + ) { + throw new Error(`Expected a repository-relative path: ${file}`) + } + return file +} + +function fingerprint(value: unknown) { + const hash = text(value) + if (!/^[a-f0-9]{16}$/.test(hash)) throw new Error(`Invalid duplication fingerprint: ${hash}`) + return hash +} + +function key(entry: Pick) { + return JSON.stringify([entry.files, entry.fingerprint]) +} + +export function parse(value: unknown): Exception[] { + const data = object(value) + if (data.version !== 1 || data.scanner !== scanner) + throw new Error("Unsupported duplication allowlist version or scanner") + const seen = new Set() + return array(data.exceptions).map((value) => { + const item = object(value) + const files = array(item.files).map(path) + if (files.length !== 2 || files.join("\0") !== files.toSorted().join("\0")) { + throw new Error("An exception must contain exactly two sorted file paths") + } + const kind = item.kind + if (kind !== "legacy" && kind !== "intentional") throw new Error("An exception must be legacy or intentional") + const entry: Exception = { + files, + fingerprint: fingerprint(item.fingerprint), + maxMatches: integer(item.maxMatches, 1), + maxTokens: integer(item.maxTokens, 1), + kind, + owner: text(item.owner), + reason: text(item.reason), + } + const id = key(entry) + if (seen.has(id)) throw new Error(`Duplicate exception: ${files.join(" and ")}`) + seen.add(id) + return entry + }) +} + +export async function scan(cwd: string) { + const directory = await realpath(cwd) + const candidates = new Set() + for (const scope of roots) { + const glob = new Bun.Glob(`${scope}/**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts,kt,kts,java,css}`) + for await (const file of glob.scan({ cwd: directory, onlyFiles: true, followSymlinks: false })) { + const normalized = file.replaceAll("\\", "/") + if (!ignored.some((glob) => glob.match(normalized))) candidates.add(normalized) + } + } + const files: string[] = [] + for (const file of [...candidates].sort()) { + const absolute = join(directory, file) + const info = await lstat(absolute) + if (info.isSymbolicLink()) continue + if (info.size > 10 * 1024 * 1024) throw new Error(`Source exceeds the duplication scanner size limit: ${file}`) + const content = await Bun.file(absolute).text() + if (/jscpd:ignore-(?:start|end)/.test(content)) { + throw new Error(`Inline duplication suppression is not allowed: ${file}. Use a bounded exception instead.`) + } + files.push(absolute) + } + if (files.length === 0) throw new Error("No Kilo-owned source files found for duplication analysis") + const temporary = await mkdtemp(join(tmpdir(), "kilo-duplication-")) + try { + const config = join(temporary, "config.json") + await Bun.write( + config, + JSON.stringify({ + path: files, + mode: "weak", + minLines: 10, + minTokens: 100, + format: ["typescript", "tsx", "javascript", "jsx", "kotlin", "java", "css"], + crossFormats: [["typescript", "tsx"]], + maxSize: "10mb", + absolute: true, + noColors: true, + noTips: true, + reporters: ["json", "sarif"], + output: temporary, + }), + ) + const proc = Bun.spawn( + [process.execPath, "x", "--package", scanner, "jscpd", "--config", config, "--workers", "1", "--no-gitignore"], + { cwd: directory, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Duplication scanner failed (${code}):\n${stderr || stdout}`) + const json = object(await Bun.file(join(temporary, "jscpd-report.json")).json()) + const total = object(object(json.statistics).total) + const report = object(await Bun.file(join(temporary, "jscpd-report.sarif")).json()) + const runs = array(report.runs) + if (runs.length !== 1) throw new Error("Expected one duplication scanner run") + const run = object(runs.at(0)) + if (object(object(run.tool).driver).version !== scanner.slice("jscpd@".length)) { + throw new Error("Unexpected duplication scanner version") + } + const results = array(run.results) + if (integer(total.clones) !== results.length) + throw new Error("Duplication reports disagree on the number of findings") + const findings = new Map() + for (const value of results) { + const result = object(value) + if (result.ruleId !== "jscpd/duplicate-code") throw new Error("Unexpected duplication scanner result") + const locations = [...array(result.locations), ...array(result.relatedLocations)].map((value) => { + const location = object(object(value).physicalLocation) + const artifact = object(location.artifactLocation) + const uri = text(artifact.uri) + const absolute = isAbsolute(uri) + ? uri + : fileURLToPath(new URL(uri, text(object(object(run.originalUriBaseIds)[text(artifact.uriBaseId)]).uri))) + const region = object(location.region) + return { + file: path(relative(directory, absolute).replaceAll("\\", "/")), + start: integer(region.startLine, 1), + end: integer(region.endLine, 1), + } + }) + if (locations.length !== 2) throw new Error("Expected two locations for a duplicated block") + const files = locations.map((location) => location.file).sort() + const hash = fingerprint(object(result.partialFingerprints)["jscpdCloneHash/v1"]) + const tokens = integer(object(result.properties).token_count, 1) + const id = key({ files, fingerprint: hash }) + const previous = findings.get(id) + findings.set(id, { + files, + fingerprint: hash, + matches: (previous?.matches ?? 0) + 1, + tokens: Math.max(previous?.tokens ?? 0, tokens), + locations: [...(previous?.locations ?? []), ...locations], + }) + } + return { + scanner, + files: integer(total.sources, 1), + lines: integer(total.lines, 1), + pairs: results.length, + duplicatedLines: integer(total.duplicatedLines), + duplicatedTokens: integer(total.duplicatedTokens), + findings: [...findings.values()].sort((a, b) => key(a).localeCompare(key(b))), + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +export function compare(findings: Finding[], exceptions: Exception[]) { + const allowed = new Map(exceptions.map((entry) => [key(entry), entry])) + const current = new Set(findings.map(key)) + const failures: string[] = [] + for (const finding of findings) { + const entry = allowed.get(key(finding)) + const locations = finding.locations + .map((location) => `${location.file}:${location.start}-${location.end}`) + .join(" and ") + if (!entry) { + failures.push(`Unclassified duplication (${finding.tokens} tokens, ${finding.matches} match(es)): ${locations}`) + continue + } + if (finding.matches > entry.maxMatches || finding.tokens > entry.maxTokens) { + failures.push( + `Duplication grew: ${locations}. Matches ${finding.matches}/${entry.maxMatches}, tokens ${finding.tokens}/${entry.maxTokens}. ${entry.reason}`, + ) + } + } + for (const entry of exceptions) { + if (!current.has(key(entry))) + failures.push( + `Stale exception: ${entry.files.join(" and ")} (${entry.fingerprint}). Remove it to lock in cleanup.`, + ) + } + return failures +} + +export function prune(findings: Finding[], exceptions: Exception[]) { + const current = new Map(findings.map((finding) => [key(finding), finding])) + return exceptions.flatMap((entry) => { + const finding = current.get(key(entry)) + return finding + ? [ + { + ...entry, + maxMatches: Math.min(entry.maxMatches, finding.matches), + maxTokens: Math.min(entry.maxTokens, finding.tokens), + }, + ] + : [] + }) +} + +async function main() { + const args = process.argv.slice(2) + const mode = args.at(0) + if (args.length > 1 || (mode && !["--help", "--report", "--init", "--prune"].includes(mode))) { + throw new Error("Usage: bun run check:duplication [--report | --prune | --init | --help]") + } + if (mode === "--help") { + console.log( + "Check Kilo-owned production code for copied blocks of at least 10 lines and 100 tokens.\n" + + "--report prints findings without changing the allowlist.\n" + + "--prune only removes stale exceptions and lowers existing limits; new findings still fail.\n" + + "--init creates the initial legacy baseline and refuses to overwrite an existing allowlist.\n" + + "Shared upstream files, docs, translations, tests, generated source and vendored code are outside this ratchet.", + ) + return + } + const file = Bun.file(join(root, filename)) + if (mode === "--init" && (await file.exists())) + throw new Error("The duplication allowlist already exists; review individual exceptions instead") + const result = await scan(root) + if (mode === "--report") { + console.log(JSON.stringify(result, null, 2)) + return + } + const initial: Exception[] = result.findings.map((finding) => ({ + files: finding.files, + fingerprint: finding.fingerprint, + maxMatches: finding.matches, + maxTokens: finding.tokens, + kind: "legacy", + owner: finding.files.at(0)?.split("/").at(1) ?? "kilo", + reason: "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction.", + })) + const previous = mode === "--init" ? initial : parse(await file.json()) + const exceptions = mode === "--prune" ? prune(result.findings, previous) : previous + const failures = compare(result.findings, exceptions) + if (failures.length) + throw new Error(`${failures.join("\n")}\nRefactor new copies; review bounded exceptions in ${filename}.`) + if (mode === "--init" || (mode === "--prune" && JSON.stringify(exceptions) !== JSON.stringify(previous))) { + await Bun.write(file, `${JSON.stringify({ version: 1, scanner, exceptions }, null, 2)}\n`) + } + const percentage = ((100 * result.duplicatedLines) / result.lines).toFixed(2) + console.log( + `check:duplication: ${result.pairs} block pairs, ${result.duplicatedLines} duplicated lines (${percentage}%), ${result.files} eligible files.\n` + + `${exceptions.length} bounded exceptions; no new duplication.`, + ) +} + +if (import.meta.main) { + await main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + }) +} diff --git a/script/kilocode-duplication-allowlist.json b/script/kilocode-duplication-allowlist.json new file mode 100644 index 00000000000..8e2c06a9e28 --- /dev/null +++ b/script/kilocode-duplication-allowlist.json @@ -0,0 +1,960 @@ +{ + "version": 1, + "scanner": "jscpd@5.0.16", + "exceptions": [ + { + "files": [ + "packages/kilo-console/src/components/app-header/OmniSearch.tsx", + "packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx" + ], + "fingerprint": "4d9a3ec7cfd2029a", + "maxMatches": 1, + "maxTokens": 187, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/components/app-header/OmniSearch.tsx", + "packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx" + ], + "fingerprint": "efd52fea0edba460", + "maxMatches": 1, + "maxTokens": 102, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/components/app-header/OmniSearch.tsx", + "packages/kilo-console/src/routes/profile/server.ts" + ], + "fingerprint": "52cd8ba8418743c4", + "maxMatches": 1, + "maxTokens": 101, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/components/app-header/OmniSearch.tsx", + "packages/kilo-console/src/routes/projects/ProjectsRoute.tsx" + ], + "fingerprint": "cb4ef004cc2151c4", + "maxMatches": 1, + "maxTokens": 155, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/routes/config/AgentsRoute.tsx", + "packages/kilo-console/src/routes/config/IndexingRoute.tsx" + ], + "fingerprint": "1642f96ca061cdcf", + "maxMatches": 1, + "maxTokens": 112, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx", + "packages/kilo-console/src/routes/config/CliUiRoute.tsx" + ], + "fingerprint": "3d8c2a788d682c4e", + "maxMatches": 1, + "maxTokens": 143, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/routes/config/state/agents.ts", + "packages/kilo-console/src/routes/config/state/models.ts" + ], + "fingerprint": "1da5f78ab9ff1975", + "maxMatches": 1, + "maxTokens": 164, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/routes/config/state/agents.ts", + "packages/kilo-console/src/routes/config/state/models.ts" + ], + "fingerprint": "f0f56e89b4d2ff59", + "maxMatches": 1, + "maxTokens": 171, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-console/src/routes/config/state/mcp.ts", + "packages/kilo-console/src/routes/config/state/mcp.ts" + ], + "fingerprint": "64494aa992cccc8d", + "maxMatches": 1, + "maxTokens": 129, + "kind": "legacy", + "owner": "kilo-console", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-gateway/src/auth/device-auth-tui.ts", "packages/kilo-gateway/src/auth/device-auth.ts"], + "fingerprint": "e6ac9a2221c1deb4", + "maxMatches": 1, + "maxTokens": 194, + "kind": "legacy", + "owner": "kilo-gateway", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts", + "packages/kilo-indexing/src/indexing/embedders/openrouter.ts" + ], + "fingerprint": "45840c5da8273cfe", + "maxMatches": 1, + "maxTokens": 168, + "kind": "legacy", + "owner": "kilo-indexing", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt" + ], + "fingerprint": "8785de60f9d8997a", + "maxMatches": 1, + "maxTokens": 103, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt" + ], + "fingerprint": "cf74d689a64abbd2", + "maxMatches": 1, + "maxTokens": 105, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt" + ], + "fingerprint": "b19588ac32686d1a", + "maxMatches": 1, + "maxTokens": 103, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt" + ], + "fingerprint": "05c8c8a650fe6dcd", + "maxMatches": 1, + "maxTokens": 125, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt" + ], + "fingerprint": "bac931856f5d70b3", + "maxMatches": 1, + "maxTokens": 225, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" + ], + "fingerprint": "230834ae94e1bdf9", + "maxMatches": 1, + "maxTokens": 116, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt" + ], + "fingerprint": "e61e3556f8972a38", + "maxMatches": 1, + "maxTokens": 123, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloProviderService.kt" + ], + "fingerprint": "8eddf146178c10ba", + "maxMatches": 1, + "maxTokens": 101, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiImpl.kt", + "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProviderRpcApi.kt" + ], + "fingerprint": "a99c672b436bbd9e", + "maxMatches": 1, + "maxTokens": 115, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt" + ], + "fingerprint": "2a0e21791bb10e09", + "maxMatches": 1, + "maxTokens": 139, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt", + "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt" + ], + "fingerprint": "a7a17138d319676b", + "maxMatches": 1, + "maxTokens": 185, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt" + ], + "fingerprint": "dd5490a22f91c32b", + "maxMatches": 1, + "maxTokens": 151, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt", + "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt" + ], + "fingerprint": "93af05cd3e742478", + "maxMatches": 1, + "maxTokens": 148, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt" + ], + "fingerprint": "c3232c8eb9a08ead", + "maxMatches": 1, + "maxTokens": 115, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" + ], + "fingerprint": "89c4f899570ecdf6", + "maxMatches": 1, + "maxTokens": 155, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" + ], + "fingerprint": "ba979cc1db897f99", + "maxMatches": 1, + "maxTokens": 106, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt" + ], + "fingerprint": "514a7faceed4d922", + "maxMatches": 1, + "maxTokens": 119, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" + ], + "fingerprint": "3a4de464ec9334c1", + "maxMatches": 1, + "maxTokens": 160, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" + ], + "fingerprint": "41b75b6c004e87e2", + "maxMatches": 1, + "maxTokens": 179, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", + "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" + ], + "fingerprint": "44d7384de3b2be6d", + "maxMatches": 1, + "maxTokens": 133, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt", + "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt" + ], + "fingerprint": "4b2726614c9c8770", + "maxMatches": 1, + "maxTokens": 115, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt" + ], + "fingerprint": "09aebb0f9e029bea", + "maxMatches": 1, + "maxTokens": 111, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt" + ], + "fingerprint": "c5400e01c299153e", + "maxMatches": 1, + "maxTokens": 186, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt" + ], + "fingerprint": "516b27fbb4086626", + "maxMatches": 1, + "maxTokens": 108, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt" + ], + "fingerprint": "b7b600e48193f8a8", + "maxMatches": 1, + "maxTokens": 112, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt" + ], + "fingerprint": "1e17965e9ae5867b", + "maxMatches": 1, + "maxTokens": 103, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt" + ], + "fingerprint": "6d680ef39343238f", + "maxMatches": 1, + "maxTokens": 188, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt" + ], + "fingerprint": "bacd1e3d15e6623c", + "maxMatches": 1, + "maxTokens": 102, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt" + ], + "fingerprint": "d7732636b7631348", + "maxMatches": 1, + "maxTokens": 140, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt" + ], + "fingerprint": "636513e3153419dc", + "maxMatches": 1, + "maxTokens": 297, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" + ], + "fingerprint": "003a004f4ba3de07", + "maxMatches": 1, + "maxTokens": 137, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" + ], + "fingerprint": "399c34c895629e98", + "maxMatches": 1, + "maxTokens": 112, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" + ], + "fingerprint": "4e1954ebf51efa9a", + "maxMatches": 1, + "maxTokens": 135, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" + ], + "fingerprint": "e8af81b39a51efd5", + "maxMatches": 1, + "maxTokens": 117, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" + ], + "fingerprint": "bcbb813709525760", + "maxMatches": 1, + "maxTokens": 101, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt" + ], + "fingerprint": "3b2719dc703c2e2a", + "maxMatches": 1, + "maxTokens": 150, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt" + ], + "fingerprint": "b6b0498d20608a76", + "maxMatches": 1, + "maxTokens": 125, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt" + ], + "fingerprint": "182f5d4212a54002", + "maxMatches": 1, + "maxTokens": 134, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt" + ], + "fingerprint": "798762edd934cf40", + "maxMatches": 1, + "maxTokens": 152, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/WorkflowsConfigurable.kt" + ], + "fingerprint": "6a0a61ed994a1440", + "maxMatches": 1, + "maxTokens": 104, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt" + ], + "fingerprint": "7f1d00dee6941d7a", + "maxMatches": 1, + "maxTokens": 128, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt" + ], + "fingerprint": "35bd87a9583941c9", + "maxMatches": 1, + "maxTokens": 130, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt" + ], + "fingerprint": "eee092f7fd1a16b5", + "maxMatches": 1, + "maxTokens": 135, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/FlowMarks.kt", + "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt" + ], + "fingerprint": "9885d82e15495d73", + "maxMatches": 1, + "maxTokens": 152, + "kind": "legacy", + "owner": "kilo-jetbrains", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-memory/src/effect/capture.ts", "packages/kilo-memory/src/effect/capture.ts"], + "fingerprint": "fc721da29f556180", + "maxMatches": 1, + "maxTokens": 144, + "kind": "legacy", + "owner": "kilo-memory", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-ui/src/components/basic-tool.css", "packages/kilo-ui/src/components/message-part.css"], + "fingerprint": "2bcd290f6a1dfcff", + "maxMatches": 1, + "maxTokens": 138, + "kind": "legacy", + "owner": "kilo-ui", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-ui/src/components/list.css", "packages/kilo-ui/src/components/model-selector.css"], + "fingerprint": "8c547d9472640a65", + "maxMatches": 1, + "maxTokens": 109, + "kind": "legacy", + "owner": "kilo-ui", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-ui/src/components/message-part.tsx", "packages/kilo-ui/src/components/message-part.tsx"], + "fingerprint": "e4dd50a081850b7d", + "maxMatches": 1, + "maxTokens": 214, + "kind": "legacy", + "owner": "kilo-ui", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-ui/src/components/message-part.tsx", + "packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx" + ], + "fingerprint": "16e0edc3d99b9858", + "maxMatches": 1, + "maxTokens": 111, + "kind": "legacy", + "owner": "kilo-ui", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/agent-manager/diff-scope.ts", + "packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts" + ], + "fingerprint": "b4d0b555891237a8", + "maxMatches": 1, + "maxTokens": 233, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts", + "packages/kilo-vscode/src/services/notebook/bridge.ts" + ], + "fingerprint": "17677ff47d3bf883", + "maxMatches": 1, + "maxTokens": 134, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts", + "packages/kilo-vscode/src/agent-manager/terminal-manager.ts" + ], + "fingerprint": "83aaa96c32fb3894", + "maxMatches": 1, + "maxTokens": 127, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/agent-manager/types.ts", + "packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts" + ], + "fingerprint": "01014e9610c0c91c", + "maxMatches": 1, + "maxTokens": 159, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-vscode/src/diff/sources/staged.ts", "packages/kilo-vscode/src/diff/sources/unstaged.ts"], + "fingerprint": "2aad5496387de63a", + "maxMatches": 1, + "maxTokens": 131, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/kilo-vscode/src/extension.ts", "packages/kilo-vscode/src/extension.ts"], + "fingerprint": "acf42b6bbd0b3d17", + "maxMatches": 1, + "maxTokens": 112, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts", + "packages/kilo-vscode/src/kilo-provider/handlers/question.ts" + ], + "fingerprint": "edde84a63bcdb8a0", + "maxMatches": 1, + "maxTokens": 108, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/src/services/marketplace/types.ts", + "packages/kilo-vscode/webview-ui/src/types/marketplace.ts" + ], + "fingerprint": "96a848236212dea1", + "maxMatches": 1, + "maxTokens": 171, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css", + "packages/kilo-vscode/webview-ui/src/styles/session-tabs.css" + ], + "fingerprint": "73d7672dd2e6cd26", + "maxMatches": 1, + "maxTokens": 155, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css", + "packages/kilo-vscode/webview-ui/src/styles/session-tabs.css" + ], + "fingerprint": "9020d3c20123ef72", + "maxMatches": 1, + "maxTokens": 104, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx", + "packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx" + ], + "fingerprint": "0772e382c935aa93", + "maxMatches": 1, + "maxTokens": 122, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx", + "packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx" + ], + "fingerprint": "94761ffc9ac99167", + "maxMatches": 1, + "maxTokens": 155, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts", + "packages/opencode/src/kilocode/cli/cmd/run/variant.ts" + ], + "fingerprint": "ea48f1bd59216acd", + "maxMatches": 1, + "maxTokens": 143, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/src/hooks/useGitChangesContext.ts", + "packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts" + ], + "fingerprint": "3474d577ea4814b0", + "maxMatches": 1, + "maxTokens": 105, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css", + "packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css" + ], + "fingerprint": "aee459acfb62435f", + "maxMatches": 1, + "maxTokens": 109, + "kind": "legacy", + "owner": "kilo-vscode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/opencode/src/kilocode/agent/index.ts", "packages/opencode/src/kilocode/agent/index.ts"], + "fingerprint": "9044b540f896a694", + "maxMatches": 1, + "maxTokens": 113, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-memory.tsx", + "packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-memory.tsx" + ], + "fingerprint": "8f8743c15cedec86", + "maxMatches": 1, + "maxTokens": 115, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/opencode/src/kilocode/components/dialog-claw-setup.tsx", + "packages/opencode/src/kilocode/components/dialog-claw-upgrade.tsx" + ], + "fingerprint": "89ef84cc3499824c", + "maxMatches": 1, + "maxTokens": 100, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts", + "packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts" + ], + "fingerprint": "ec31254ab83e4a2a", + "maxMatches": 1, + "maxTokens": 133, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/opencode/src/kilocode/session/prompt.ts", "packages/opencode/src/kilocode/session/prompt.ts"], + "fingerprint": "b985f7708c9b4632", + "maxMatches": 1, + "maxTokens": 109, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": ["packages/opencode/src/kilocode/session/prompt.ts", "packages/opencode/src/kilocode/session/prompt.ts"], + "fingerprint": "dda23e2fc1e7c4f3", + "maxMatches": 1, + "maxTokens": 111, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + }, + { + "files": [ + "packages/opencode/src/kilocode/tool/agent-manager.ts", + "packages/opencode/src/kilocode/tool/notebook-host.ts" + ], + "fingerprint": "a1b1dcac57a91162", + "maxMatches": 1, + "maxTokens": 110, + "kind": "legacy", + "owner": "opencode", + "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." + } + ] +} From 3a923e09651ebf5a11cdaeda0dfa880a34e2943d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 31 Aug 2026 13:15:31 +0200 Subject: [PATCH 2/2] chore: exclude JetBrains from duplication checks --- .../workflows/check-opencode-annotations.yml | 1 + .../tests/check-kilocode-duplication.test.ts | 32 +- script/check-kilocode-duplication.ts | 8 +- script/kilocode-duplication-allowlist.json | 528 ------------------ 4 files changed, 11 insertions(+), 558 deletions(-) diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 604eaa83669..aa41b12d099 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -18,6 +18,7 @@ on: - "script/**" - "package.json" - "bun.lock" + - "!packages/kilo-jetbrains/**" workflow_dispatch: jobs: diff --git a/packages/script/tests/check-kilocode-duplication.test.ts b/packages/script/tests/check-kilocode-duplication.test.ts index ab34b996fc2..9b967acefa2 100644 --- a/packages/script/tests/check-kilocode-duplication.test.ts +++ b/packages/script/tests/check-kilocode-duplication.test.ts @@ -148,7 +148,7 @@ test( ) test( - "includes new Kilo packages without counting upstream, generated, locale or fixture files", + "includes new Kilo packages without counting JetBrains, upstream, generated, locale or fixture files", async () => { await fixture( { @@ -161,6 +161,8 @@ test( "packages/kilo-example/src/copy.d.ts": source, "packages/kilo-i18n/src/en.ts": source, "packages/kilo-docs/src/copy.ts": source, + "packages/kilo-jetbrains/src/first.ts": source, + "packages/kilo-jetbrains/src/second.tsx": source, "packages/kilo-vscode/src/services/autocomplete/continuedev/copy.ts": source, }, async (root) => { @@ -197,40 +199,18 @@ test( ) test( - "detects CSS and handwritten JetBrains Kotlin", + "detects duplicated CSS blocks", async () => { const css = `.box {\n${Array.from({ length: 20 }, (_, index) => ` --shade-${index}: rgb(${index}, 0, 0);`).join("\n")}\n}\n` - const kotlin = `fun summarize(input: List): Map { - val positive = input.filter { it > 0 } - val negative = input.filter { it < 0 } - val total = positive.sum() - val sorted = positive.sorted() - val first = sorted.firstOrNull() ?: 0 - val last = sorted.lastOrNull() ?: 0 - val average = if (positive.isEmpty()) 0 else total / positive.size - return mapOf( - "count" to input.size, - "positive" to positive.size, - "negative" to negative.size, - "total" to total, - "first" to first, - "last" to last, - "average" to average, - "range" to last - first, - ) -} -` await fixture( { "packages/kilo-example/src/first.css": css, "packages/kilo-example/src/second.css": css, - "packages/kilo-jetbrains/frontend/src/main/kotlin/First.kt": kotlin, - "packages/kilo-jetbrains/backend/src/main/kotlin/Second.kt": kotlin, }, async (root) => { const result = await scan(root) - expect(result.findings.some((finding) => finding.files.every((file) => file.endsWith(".css")))).toBe(true) - expect(result.findings.some((finding) => finding.files.every((file) => file.endsWith(".kt")))).toBe(true) + expect(result.pairs).toBe(1) + expect(result.findings.at(0)?.files.every((file) => file.endsWith(".css"))).toBe(true) }, ) }, diff --git a/script/check-kilocode-duplication.ts b/script/check-kilocode-duplication.ts index 06fe81f2909..5f2f1e57e8c 100644 --- a/script/check-kilocode-duplication.ts +++ b/script/check-kilocode-duplication.ts @@ -9,12 +9,12 @@ const filename = "script/kilocode-duplication-allowlist.json" const roots = [ "packages/kilo-*/src", "packages/kilo-vscode/webview-ui", - "packages/kilo-jetbrains/*/src/main/kotlin", "packages/plugin-atomic-chat/src", "packages/*/src/kilocode", "packages/*/src/kilo-*", ] const excluded = { + "packages/kilo-jetbrains/**": "Outside the VS Code scope", "packages/kilo-i18n/**": "Locale dictionaries", "packages/kilo-docs/**": "Documentation", "**/node_modules/**": "Dependencies", @@ -144,7 +144,7 @@ export async function scan(cwd: string) { const directory = await realpath(cwd) const candidates = new Set() for (const scope of roots) { - const glob = new Bun.Glob(`${scope}/**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts,kt,kts,java,css}`) + const glob = new Bun.Glob(`${scope}/**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts,css}`) for await (const file of glob.scan({ cwd: directory, onlyFiles: true, followSymlinks: false })) { const normalized = file.replaceAll("\\", "/") if (!ignored.some((glob) => glob.match(normalized))) candidates.add(normalized) @@ -173,7 +173,7 @@ export async function scan(cwd: string) { mode: "weak", minLines: 10, minTokens: 100, - format: ["typescript", "tsx", "javascript", "jsx", "kotlin", "java", "css"], + format: ["typescript", "tsx", "javascript", "jsx", "css"], crossFormats: [["typescript", "tsx"]], maxSize: "10mb", absolute: true, @@ -307,7 +307,7 @@ async function main() { "--report prints findings without changing the allowlist.\n" + "--prune only removes stale exceptions and lowers existing limits; new findings still fail.\n" + "--init creates the initial legacy baseline and refuses to overwrite an existing allowlist.\n" + - "Shared upstream files, docs, translations, tests, generated source and vendored code are outside this ratchet.", + "JetBrains, shared upstream files, docs, translations, tests, generated source and vendored code are outside this ratchet.", ) return } diff --git a/script/kilocode-duplication-allowlist.json b/script/kilocode-duplication-allowlist.json index 8e2c06a9e28..9df267a663f 100644 --- a/script/kilocode-duplication-allowlist.json +++ b/script/kilocode-duplication-allowlist.json @@ -131,534 +131,6 @@ "owner": "kilo-indexing", "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt" - ], - "fingerprint": "8785de60f9d8997a", - "maxMatches": 1, - "maxTokens": 103, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt" - ], - "fingerprint": "cf74d689a64abbd2", - "maxMatches": 1, - "maxTokens": 105, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt" - ], - "fingerprint": "b19588ac32686d1a", - "maxMatches": 1, - "maxTokens": 103, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt" - ], - "fingerprint": "05c8c8a650fe6dcd", - "maxMatches": 1, - "maxTokens": 125, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt" - ], - "fingerprint": "bac931856f5d70b3", - "maxMatches": 1, - "maxTokens": 225, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" - ], - "fingerprint": "230834ae94e1bdf9", - "maxMatches": 1, - "maxTokens": 116, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt" - ], - "fingerprint": "e61e3556f8972a38", - "maxMatches": 1, - "maxTokens": 123, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloProviderService.kt" - ], - "fingerprint": "8eddf146178c10ba", - "maxMatches": 1, - "maxTokens": 101, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiImpl.kt", - "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProviderRpcApi.kt" - ], - "fingerprint": "a99c672b436bbd9e", - "maxMatches": 1, - "maxTokens": 115, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt" - ], - "fingerprint": "2a0e21791bb10e09", - "maxMatches": 1, - "maxTokens": 139, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt", - "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt" - ], - "fingerprint": "a7a17138d319676b", - "maxMatches": 1, - "maxTokens": 185, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt" - ], - "fingerprint": "dd5490a22f91c32b", - "maxMatches": 1, - "maxTokens": 151, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt", - "packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt" - ], - "fingerprint": "93af05cd3e742478", - "maxMatches": 1, - "maxTokens": 148, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt" - ], - "fingerprint": "c3232c8eb9a08ead", - "maxMatches": 1, - "maxTokens": 115, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" - ], - "fingerprint": "89c4f899570ecdf6", - "maxMatches": 1, - "maxTokens": 155, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt" - ], - "fingerprint": "ba979cc1db897f99", - "maxMatches": 1, - "maxTokens": 106, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt" - ], - "fingerprint": "514a7faceed4d922", - "maxMatches": 1, - "maxTokens": 119, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" - ], - "fingerprint": "3a4de464ec9334c1", - "maxMatches": 1, - "maxTokens": 160, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" - ], - "fingerprint": "41b75b6c004e87e2", - "maxMatches": 1, - "maxTokens": 179, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt", - "packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt" - ], - "fingerprint": "44d7384de3b2be6d", - "maxMatches": 1, - "maxTokens": 133, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt", - "packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt" - ], - "fingerprint": "4b2726614c9c8770", - "maxMatches": 1, - "maxTokens": 115, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt" - ], - "fingerprint": "09aebb0f9e029bea", - "maxMatches": 1, - "maxTokens": 111, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt" - ], - "fingerprint": "c5400e01c299153e", - "maxMatches": 1, - "maxTokens": 186, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt" - ], - "fingerprint": "516b27fbb4086626", - "maxMatches": 1, - "maxTokens": 108, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt" - ], - "fingerprint": "b7b600e48193f8a8", - "maxMatches": 1, - "maxTokens": 112, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt" - ], - "fingerprint": "1e17965e9ae5867b", - "maxMatches": 1, - "maxTokens": 103, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt" - ], - "fingerprint": "6d680ef39343238f", - "maxMatches": 1, - "maxTokens": 188, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt" - ], - "fingerprint": "bacd1e3d15e6623c", - "maxMatches": 1, - "maxTokens": 102, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt" - ], - "fingerprint": "d7732636b7631348", - "maxMatches": 1, - "maxTokens": 140, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt" - ], - "fingerprint": "636513e3153419dc", - "maxMatches": 1, - "maxTokens": 297, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" - ], - "fingerprint": "003a004f4ba3de07", - "maxMatches": 1, - "maxTokens": 137, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" - ], - "fingerprint": "399c34c895629e98", - "maxMatches": 1, - "maxTokens": 112, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" - ], - "fingerprint": "4e1954ebf51efa9a", - "maxMatches": 1, - "maxTokens": 135, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" - ], - "fingerprint": "e8af81b39a51efd5", - "maxMatches": 1, - "maxTokens": 117, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt" - ], - "fingerprint": "bcbb813709525760", - "maxMatches": 1, - "maxTokens": 101, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt" - ], - "fingerprint": "3b2719dc703c2e2a", - "maxMatches": 1, - "maxTokens": 150, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt" - ], - "fingerprint": "b6b0498d20608a76", - "maxMatches": 1, - "maxTokens": 125, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentCreateDialog.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt" - ], - "fingerprint": "182f5d4212a54002", - "maxMatches": 1, - "maxTokens": 134, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentEditDialog.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt" - ], - "fingerprint": "798762edd934cf40", - "maxMatches": 1, - "maxTokens": 152, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/WorkflowsConfigurable.kt" - ], - "fingerprint": "6a0a61ed994a1440", - "maxMatches": 1, - "maxTokens": 104, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt" - ], - "fingerprint": "7f1d00dee6941d7a", - "maxMatches": 1, - "maxTokens": 128, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt" - ], - "fingerprint": "35bd87a9583941c9", - "maxMatches": 1, - "maxTokens": 130, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt" - ], - "fingerprint": "eee092f7fd1a16b5", - "maxMatches": 1, - "maxTokens": 135, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, - { - "files": [ - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/FlowMarks.kt", - "packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt" - ], - "fingerprint": "9885d82e15495d73", - "maxMatches": 1, - "maxTokens": 152, - "kind": "legacy", - "owner": "kilo-jetbrains", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, { "files": ["packages/kilo-memory/src/effect/capture.ts", "packages/kilo-memory/src/effect/capture.ts"], "fingerprint": "fc721da29f556180",