mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
refactor: ratchet and reduce Kilo-owned duplication
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,12 +8,7 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>, convert: (value: unknown) => unknown): Record<string, unknown> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
BotStatusRecord,
|
||||
ContentBlock,
|
||||
ConversationListItem,
|
||||
ConversationStatusRecord,
|
||||
ExecApprovalDecision,
|
||||
Message,
|
||||
} from "./types.js"
|
||||
|
||||
export type KiloChatClientConfig = {
|
||||
baseUrl: string
|
||||
getToken: () => Promise<string>
|
||||
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<string, unknown>).error
|
||||
if (typeof err === "string") return ` - ${err}`
|
||||
try {
|
||||
return ` - ${JSON.stringify(body)}`
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type HttpOpts = {
|
||||
method?: string
|
||||
body?: unknown
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
}
|
||||
|
||||
type SendQueue = Map<string, Promise<unknown>>
|
||||
|
||||
export class KiloChatClient<Conversation = unknown> {
|
||||
private readonly baseUrl: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
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<void> {
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
|
||||
}
|
||||
|
||||
async sendTypingStop(conversationId: string): Promise<void> {
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/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<T>(path: string, opts: HttpOpts = {}): Promise<T> {
|
||||
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<string, string> = { 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<string>
|
||||
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<string>
|
||||
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<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
private tickets = new Set<AbortController>()
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
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<void> {
|
||||
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<T = unknown>(event: string, handler: (context: string, payload: T) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
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<void> {
|
||||
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<void>((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<string> {
|
||||
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<string, unknown>
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<EmbeddingResponse> {
|
||||
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})`),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return updateRateLimitState(OpenAICompatibleEmbedder.globalRateLimitState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current global rate limit delay
|
||||
*/
|
||||
private async getGlobalRateLimitDelay(): Promise<number> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EmbeddingResponse> {
|
||||
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})`),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return updateRateLimitState(OpenRouterEmbedder.globalRateLimitState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current global rate limit delay
|
||||
*/
|
||||
private async getGlobalRateLimitDelay(): Promise<number> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EmbeddingResponse> {
|
||||
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})`),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string[]> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
const release = await state.mutex.acquire()
|
||||
const delay = state.isRateLimited && state.rateLimitResetTime > Date.now() ? state.rateLimitResetTime - Date.now() : 0
|
||||
release()
|
||||
return delay
|
||||
}
|
||||
@@ -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<T>(props: CodeProps<T>) {
|
||||
})
|
||||
})
|
||||
|
||||
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<T>(props: CodeProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
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<T>(props: CodeProps<T>) {
|
||||
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<T>(props: CodeProps<T>) {
|
||||
})
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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<T = {}> = DiffProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
@@ -54,61 +56,10 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
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<T>(props: SSRDiffProps<T>) {
|
||||
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<T>(props: SSRDiffProps<T>) {
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
const root = getRoot()
|
||||
if (root) requestAnimationFrame(() => applyDiffCommentedLines(root, ranges))
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
|
||||
@@ -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<T>(props: DiffProps<T>) {
|
||||
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<T>(props: DiffProps<T>) {
|
||||
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<T>(props: DiffProps<T>) {
|
||||
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<T>(props: DiffProps<T>) {
|
||||
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<T>(props: DiffProps<T>) {
|
||||
})
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <JWT>` to mint a
|
||||
* single-use ticket (30 s TTL).
|
||||
* 2. Open WebSocket to `/connect?ticket=<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<string>
|
||||
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<string>
|
||||
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<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
private reconnectHandlers = new Set<() => void>()
|
||||
|
||||
constructor(config: EventServiceConfig) {
|
||||
this.url = config.url
|
||||
this.getToken = config.getToken
|
||||
this.onUnauthorized = config.onUnauthorized
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
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<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
// 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<void> {
|
||||
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<void>((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<string> {
|
||||
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<string, unknown>
|
||||
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<N extends KiloChatEventName>(
|
||||
event: N,
|
||||
handler: (context: string, payload: KiloChatEventMap[N]) => void,
|
||||
): () => void
|
||||
on<T = unknown>(event: string, handler: (context: string, payload: T) => void): () => void
|
||||
on(event: string, handler: (context: string, payload: unknown) => void): () => void {
|
||||
return super.on(event, handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
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<string, unknown>).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<string, string | number | boolean | undefined | null>
|
||||
}
|
||||
|
||||
// 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<string, Promise<unknown>>
|
||||
|
||||
export class KiloChatClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
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<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/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<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
|
||||
}
|
||||
|
||||
async sendTypingStop(conversationId: string): Promise<void> {
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/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<T>(path: string, opts: HttpOpts = {}): Promise<T> {
|
||||
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<string, string> = { 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<ConversationDetail> {}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
|
||||
}
|
||||
|
||||
interface DiffPanelProps {
|
||||
diffs: WorktreeFileDiff[]
|
||||
loading: boolean
|
||||
@@ -114,102 +65,23 @@ interface DiffPanelProps {
|
||||
|
||||
export const DiffPanel: Component<DiffPanelProps> = (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<Record<string, string[]>>({})
|
||||
const [knownFiles, setKnownFiles] = createSignal<Record<string, string[]>>({})
|
||||
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<ReviewDraft | null>(reviewComposerDraft(composer()))
|
||||
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
|
||||
const speechKeys = createMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
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<DiffPanelProps> = (props) => {
|
||||
const [scroller, setScroller] = createSignal<HTMLDivElement>()
|
||||
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
|
||||
|
||||
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<DiffPanelProps> = (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<string, ReviewComment[]>()
|
||||
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<string>()
|
||||
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<AnnotationMeta>[] => {
|
||||
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<AnnotationMeta>): 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<DiffPanelProps> = (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 (
|
||||
<Accordion.Item
|
||||
ref={viewport.ref}
|
||||
value={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-file-path={diff.file}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div
|
||||
data-slot="session-review-file-info"
|
||||
draggable={true}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
e.dataTransfer?.setData(KILO_FILE_PATH_MIME, diff.file)
|
||||
e.dataTransfer?.setData("text/plain", diff.file)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u2066${getDirectory(diff.file)}\u2069`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={fileCommentCount() > 0}>
|
||||
<span class="am-diff-file-badge">{fileCommentCount()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Show when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Show>
|
||||
<DiffChanges changes={diff} />
|
||||
<Show when={diff.kind === "image"}>
|
||||
<span class="am-diff-summary-pill">{t("agentManager.review.image")}</span>
|
||||
</Show>
|
||||
<Show when={isLargeCollapsed()}>
|
||||
<span class="am-diff-large-pill">{t("agentManager.review.largeFileCollapsed")}</span>
|
||||
</Show>
|
||||
<Show when={diff.tracked === false}>
|
||||
<span class="am-diff-summary-pill">untracked</span>
|
||||
</Show>
|
||||
<Show when={diff.generatedLike === true}>
|
||||
<span class="am-diff-summary-pill">generated</span>
|
||||
</Show>
|
||||
<Show when={props.onOpenFile && !isDeleted()}>
|
||||
<Tooltip value={t("agentManager.diff.openFile")} placement="top">
|
||||
<IconButton
|
||||
icon="go-to-file"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.diff.openFile")}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onOpenFile?.(diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isMarkdownFile(diff.file) && props.onOpenDocument && !isDeleted()}>
|
||||
<Tooltip value={t("agentManager.documents.preview")} placement="top">
|
||||
<IconButton
|
||||
icon="book-open-check"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.documents.preview")}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onOpenDocument?.(diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.onRevertFile && props.canRevert !== false}>
|
||||
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
|
||||
<IconButton
|
||||
icon="discard"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="am-diff-revert-btn"
|
||||
label={t("agentManager.diff.revertFile")}
|
||||
disabled={props.revertingFiles?.has(diff.file) ?? false}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onRevertFile?.(diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isMarkdownFile(diff.file) && props.onMarkdownRenderChange}>
|
||||
<Tooltip
|
||||
value={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon={props.markdownRender ? "code" : "eye"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onMarkdownRenderChange?.(!props.markdownRender)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isDiffExpandable(diff)}>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={open().includes(diff.file)}>
|
||||
<Show
|
||||
when={diff.summarized !== true}
|
||||
fallback={
|
||||
<div class="am-diff-summary-state">
|
||||
<Show when={isLoadingDetail()} fallback={<span>Diff preview loads on demand.</span>}>
|
||||
<span>Loading diff...</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={diff.kind === "image"}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.markdownRender && isMarkdownFile(diff.file)}
|
||||
fallback={
|
||||
<Diff<AnnotationMeta>
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MarkdownDiffView
|
||||
diff={diff}
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ImageDiffView diff={diff} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<ReviewDiffItem
|
||||
diff={diff}
|
||||
open={open}
|
||||
viewport={viewport}
|
||||
request={request}
|
||||
active={() => 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
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
|
||||
}
|
||||
|
||||
interface FullScreenDiffViewProps {
|
||||
diffs: WorktreeFileDiff[]
|
||||
loading: boolean
|
||||
@@ -106,55 +73,16 @@ interface FullScreenDiffViewProps {
|
||||
|
||||
export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (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<Record<string, string[]>>({})
|
||||
const [knownFiles, setKnownFiles] = createSignal<Record<string, string[]>>({})
|
||||
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<Record<string, string | null>>({})
|
||||
const activeFile = createMemo(() => {
|
||||
@@ -170,27 +98,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
|
||||
setManualActiveFile((prev) => ({ ...prev, [key]: file }))
|
||||
}
|
||||
|
||||
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
|
||||
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
|
||||
const speechKeys = createMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
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<HTMLDivElement>()
|
||||
@@ -202,48 +110,37 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (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<FullScreenDiffViewProps> = (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<FullScreenDiffViewProps> = (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<string, ReviewComment[]>()
|
||||
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<string>()
|
||||
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<AnnotationMeta>[] => {
|
||||
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<AnnotationMeta>): 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<FullScreenDiffViewProps> = (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 (
|
||||
<Accordion.Item ref={viewport.ref} value={diff.file} data-file-path={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div
|
||||
data-slot="session-review-file-info"
|
||||
draggable={true}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
e.dataTransfer?.setData(KILO_FILE_PATH_MIME, diff.file)
|
||||
e.dataTransfer?.setData("text/plain", diff.file)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u2066${getDirectory(diff.file)}\u2069`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={fileCommentCount() > 0}>
|
||||
<span class="am-diff-file-badge">{fileCommentCount()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Show when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Show>
|
||||
<DiffChanges changes={diff} />
|
||||
<Show when={diff.kind === "image"}>
|
||||
<span class="am-diff-summary-pill">{t("agentManager.review.image")}</span>
|
||||
</Show>
|
||||
<Show when={isLargeCollapsed()}>
|
||||
<span class="am-diff-large-pill">{t("agentManager.review.largeFileCollapsed")}</span>
|
||||
</Show>
|
||||
<Show when={diff.tracked === false}>
|
||||
<span class="am-diff-summary-pill">untracked</span>
|
||||
</Show>
|
||||
<Show when={diff.generatedLike === true}>
|
||||
<span class="am-diff-summary-pill">generated</span>
|
||||
</Show>
|
||||
<Show when={props.onOpenFile && !isDeleted()}>
|
||||
<Tooltip value={t("agentManager.diff.openFile")} placement="top">
|
||||
<IconButton
|
||||
icon="go-to-file"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.diff.openFile")}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onOpenFile?.(diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.onRevertFile && props.canRevert !== false}>
|
||||
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
|
||||
<IconButton
|
||||
icon="discard"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="am-diff-revert-btn"
|
||||
label={t("agentManager.diff.revertFile")}
|
||||
disabled={props.revertingFiles?.has(diff.file) ?? false}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onRevertFile?.(diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isMarkdownFile(diff.file) && props.onMarkdownRenderChange}>
|
||||
<Tooltip
|
||||
value={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon={props.markdownRender ? "code" : "eye"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onMarkdownRenderChange?.(!props.markdownRender)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isDiffExpandable(diff)}>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={open().includes(diff.file)}>
|
||||
<Show
|
||||
when={diff.summarized !== true}
|
||||
fallback={
|
||||
<div class="am-diff-summary-state">
|
||||
<Show when={isLoadingDetail()} fallback={<span>Diff preview loads on demand.</span>}>
|
||||
<>
|
||||
<Spinner />
|
||||
<span>Loading diff...</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={diff.kind === "image"}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.markdownRender && isMarkdownFile(diff.file)}
|
||||
fallback={
|
||||
<Diff<AnnotationMeta>
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MarkdownDiffView
|
||||
diff={diff}
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ImageDiffView diff={diff} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<ReviewDiffItem
|
||||
diff={diff}
|
||||
open={open}
|
||||
viewport={viewport}
|
||||
request={request}
|
||||
loading={() => 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
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<string[]>
|
||||
viewport: DiffViewport
|
||||
request: (diff: WorktreeFileDiff, visible?: () => boolean) => void
|
||||
active?: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
comments: Accessor<number>
|
||||
diffStyle: Accessor<"unified" | "split">
|
||||
markdownRender: Accessor<boolean>
|
||||
annotations: () => DiffLineAnnotation<AnnotationMeta>[]
|
||||
renderAnnotation: (annotation: DiffLineAnnotation<AnnotationMeta>) => HTMLElement | undefined
|
||||
onGutterUtilityClick: (range: SelectedLineRange) => void
|
||||
onOpenFile?: (file: string, line?: number) => void
|
||||
onOpenDocument?: (file: string) => void
|
||||
onRevertFile?: (file: string) => void
|
||||
reverting: Accessor<boolean>
|
||||
onMarkdownRenderChange?: (render: boolean) => void
|
||||
canComment: Accessor<boolean>
|
||||
sessionKey?: string
|
||||
sessionReviewSlot?: boolean
|
||||
showLoadingSpinner?: boolean
|
||||
}
|
||||
|
||||
export const ReviewDiffItem: Component<Props> = (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 (
|
||||
<Accordion.Item
|
||||
ref={props.viewport.ref}
|
||||
value={props.diff.file}
|
||||
data-slot={props.sessionReviewSlot ? "session-review-accordion-item" : undefined}
|
||||
data-file-path={props.diff.file}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div
|
||||
data-slot="session-review-file-info"
|
||||
draggable={true}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
event.dataTransfer?.setData(KILO_FILE_PATH_MIME, props.diff.file)
|
||||
event.dataTransfer?.setData("text/plain", props.diff.file)
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FileIcon node={{ path: props.diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={props.diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u2066${getDirectory(props.diff.file)}\u2069`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(props.diff.file)}</span>
|
||||
<Show when={props.comments() > 0}>
|
||||
<span class="am-diff-file-badge">{props.comments()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Show when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Show>
|
||||
<DiffChanges changes={props.diff} />
|
||||
<Show when={props.diff.kind === "image"}>
|
||||
<span class="am-diff-summary-pill">{t("agentManager.review.image")}</span>
|
||||
</Show>
|
||||
<Show when={isLargeCollapsed()}>
|
||||
<span class="am-diff-large-pill">{t("agentManager.review.largeFileCollapsed")}</span>
|
||||
</Show>
|
||||
<Show when={props.diff.tracked === false}>
|
||||
<span class="am-diff-summary-pill">untracked</span>
|
||||
</Show>
|
||||
<Show when={props.diff.generatedLike === true}>
|
||||
<span class="am-diff-summary-pill">generated</span>
|
||||
</Show>
|
||||
<Show when={props.onOpenFile && !isDeleted()}>
|
||||
<Tooltip value={t("agentManager.diff.openFile")} placement="top">
|
||||
<IconButton
|
||||
icon="go-to-file"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.diff.openFile")}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onOpenFile?.(props.diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isMarkdownFile(props.diff.file) && props.onOpenDocument && !isDeleted()}>
|
||||
<Tooltip value={t("agentManager.documents.preview")} placement="top">
|
||||
<IconButton
|
||||
icon="book-open-check"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.documents.preview")}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onOpenDocument?.(props.diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.onRevertFile}>
|
||||
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
|
||||
<IconButton
|
||||
icon="discard"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="am-diff-revert-btn"
|
||||
label={t("agentManager.diff.revertFile")}
|
||||
disabled={props.reverting()}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onRevertFile?.(props.diff.file)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isMarkdownFile(props.diff.file) && props.onMarkdownRenderChange}>
|
||||
<Tooltip value={props.markdownRender() ? "Show raw Markdown" : "Render Markdown"} placement="top">
|
||||
<IconButton
|
||||
icon={props.markdownRender() ? "code" : "eye"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.markdownRender() ? "Show raw Markdown" : "Render Markdown"}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onMarkdownRenderChange?.(!props.markdownRender())
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isDiffExpandable(props.diff)}>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={props.open().includes(props.diff.file)}>
|
||||
<Show
|
||||
when={props.diff.summarized !== true}
|
||||
fallback={
|
||||
<div class="am-diff-summary-state">
|
||||
<Show when={props.loading()} fallback={<span>Diff preview loads on demand.</span>}>
|
||||
<Show when={props.showLoadingSpinner}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<span>Loading diff...</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.diff.kind === "image"}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.markdownRender() && isMarkdownFile(props.diff.file)}
|
||||
fallback={
|
||||
<Diff<AnnotationMeta>
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MarkdownDiffView
|
||||
diff={props.diff}
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ImageDiffView diff={props.diff} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export function createDiffViewport(root: Accessor<Element | undefined>) {
|
||||
return { ref: (node: Element) => setElement(node), visible, intersects }
|
||||
}
|
||||
|
||||
export type DiffViewport = ReturnType<typeof createDiffViewport>
|
||||
|
||||
export function createDiffRequests(opts: DiffRequestOptions) {
|
||||
const requested = new Map<string, string>()
|
||||
let active = false
|
||||
|
||||
@@ -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, string | number>) => 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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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<WorktreeFileDiff[]>
|
||||
rows: Accessor<WorktreeFileDiff[]>
|
||||
comments: Accessor<ReviewComment[]>
|
||||
setComments: (comments: ReviewComment[]) => void
|
||||
composer: () => ReviewComposer
|
||||
key: Accessor<string | undefined>
|
||||
preserveScroll: (run: () => void) => void
|
||||
focus: () => void
|
||||
label: (key: string, params?: UiI18nParams) => string
|
||||
activeTerminalId: Accessor<string | undefined>
|
||||
active?: Accessor<boolean>
|
||||
canComment?: Accessor<boolean>
|
||||
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<string>())
|
||||
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<string>()
|
||||
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<string, ReviewComment[]>()
|
||||
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<string>()
|
||||
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<AnnotationMeta>[] => {
|
||||
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<AnnotationMeta>): 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,
|
||||
}
|
||||
}
|
||||
@@ -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<WorktreeFileDiff[]>,
|
||||
virtualizer: Accessor<VirtualizerHandle | undefined>,
|
||||
) {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"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
|
||||
}
|
||||
@@ -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<WorktreeFileDiff[]>, key: Accessor<string | undefined>) {
|
||||
const [manual, setManual] = createSignal<Record<string, string[]>>({})
|
||||
const [known, setKnown] = createSignal<Record<string, string[]>>({})
|
||||
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 }
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export const DocumentPanel: Component<DocumentPanelProps> = (props) => {
|
||||
deleteComment,
|
||||
cancelDraft,
|
||||
labels: labels(t),
|
||||
activeTerminalId: props.activeTerminalId,
|
||||
activeTerminalId: () => props.activeTerminalId,
|
||||
})
|
||||
const gutter = (range: SelectedLineRange) => {
|
||||
if (draft()) return
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
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<string, unknown>).error
|
||||
if (typeof err === "string") return ` - ${err}`
|
||||
try {
|
||||
return ` - ${JSON.stringify(body)}`
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type HttpOpts = {
|
||||
method?: string
|
||||
body?: unknown
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
}
|
||||
|
||||
type SendQueue = Map<string, Promise<unknown>>
|
||||
|
||||
export class KiloChatClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
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<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/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<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
|
||||
}
|
||||
|
||||
async sendTypingStop(conversationId: string): Promise<void> {
|
||||
await this.request<unknown>(`/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<void> {
|
||||
await this.request<unknown>(`/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<T>(path: string, opts: HttpOpts = {}): Promise<T> {
|
||||
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<string, string> = { 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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,382 +1 @@
|
||||
/**
|
||||
* Generic Event Service WebSocket client.
|
||||
*
|
||||
* Connects via a two-step ticket flow:
|
||||
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
|
||||
* single-use ticket (30 s TTL).
|
||||
* 2. Open WebSocket to `/connect?ticket=<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<string>
|
||||
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<string>
|
||||
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<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
private tickets = new Set<AbortController>()
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
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<void> {
|
||||
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<T = unknown>(event: string, handler: (context: string, payload: T) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
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<void> {
|
||||
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<void>((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<string> {
|
||||
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<string, unknown>
|
||||
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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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<string, string>, run: (root: string) => Promise<void>) {
|
||||
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<Int>): Map<String, Int> {
|
||||
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()
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
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<Exception, "files" | "fingerprint">) {
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string, Finding>()
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user