Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-indexing-status-consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep codebase indexing status current and support workspace paths with non-ASCII characters.
70 changes: 50 additions & 20 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private indexingProjectId: string | undefined
private indexingSettingsRequest = 0
private indexingStatusRequest = 0
private indexingTarget?: { source: string; directory: string; projectId?: string }
private slimEditMetadata = true

private pendingFollowup: Followup | null = null
Expand Down Expand Up @@ -1800,6 +1801,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
(payload, directory) => {
const event = unwrapSyncEvent(payload)
if (!event) return false
if (event.type === "indexing.status" && directory) {
return sameDirectory(directory, this.indexingScope.directory)
}
if (
directory &&
directory !== "global" &&
Expand Down Expand Up @@ -1857,6 +1861,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postConnectionState(error)

if (state === "connected") {
const target = this.indexingScope
void this.fetchAndSendIndexingStatus(target.directory, target.projectId)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
this.flushPendingKiloModel()
// Fire config warnings independently so a failure in the
// sequential await chain doesn't prevent warnings from being shown
Expand Down Expand Up @@ -2907,6 +2913,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}

private get indexingScope() {
const source = this.getWorkspaceDirectory(this.currentSession?.id)
const target = this.indexingTarget
if (target && (target.source === source || sameDirectory(target.source, source))) return target
return { source, directory: source, projectId: undefined }
}

private async fetchAndSendIndexingStatus(directory?: string, projectId?: string): Promise<void> {
if (!this.client || !this.extensionContext) {
if (this.cachedIndexingStatusMessage) {
Expand All @@ -2917,15 +2930,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

const config = this.connectionService.getServerConfig()
if (!config) return
const request = ++this.indexingStatusRequest
const source = this.getWorkspaceDirectory(this.currentSession?.id)
const dir = directory ?? source
if (!dir) return
const target = { source, directory: dir, projectId }
this.indexingTarget = target

try {
const dir = directory ?? this.getWorkspaceDirectory(this.currentSession?.id)
if (!dir) return
const store = indexingConsentStore(this.extensionContext)
const project = await store.project(dir)
if (target !== this.indexingScope) return
target.directory = project.root
const request = ++this.indexingStatusRequest
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
const status = await this.syncIndexingConsent(project.root, store.enabled(project.id), config)
if (request !== this.indexingStatusRequest) return
if (request !== this.indexingStatusRequest || target !== this.indexingScope) return
const message = {
type: "indexingStatusLoaded",
status,
Expand Down Expand Up @@ -3807,9 +3825,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.sendIndexingSettings(project.id)
const config = this.connectionService.getServerConfig()
if (!config) return
const target = {
source: this.getWorkspaceDirectory(this.currentSession?.id),
directory: project.root,
projectId: project.id,
}
this.indexingTarget = target
const request = ++this.indexingStatusRequest
const status = await this.syncIndexingConsent(project.root, enabled, config)
if (request !== this.indexingStatusRequest || this.indexingProjectId !== project.id) return
if (
request !== this.indexingStatusRequest ||
target !== this.indexingScope ||
this.indexingProjectId !== project.id
)
return
const message = { type: "indexingStatusLoaded", status, projectId: project.id }
this.cachedIndexingStatusMessage = message
this.postMessage(message)
Expand All @@ -3826,7 +3855,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
"x-kilo-directory": dir,
"x-kilo-directory": encodeURIComponent(dir),
},
body: JSON.stringify({ enabled }),
})
Expand Down Expand Up @@ -4753,6 +4782,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
*/
private handleEvent(event: ProviderEvent, directory?: string): void {
if (event.type === "indexing.status") {
const target = this.indexingScope
if (directory && !sameDirectory(directory, target.directory)) return
this.indexingStatusRequest++
const message = {
type: "indexingStatusLoaded",
status: event.properties.status,
projectId: target.projectId,
}
this.cachedIndexingStatusMessage = message
this.postMessage(message)
return
}

if (event.type === "kilo-sessions.remote-status-changed") {
this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected })
return
Expand Down Expand Up @@ -4859,13 +4902,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// message.part.* events are always session-scoped; drop if session unknown.
if (!sessionID && isSessionScopedPartEvent(event.type)) return
if (this.postModelUsageChanged(event, sessionID)) return
if (
event.type !== "indexing.status" &&
event.type !== "session.deleted" &&
sessionID &&
!this.trackedSessionIds.has(sessionID)
)
return
if (event.type !== "session.deleted" && sessionID && !this.trackedSessionIds.has(sessionID)) return

if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID)

Expand Down Expand Up @@ -4978,10 +5015,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
}

if (event.type === "indexing.status" && directory) {
if (!sameDirectory(directory, this.getWorkspaceDirectory(this.currentSession?.id))) return
}

const msg = isLegacySyncEvent(event)
? this.mapSyncEventToWebviewMessage(event)
: mapSSEEventToWebviewMessage(event, sessionID)
Expand All @@ -4996,9 +5029,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ ...next, revision: ++this.sandboxRevision })
return
}
if (next.type === "indexingStatusLoaded") {
this.cachedIndexingStatusMessage = next
}
this.streams.flush(sessionID)
this.postMessage(next)
const sid = event.type === "session.turn.close" ? event.properties.sessionID : sessionID
Expand Down
196 changes: 149 additions & 47 deletions packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import type { Config } from "@kilocode/sdk/v2/client"
import { indexingConsentStore } from "../../src/indexing-consent"
import { fetchSnapshot } from "../../src/kilo-provider/config-snapshot"

// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
Expand All @@ -24,7 +25,8 @@ type Internals = {
fetchAndSendSkills: () => Promise<void>
fetchAndSendCommands: () => Promise<void>
fetchAndSendNotifications: () => Promise<void>
fetchAndSendIndexingStatus: () => Promise<void>
fetchAndSendIndexingStatus: (directory?: string, projectId?: string) => Promise<void>
initializeConnection: () => Promise<void>
connectionGeneration: number
configBindings: {
create: (input: unknown) => { id: string }
Expand Down Expand Up @@ -107,6 +109,45 @@ function createConnection() {
}
}

const initial = {
state: "In Progress",
message: "Indexing is initializing.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}

const complete = {
state: "Complete",
message: "Index up-to-date.",
processedFiles: 100,
totalFiles: 100,
percent: 100,
}

function indexing(dir = "/repo", root = dir) {
const context = { globalState: { get: () => undefined, update: async () => {} } }
indexingConsentStore(context as never).project = async () => ({ id: "prj-test", root, label: "Project" })
const client = {
kilo: { profile: async () => ({ data: null }) },
config: { warnings: async () => ({ data: [] }) },
}
const service = {
getClient: () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:9999", password: "secret" }),
getServerInfo: () => null,
getConnectionError: () => null,
resolveEventSessionId: () => undefined,
}
const provider = new KiloProvider({} as never, service as never, context as never)
const internal = provider as unknown as Internals
const messages: Array<Record<string, unknown>> = []
provider.postMessage = (message) => void messages.push(message as Record<string, unknown>)
provider.setSessionDirectory("ses_indexing", dir)
internal.currentSession = { id: "ses_indexing" }
return { internal, client, service, messages }
}

describe("KiloProvider indexing refresh", () => {
it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => {
const conn = createConnection()
Expand Down Expand Up @@ -307,61 +348,122 @@ describe("KiloProvider indexing refresh", () => {
})
})

it("fetchAndSendIndexingStatus writes project consent through the dedicated endpoint", async () => {
const worktree = "/repo/.kilo/.kilocode/worktrees/feature"
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const original = globalThis.fetch
it.each(["/repo/.kilo/.kilocode/worktrees/feature", "/home/user/桌面/project", "/repo/100%/%2F/project"])(
"fetchAndSendIndexingStatus writes consent with an encoded directory header: %s",
async (dir) => {
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const original = globalThis.fetch
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init })
return Response.json(initial)
}) as typeof fetch

try {
await indexing(dir).internal.fetchAndSendIndexingStatus()

expect(calls).toHaveLength(1)
const call = calls.at(0)
const headers = new Headers(call?.init?.headers)
const auth = Buffer.from("kilo:secret").toString("base64")
expect(headers.get("Authorization")).toBe(`Basic ${auth}`)
expect(headers.get("x-kilo-directory")).toBe(encodeURIComponent(dir))
expect(decodeURIComponent(headers.get("x-kilo-directory") ?? "")).toBe(dir)
expect(call?.init?.method).toBe("PUT")
expect(String(call?.input)).toBe("http://127.0.0.1:9999/indexing/consent")
expect(JSON.parse(String(call?.init?.body))).toEqual({ enabled: false })
} finally {
globalThis.fetch = original
}
},
)

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init })
return new Response(
JSON.stringify({
state: "Disabled",
message: "Indexing is disabled in worktree sessions.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
it("keeps newer indexing.status when an older HTTP status response arrives", async () => {
const fixture = indexing()
const called = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const original = globalThis.fetch
globalThis.fetch = (() => {
called.resolve()
return response.promise
}) as typeof fetch

const request = fixture.internal.fetchAndSendIndexingStatus()
try {
const provider = new KiloProvider(
{} as never,
{
getClient: () => ({}) as never,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:9999", password: "secret" }),
} as never,
{
globalState: {
get: () => undefined,
update: async () => {},
},
} as never,
)
const internal = provider as unknown as Internals
provider.setSessionDirectory("ses_worktree", worktree)
internal.currentSession = { id: "ses_worktree" }

await internal.fetchAndSendIndexingStatus()

expect(calls.length).toBe(1)
const headers = new Headers(calls[0]?.init?.headers)
const auth = Buffer.from("kilo:secret").toString("base64")
expect(headers.get("Authorization")).toBe(`Basic ${auth}`)
expect(headers.get("x-kilo-directory")).toBe(worktree)
expect(calls[0]?.init?.method).toBe("PUT")
expect(String(calls[0]?.input)).toBe("http://127.0.0.1:9999/indexing/consent")
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ enabled: false })
await called.promise
fixture.internal.handleEvent({ type: "indexing.status", properties: { status: complete } }, "/repo")
response.resolve(Response.json(initial))
await request

expect(fixture.messages).toEqual([expect.objectContaining({ type: "indexingStatusLoaded", status: complete })])
} finally {
response.resolve(Response.json(initial))
await request
globalThis.fetch = original
}
})

it("accepts the resolved project root and rejects unrelated indexing.status events from a repo subfolder", async () => {
const fixture = indexing("/repo/subfolder", "/repo")
const original = globalThis.fetch
globalThis.fetch = (async () => Response.json(initial)) as typeof fetch

try {
await fixture.internal.fetchAndSendIndexingStatus(undefined, "prj-test")
const event = { type: "indexing.status", properties: { status: complete } }
fixture.internal.handleEvent(event, "/other-repo")
expect(fixture.messages).toHaveLength(1)

fixture.internal.handleEvent(event, "/repo")
expect(fixture.messages).toEqual([
expect.objectContaining({ type: "indexingStatusLoaded", status: initial, projectId: "prj-test" }),
expect.objectContaining({ type: "indexingStatusLoaded", status: complete, projectId: "prj-test" }),
])
} finally {
globalThis.fetch = original
}
})

it("refreshes indexing on SSE reconnect without waiting for profile", async () => {
const fixture = indexing()
const callback = Promise.withResolvers<(state: Internals["connectionState"]) => Promise<void>>()
const subscribe = () => () => {}
Object.assign(fixture.service, {
connect: async () => {},
getClient: () => null,
getConnectionState: () => "disconnected",
onEventFiltered: subscribe,
onStateChange: (listener: (state: Internals["connectionState"]) => Promise<void>) => {
callback.resolve(listener)
return () => {}
},
onNotificationDismissed: subscribe,
onLanguageChanged: subscribe,
onProfileChanged: subscribe,
onFavoritesChanged: subscribe,
onModelSelectorExpandedChanged: subscribe,
onClearPendingPrompts: subscribe,
registerDirectoryProvider: subscribe,
})
await fixture.internal.initializeConnection()
expect(fixture.internal.connectionState).toBe("disconnected")

const profile = Promise.withResolvers<{ data: null }>()
const calls: string[] = []
fixture.service.getClient = () => fixture.client
fixture.client.kilo.profile = () => profile.promise
fixture.internal.fetchAndSendIndexingStatus = async () => {
calls.push("indexing")
}

const syncing = (await callback.promise)("connected")
try {
expect(calls).toEqual(["indexing"])
} finally {
profile.resolve({ data: null })
await syncing
}
})

it("forwards indexing.status when directory only differs by Windows drive casing", () => {
const provider = new KiloProvider(
{} as never,
Expand Down
Loading