refactor: ratchet and reduce Kilo-owned duplication

This commit is contained in:
marius-kilocode
2026-08-28 16:25:31 +02:00
parent 4e311d56d2
commit a91bf44acf
50 changed files with 4040 additions and 4428 deletions
@@ -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
+1
View File
@@ -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",
+12 -18
View File
@@ -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({
+3 -1
View File
@@ -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"
+39
View File
@@ -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
}
}
+182
View File
@@ -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
}
+19 -57
View File
@@ -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 -147
View File
@@ -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
+16 -156
View File
@@ -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;
}
}
}
+93
View File
@@ -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
}
+62
View File
@@ -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> {}
+47 -241
View File
@@ -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 {}
+35 -207
View File
@@ -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()
})
+351
View File
@@ -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
})
}
+960
View File
@@ -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."
}
]
}