Merge remote-tracking branch 'origin/main' into tidy-antlion

# Conflicts:
#	packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt
This commit is contained in:
kirillk
2026-06-17 09:16:29 -04:00
275 changed files with 6985 additions and 2489 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support closing other Agent Manager tabs from the tab context menu.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Identify Kilo in provider request user-agent headers instead of OpenCode.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Highlight the exact changed characters within modified diff lines.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Ignore Enter while IME composition is active (including Windows virtual-key 229) so composed text is not accidentally submitted from chat and sidebar inputs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Clean up incomplete worktrees when moving a session fails to transfer its Git changes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Announce every Settings tab by name to screen readers in compact sidebars.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent the bundled Atomic Chat plugin from triggering an npm installation.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Render image changes in diff viewers and open images with VS Code's image preview.
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/sdk": patch
"kilo-code": patch
---
Restore speech input when profile details are unavailable, move transcription model selection to the Models tab, and default transcription to Whisper Large V3 Turbo.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Search and switch between local sessions, worktrees, and their sessions from the Agent Manager sidebar.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-indexing": minor
---
Share the main codebase index with Agent Manager worktrees while indexing and searching only each worktree's changed files.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show line numbers in edit approval diffs, including compact sidebar views.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent concurrent Kilo processes from reusing a ChatGPT Codex refresh token.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support VS Code switch links that select a Kilo model, agent, or both.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"@kilocode/sdk": minor
---
Add opt-in Unicode or emoji terminal title indicators for sessions that are working, need attention, or have finished.
+1 -1
View File
@@ -3,7 +3,7 @@ name: containers
on:
push:
branches:
- dev
- main # kilocode_change
paths:
- packages/containers/**
- .github/workflows/containers.yml
+109
View File
@@ -0,0 +1,109 @@
# kilocode_change - new file
name: test-jetbrains
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
checks: write
pull-requests: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
changes:
name: detect JetBrains changes
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }}
steps:
- name: Checkout repository
if: github.event_name != 'workflow_dispatch'
uses: actions/checkout@v6
- name: Detect JetBrains changes
if: github.event_name != 'workflow_dispatch'
id: filter
uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3
with:
predicate-quantifier: every
filters: |
jetbrains:
- '**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
unit:
name: jetbrains
needs: changes
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run JetBrains unit tests
run: bun script/test-ci.ts
working-directory: packages/kilo-jetbrains
- name: Publish JetBrains unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
report_paths: packages/kilo-jetbrains/.artifacts/unit/junit.xml
check_name: "unit results (jetbrains)"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload JetBrains unit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: unit-jetbrains-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/kilo-jetbrains/.artifacts/unit/junit.xml
required:
name: result
needs:
- changes
- unit
if: always()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Verify JetBrains jobs passed
run: |
echo "changes=${{ needs.changes.result }}"
echo "jetbrains=${{ needs.unit.result }}"
test "${{ needs.changes.result }}" = "success"
if [ "${{ needs.changes.outputs.jetbrains }}" = "true" ]; then
test "${{ needs.unit.result }}" = "success"
else
test "${{ needs.changes.outputs.jetbrains }}" = "false"
test "${{ needs.unit.result }}" = "skipped"
fi
+6 -80
View File
@@ -22,28 +22,6 @@ env:
jobs:
# kilocode_change start
changes:
name: detect JetBrains changes
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
outputs:
jetbrains: ${{ steps.filter.outputs.jetbrains }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Detect JetBrains changes
id: filter
uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3
with:
predicate-quantifier: every
filters: |
jetbrains:
- '**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -131,55 +109,11 @@ jobs:
# kilocode_change start
jetbrains:
name: jetbrains
needs: changes
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run JetBrains unit tests
run: bun script/test-ci.ts
working-directory: packages/kilo-jetbrains
- name: Publish JetBrains unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/kilo-jetbrains/.artifacts/unit/junit.xml
check_name: "unit results (jetbrains)"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload JetBrains unit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: unit-jetbrains-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/kilo-jetbrains/.artifacts/unit/junit.xml
permissions:
contents: read
checks: write
pull-requests: read
uses: ./.github/workflows/test-jetbrains.yml
# kilocode_change end
# kilocode_change start
@@ -187,22 +121,14 @@ jobs:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- changes
- unit
- jetbrains
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "changes=${{ needs.changes.result }}"
echo "unit=${{ needs.unit.result }}"
echo "jetbrains=${{ needs.jetbrains.result }}"
test "${{ needs.changes.result }}" = "success"
test "${{ needs.unit.result }}" = "success"
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ needs.changes.outputs.jetbrains }}" = "true" ]; then
test "${{ needs.jetbrains.result }}" = "success"
else
test "${{ needs.changes.outputs.jetbrains }}" = "false"
test "${{ needs.jetbrains.result }}" = "skipped"
fi
test "${{ needs.jetbrains.result }}" = "success"
# kilocode_change end
+39 -2
View File
@@ -58,7 +58,13 @@ Create a changelog draft after the prepare PR exists:
1. Read the PR body with `gh pr view <pr> --json body`.
2. Extract `JetBrains-From-Tag`, `JetBrains-Tag`, and `## Generated Notes`.
3. Use the release range and path filter as the primary relevance signal:
3. Fetch the release range tags if they are missing locally:
```bash
git fetch origin refs/tags/<from-tag>:refs/tags/<from-tag> refs/tags/<tag>:refs/tags/<tag>
```
4. Use the release range and path filter as the primary relevance signal:
```bash
git log --oneline <from-tag>..<tag> -- packages/opencode packages/kilo-jetbrains
@@ -103,9 +109,33 @@ The script updates `packages/kilo-jetbrains/CHANGELOG.md` on `jetbrains/release/
docs(jetbrains): edit changelog for v<version>
```
If `update-changelog.ts` fails with `gh: Not Found (HTTP 404)`, verify the release branch and changelog path with:
```bash
gh api "repos/Kilo-Org/kilocode/contents/packages/kilo-jetbrains/CHANGELOG.md?ref=jetbrains/release/v<version>"
```
Then either fix and retry the helper, or perform the equivalent contents API update using `ref` in the query string.
## Approve And Publish
Ask the user to approve the release changelog and metadata. By default, have the user merge the release PR manually in GitHub, then watch the publish workflow:
Ask the user to approve the release changelog and metadata. Before merging or publishing, verify the PR approval and required checks are green:
```bash
gh pr view <pr> --json mergeStateStatus,reviewDecision,statusCheckRollup
gh pr checks <pr> --watch --interval 10
```
Do not merge or publish while required checks are failing unless the user explicitly gives a maintainer override.
If a required check fails from an apparent flake, rerun only the failed jobs and wait for the run to finish:
```bash
gh run rerun <run-id> --failed
gh run watch <run-id> --exit-status
```
By default, have the user merge the release PR manually in GitHub, then watch the publish workflow:
```bash
bun .kilo/skills/release-jetbrains/script/watch-publish.ts --pr <number> --version 7.0.1-rc.7
@@ -123,11 +153,18 @@ Pass a generous Bash timeout, such as `1800000` ms. If the shell times out, re-a
bun .kilo/skills/release-jetbrains/script/watch-publish.ts --pr <number> --version 7.0.1-rc.7 --run-id <run-id>
```
If `watch-publish.ts --merge` reports that the PR is already merged, or a transient GitHub API `5xx` interrupts publish-run discovery, rerun without `--merge`:
```bash
bun .kilo/skills/release-jetbrains/script/watch-publish.ts --pr <number> --version <version>
```
Report the Marketplace channel and GitHub Release URL. RC versions publish to the `eap` channel; stable versions publish to the default Marketplace channel.
## Recovery
- If prepare created the tag but failed before creating a PR, rerun prepare for the same version. The existing workflow reuses the tag if it points to the same commit.
- If a tag points to an unexpected SHA, stop and inspect manually. Do not move or delete release tags casually.
- If release PR checks fail from an apparent flake, use `gh run rerun <run-id> --failed`, then `gh run watch <run-id> --exit-status` before publishing.
- If publish fails after merge, rerun the failed workflow only if Marketplace did not already accept the version.
- If Marketplace succeeds but GitHub Release upload fails, manually create or edit the GitHub Release for `jetbrains/v<version>` using the reviewed changelog.
@@ -30,7 +30,7 @@ const branch = `jetbrains/release/v${ver}`
const section = strip((await Bun.file(file).text()).trim())
validate(section, ver)
const current = (await $`gh api ${`repos/${repo}/contents/${path}`} -f ref=${branch}`.json()) as {
const current = (await $`gh api ${`repos/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`}`.json()) as {
content: string
encoding: string
sha: string
@@ -37,10 +37,14 @@ console.log(`runUrl=${url}`)
await $`gh run watch ${id} --repo ${repo} --exit-status`
const rel = (await $`gh release view ${`jetbrains/v${ver}`} --repo ${repo} --json url,isPrerelease`.json()) as {
url: string
isPrerelease: boolean
}
const rel = await retry(
async () =>
(await $`gh release view ${`jetbrains/v${ver}`} --repo ${repo} --json url,isPrerelease`.json()) as {
url: string
isPrerelease: boolean
},
"view release",
)
console.log(
JSON.stringify(
{
@@ -56,7 +60,15 @@ console.log(
async function merge() {
const before = new Set((await runs()).map((run) => run.databaseId))
await $`gh pr merge ${pr} --repo ${repo} --merge`
try {
await $`gh pr merge ${pr} --repo ${repo} --merge`
} catch (err) {
if (await merged()) {
console.warn(`PR ${pr} is already merged; looking for the publish workflow run`)
return await find()
}
throw err
}
for (const _ of Array.from({ length: 120 })) {
const run = (await runs()).find((item) => item.headBranch === branch && !before.has(item.databaseId))
@@ -68,30 +80,49 @@ async function merge() {
async function find() {
for (const _ of Array.from({ length: 120 })) {
const run = (await runs()).find((item) => item.headBranch === branch && active(item.status))
const run = (await runs()).find((item) => item.headBranch === branch)
if (run) return String(run.databaseId)
await Bun.sleep(1000)
}
throw new Error(
`No ${workflow} run found for ${branch}. Merge PR ${pr} first, or pass --merge to merge it automatically.`,
)
throw new Error(`No ${workflow} run found for ${branch}. Merge PR ${pr} first, or pass --merge to merge it automatically.`)
}
function active(status: string) {
return (
status === "queued" ||
status === "in_progress" ||
status === "waiting" ||
status === "requested" ||
status === "pending"
async function merged() {
const info = await retry(
async () => (await $`gh pr view ${pr} --repo ${repo} --json state`.json()) as { state: string },
"check PR state",
)
return info.state === "MERGED"
}
async function runs() {
return (await $`gh run list --repo ${repo} --workflow ${workflow} --event pull_request --json databaseId,createdAt,headBranch,status --limit 100`.json()) as {
databaseId: number
createdAt: string
headBranch: string
status: string
}[]
return await retry(
async () =>
(await $`gh run list --repo ${repo} --workflow ${workflow} --event pull_request --json databaseId,createdAt,headBranch,status --limit 100`.json()) as {
databaseId: number
createdAt: string
headBranch: string
status: string
}[],
"list workflow runs",
)
}
async function retry<T>(task: () => Promise<T>, label: string, tries = 5): Promise<T> {
try {
return await task()
} catch (err) {
if (tries <= 1 || !transient(err)) throw err
console.warn(`${label} failed with a transient GitHub error; retrying (${tries - 1} left): ${message(err)}`)
await Bun.sleep(2000)
return await retry(task, label, tries - 1)
}
}
function transient(err: unknown) {
return /\bHTTP 5\d\d\b|\b50[234]\b|Bad Gateway|Gateway Timeout|Service Unavailable/i.test(message(err))
}
function message(err: unknown) {
return err instanceof Error ? err.message : String(err)
}
+2 -2
View File
@@ -12,7 +12,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts`
- **Dev with params**: `bun dev -- help`
- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build.
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`). Includes the JetBrains plugin — requires Java 21. Check with `java -version` before running. If missing, install via SDKMAN: `sdk install java 21-tem && sdk use java 21-tem`. If SDKMAN is not installed, see https://sdkman.io/install.
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`). Includes the JetBrains plugin and requires Java 21; do not run `java -version` as a routine preflight. Only check Java when a Gradle/Java command fails with a Java-version or missing-Java error. If missing, install via SDKMAN: `sdk install java 21-tem && sdk use java 21-tem`. If SDKMAN is not installed, see https://sdkman.io/install.
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
- **Single test**: `bun test ./test/tool/tool-define.test.ts` from `packages/opencode/`
- **CLI build artifact size check**: after `bun run script/build.ts --single --skip-install` in `packages/opencode/`, use `du -h dist/*/*/bin/kilo` (scoped package output lives under `dist/@kilocode/`)
@@ -35,7 +35,7 @@ Before saying an implementation is ready, run the smallest relevant checks that
| CLI | From `packages/opencode/`: `bun run typecheck`, `bun test` or targeted `bun test ./path/to/file.test.ts` |
| VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` |
| Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths |
| JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21 — check first with `java -version`. Install via SDKMAN if missing: `sdk install java 21-tem && sdk use java 21-tem`. |
| JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21; do not run `java -version` as a routine preflight. Check Java only after a Java-version or missing-Java failure. |
| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts`, or source link extraction |
Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead.
+116 -91
View File
@@ -1,144 +1,165 @@
<p align="center">
<a href="https://kilo.ai"><img width="250" alt="Kilo Code logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" /></a>
</p>
<p align="center">The open source coding agent for building with AI in VS Code, JetBrains, or the CLI.</p>
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://raster.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace" height="20"></a>
<a href="https://www.npmjs.com/package/@kilocode/cli"><img alt="npm" src="https://raster.shields.io/npm/v/@kilocode/cli?style=flat" height="20" /></a>
<a href="https://x.com/kilocode"><img src="https://raster.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)" height="20"></a>
<a href="https://blog.kilo.ai"><img src="https://raster.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog" height="20"></a>
<a href="https://blog.kilo.ai"><img src="https://raster.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Blog" height="20"></a>
<a href="https://kilo.ai/discord"><img src="https://raster.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" height="20"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit" height="20"></a>
</p>
<p align="center">
<a href="https://kilo.ai"><img width="250" alt="kilo-code-logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" /></a>
</p>
![Kilo-in-VS-Code-and-CLI](https://github.com/user-attachments/assets/0536ca59-ed81-4512-9e05-d186187a1b52)
<p align="center">
<strong>Kilo is the all-in-one agentic engineering platform.</strong><br>
Build, ship, and iterate faster with the most popular open source coding agent.
</p>
---
<p align="center">
<img width="100%" alt="Kilo Code running inside VS Code" src="https://kilo.ai/_next/image?url=%2Fscreenshots%2Fvs-code%2Fvs-code-home-page-screenshot.png&w=3840&q=75">
</p>
Kilo Code is an AI coding agent that meets you everywhere you work: [VS Code](https://kilo.ai/landing/vs-code), [JetBrains](https://kilo.ai/features/jetbrains-native), and the [CLI](https://kilo.ai/cli). It's open source with open pricing. You pick from 500+ models, switch between them mid-task, and pay the model provider's rate with zero markup. No API keys required to start.
<p align="center">
<a href="https://kilo.ai">Website</a> ·
<a href="https://kilo.ai/install">Install</a> ·
<a href="https://kilo.ai/landing/vs-code">IDE</a> ·
<a href="https://kilo.ai/cli">CLI</a> ·
<a href="https://kilo.ai/docs">Docs</a> ·
<a href="https://kilo.ai/leaderboard">Models</a> ·
<a href="https://kilo.ai/gateway">Gateway</a> ·
<a href="https://kilo.ai/pricing">Pricing</a> ·
<a href="https://kilo.ai/pricing/kilo-pass">Kilo Pass</a>
</p>
### Installation
<p align="center">
500+ models. One open source agent in <a href="https://kilo.ai/install">VS Code</a>, <a href="https://kilo.ai/features/jetbrains-native">JetBrains</a>, <a href="https://kilo.ai/cli">CLI</a>, <a href="https://kilo.ai/slack">Slack</a>, and <a href="https://kilo.ai/cloud">Cloud</a>.
</p>
Pick where you want to run Kilo.
- ✨ Generate code from natural language
- ✅ Checks its own work
- 🧪 Run terminal commands
- 🌐 Automate the browser
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
<details open>
<summary><strong>VS Code</strong></summary>
## Quick Links
<br>
- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
- Install CLI: `npm install -g @kilocode/cli`
- [Official Kilo.ai Home page](https://kilo.ai) (learn more)
Install the [Kilo Code extension](vscode:extension/kilocode.kilo-code) directly, or grab it from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). Create an account and you'll have access to 500+ models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, all at provider pricing.
## Key Features
</details>
- **Code Generation:** Kilo can generate code using natural language.
- **Inline Autocomplete:** Get intelligent code completions as you type, powered by AI.
- **Task Automation:** Kilo can automate repetitive coding tasks to save time.
- **Automated Refactoring:** Kilo can refactor and improve existing code efficiently.
- **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities.
- **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes.
<details open>
<summary><strong>CLI</strong></summary>
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
## Get Started with the CLI
<br>
```bash
# npm
npm install -g @kilocode/cli
# Or run directly with npx
npx @kilocode/cli
# curl
curl -fsSL https://kilo.ai/cli/install | bash
# pnpm
pnpm add -g @kilocode/cli
# bun
bun add -g @kilocode/cli
# Homebrew (macOS / Linux)
brew install Kilo-Org/tap/kilo
# Arch Linux (AUR)
paru -S kilo-bin
```
Then run `kilo` in any project directory to start.
<!-- kilocode_change start -->
</details>
### npm Install Note: Hidden `.kilo` File
<details>
<summary><strong>JetBrains</strong></summary>
On some systems and npm versions, installing `@kilocode/cli` can create a hidden `.kilo` file near the installed `kilo` command (for example in a global npm bin directory). This file is an npm-generated launcher helper, not project data.
<br>
- Why it exists: npm may create helper artifacts while wiring CLI executables.
- Size caveat: size can vary by platform, npm version, and install mode (symlink vs copied launcher), so a strict fixed size is not guaranteed.
- Safety: it is safe to leave in place. Do not edit it manually. Use your package manager's uninstall (`npm uninstall -g @kilocode/cli`) to remove install artifacts cleanly.
<!-- kilocode_change end -->
Install the [Kilo Code plugin](https://plugins.jetbrains.com/plugin/28350-kilo-code) from the JetBrains Marketplace, or search "Kilo Code" in `Settings → Plugins` inside any JetBrains IDE.
### Install from GitHub Releases (Optional)
</details>
Download the latest binary or source code from the [Releases page](https://github.com/Kilo-Org/kilocode/releases), use this quick guide:
<details>
<summary><strong>Cloud Agent</strong></summary>
- `kilo-<os>-<arch>.zip` is the CLI binary for your OS and CPU architecture on Windows and macOS. (`kilo-linux-<arch>.tar.gz` for Linux)
- `darwin` means macOS.
- `x64` is standard 64-bit Intel/AMD CPUs.
- `x64-baseline` is a compatibility build for older x64 CPUs(do not support AVX Instruction).
- `arm64` is ARM-based Linux/MacOS.
- `musl` is statically linked Linux build for Alpine/minimal Docker without glibc. Alpine/minimal Docker users should prefer the matching \*-musl asset.
- `kilo-vscode-*.vsix` is the VS Code extension package and not the CLI binary.
- `Source code` releases are for building from source, not normal installation.
<br>
For most users:
Run Kilo from the web, no local machine needed, at [app.kilo.ai/cloud](https://app.kilo.ai/cloud).
- **Windows (most PCs):** `kilo-windows-x64.zip`
- **macOS Apple Silicon:** `kilo-darwin-arm64.zip`
- **macOS Intel:** `kilo-darwin-x64.zip`
- **Linux x64:** `kilo-linux-x64.tar.gz`
- **Linux on ARM:** `kilo-linux-arm64.tar.gz`
</details>
<details>
<summary><strong>Code Reviews</strong></summary>
<br>
Set up automated AI code reviews on your pull requests at [app.kilo.ai/code-reviews](https://app.kilo.ai/code-reviews).
</details>
<details>
<summary><strong>KiloClaw</strong></summary>
<br>
Spin up your always-on AI agent at [app.kilo.ai/claw](https://app.kilo.ai/claw).
</details>
<details>
<summary>Install the CLI from GitHub Releases (binaries)</summary>
Download the latest binary from the [Releases page](https://github.com/Kilo-Org/kilocode/releases).
| Platform | Asset |
|---|---|
| Windows (most PCs) | `kilo-windows-x64.zip` |
| macOS (Apple Silicon) | `kilo-darwin-arm64.zip` |
| macOS (Intel) | `kilo-darwin-x64.zip` |
| Linux x64 | `kilo-linux-x64.tar.gz` |
| Linux ARM | `kilo-linux-arm64.tar.gz` |
Notes: `x64-baseline` is a compatibility build for older CPUs without AVX. `musl` is the statically linked build for Alpine or minimal Docker images without glibc. `kilo-vscode-*.vsix` is the VS Code extension package, not the CLI. `Source code` archives are for building from source.
</details>
### Agents
Kilo ships with specialized agents you switch between depending on the task. You can also build your own custom agents.
- **Code** - The default. Implements and edits code from natural language.
- **Plan** - Designs architecture and writes implementation plans before any code gets written.
- **Ask** - Answers questions about your codebase without touching any files.
- **Debug** - Troubleshoots and traces issues.
- **Review** - Reviews your changes and surfaces issues across performance, security, style, and test coverage.
Learn more about [agents and custom agents](https://kilo.ai/docs/code-with-ai/agents/using-agents).
### What it does
- **Code generation** from natural language, across multiple files.
- **Inline autocomplete** with ghost-text suggestions and tab to accept.
- **Self-checking** so the agent reviews and corrects its own work.
- **Terminal and browser control** to run commands and automate the web.
- **MCP marketplace** to find and wire up MCP servers that extend what the agent can do.
- **500+ models** with mid-task switching, so you can match latency, cost, and reasoning to the job.
### Autonomous Mode (CI/CD)
Use the `--auto` flag with `kilo run` to enable fully autonomous operation without user interaction. This is ideal for CI/CD pipelines and automated workflows:
Run `kilo run` with `--auto` for fully autonomous operation with no prompts, built for CI/CD pipelines:
```bash
kilo run --auto "run tests and fix any failures"
```
**Important:** The `--auto` flag disables all permission prompts and allows the agent to execute any action without confirmation. Only use this in trusted environments like CI/CD pipelines.
`--auto` disables all permission prompts and lets the agent execute any action without confirmation. Only use it in trusted environments.
## Contributing
### Documentation
We welcome contributions from developers, writers, and enthusiasts!
To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
For configuration and everything else, [head over to the docs](https://kilo.ai/docs).
See [RELEASING.md](RELEASING.md) for the VS Code extension and CLI release process.
### Contributing
See [packages/kilo-jetbrains/RELEASING.md](packages/kilo-jetbrains/RELEASING.md) for the JetBrains plugin release process.
Contributions are welcome from developers, writers, and everyone in between. Start with the [Contributing Guide](/CONTRIBUTING.md) for environment setup, coding standards, and how to open a pull request. See [RELEASING.md](RELEASING.md) for the VS Code extension and CLI release process, and [packages/kilo-jetbrains/RELEASING.md](packages/kilo-jetbrains/RELEASING.md) for the JetBrains plugin.
## Code of Conduct
Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) before getting involved.
Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
### License
## License
MIT. You're free to use, modify, and distribute this code, including commercially, as long as you keep the attribution and license notices. See [License](/LICENSE).
This project is licensed under the MIT License.
You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE).
## FAQ
### FAQ
<details>
<summary>Where did Kilo CLI come from?</summary>
@@ -146,3 +167,7 @@ You’re free to use, modify, and distribute this code, including for commercial
Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
</details>
---
**Join the community** [Discord](https://kilo.ai/discord) | [X](https://x.com/kilocode) | [Reddit](https://www.reddit.com/r/kilocode/)
+58 -459
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Hd0VEzglgXhfHyK/8aSnR7VxE4x57ykNGWndASJRF3c=",
"aarch64-linux": "sha256-qXu4K8hQF/JqLzYGKphpob0e1I+Q8czJZ7RY8LcRTkU=",
"aarch64-darwin": "sha256-p+WYvQLgj7X1bT7LHTmu3HZ3W9uo1rz9wSoVOgrC5k8=",
"x86_64-darwin": "sha256-89Gy8mdutYBJcY+E57x2j3Rd0RRqrcaJstaQqex/nJA="
"x86_64-linux": "sha256-qzU+msL1HicrkrYHNCitcutUqTFUatMArEuGgf/gOoE=",
"aarch64-linux": "sha256-GebxO5jgwnsNbvBdrbOlJ6u9C3/r6J7Q8WbzBv+wmhI=",
"aarch64-darwin": "sha256-NtAQ6QiL+iGaDxuFyvBiMFvBoEgBb5pK8b8xgkRdKx0=",
"x86_64-darwin": "sha256-rfxQmcEFsPD7AzSCBUZNxkNQUnkPnoN8a4shieEK1+Q="
}
}
+1 -7
View File
@@ -31,7 +31,6 @@
"@types/bun": "1.3.14",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.11",
"@opentui/solid": "0.2.11",
"ulid": "3.0.1",
@@ -82,7 +81,6 @@
}
},
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
@@ -92,20 +90,16 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"turbo": "2.9.14",
"@types/bun": "catalog:",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.27.10"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1025.0",
"@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@kilocode/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
"@morphllm/morphsdk": "0.2.166"
"typescript": "catalog:"
},
"repository": {
"type": "git",
+1
View File
@@ -8,6 +8,7 @@ Images
- `base`: Ubuntu 24.04 with common build tools and utilities
- `bun-node`: `base` plus Bun and Node.js 24
- `jetbrains`: `bun-node` plus Java 21 and the pre-cached Gradle distribution
- `rust`: `bun-node` plus Rust (stable, minimal profile)
- `tauri-linux`: `rust` plus Tauri Linux build dependencies
- `publish`: `bun-node` plus Docker CLI and AUR tooling
+35
View File
@@ -0,0 +1,35 @@
# kilocode_change start
ARG REGISTRY=ghcr.io/kilo-org
FROM ${REGISTRY}/build/bun-node:24.04
ARG DEBIAN_FRONTEND=noninteractive
ARG GRADLE_VERSION=9.4.1
# Install Java 21 (apt handles multi-arch automatically)
RUN apt-get update \
&& apt-get install -y --no-install-recommends openjdk-21-jdk-headless \
&& rm -rf /var/lib/apt/lists/*
# Create a stable /usr/lib/jvm/java-21 symlink that works on amd64 and arm64
RUN java_home="$(dirname "$(dirname "$(readlink -f "$(which java)")")")" \
&& ln -nfs "${java_home}" /usr/lib/jvm/java-21
ENV JAVA_HOME=/usr/lib/jvm/java-21
# Pre-download the exact Gradle distribution used by the wrapper so CI jobs
# skip the download entirely. GRADLE_USER_HOME is set to a location that any
# user (including the GitHub Actions runner) can read.
ENV GRADLE_USER_HOME=/gradle-home
RUN set -euo pipefail; \
url="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"; \
hash=$(echo -n "${url}" | md5sum | cut -d' ' -f1); \
dir="/gradle-home/wrapper/dists/gradle-${GRADLE_VERSION}-bin/${hash}"; \
mkdir -p "${dir}"; \
curl -fsSL "${url}" -o "${dir}/gradle-${GRADLE_VERSION}-bin.zip"; \
unzip -q "${dir}/gradle-${GRADLE_VERSION}-bin.zip" -d "${dir}"; \
touch "${dir}/gradle-${GRADLE_VERSION}-bin.zip.ok"; \
rm "${dir}/gradle-${GRADLE_VERSION}-bin.zip"; \
java -version; \
"${dir}/gradle-${GRADLE_VERSION}/bin/gradle" --version
# kilocode_change end
+1 -1
View File
@@ -17,7 +17,7 @@ const manager = pkg.packageManager ?? ""
const bun = manager.startsWith("bun@") ? manager.slice(4) : ""
if (!bun) throw new Error("packageManager must be bun@<version>")
const images = ["base", "bun-node", "rust", "tauri-linux", "publish"]
const images = ["base", "bun-node", "jetbrains", "rust", "tauri-linux", "publish"] // kilocode_change
const setup = async () => {
if (!push) return
+1 -2
View File
@@ -22,8 +22,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:"
"@types/npmcli__arborist": "6.3.3"
},
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
View File
@@ -1,7 +1,14 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { useTuiNotificationSettings } from "./state/ui"
import { type TitleIcon, useTuiNotificationSettings } from "./state/ui"
const icons = [
{ value: "none", label: "None" },
{ value: "unicode", label: "Unicode" },
{ value: "emojis", label: "Emojis" },
] satisfies SelectOption<TitleIcon>[]
function Toggle(props: {
label: string
@@ -34,57 +41,82 @@ export function CliNotificationsRoute() {
return (
<ConfigPage
title="CLI Notifications"
description="Configure TUI attention alerts, desktop notifications, and sound defaults."
description="Configure terminal title indicators, TUI attention alerts, desktop notifications, and sound defaults."
actions={
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.dirty()} onClick={state.save}>
Save
</Button>
}
>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Attention</h2>
<p>Control when the TUI asks for attention and how it notifies you.</p>
<div class="ui-settings">
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Terminal title</h2>
<p>Choose how session status appears in the terminal tab title.</p>
</div>
</header>
<div class="ui-form">
<div class="ui-field">
<span>Title Icon</span>
<CustomSelect
class="title-icon-select"
label="Title Icon"
value={state.icon()}
options={icons}
disabled={Boolean(state.ctx.saving())}
onSelect={state.setIcon}
/>
<small>None hides status icons. Unicode and Emojis show working, attention, and finished states.</small>
</div>
</div>
</header>
<div class="ui-form attention-form">
<Toggle
label="Attention alerts"
description="Turn on TUI attention events."
checked={state.alert()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAlert(!state.alert())}
/>
<Toggle
label="Desktop notifications"
description="Show desktop notifications when attention alerts fire."
checked={state.notify()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setNotify(!state.notify())}
/>
<Toggle
label="Sound"
description="Play an attention sound when alerts fire."
checked={state.sound()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setSound(!state.sound())}
/>
<label class="ui-field">
<span>Volume</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={state.volume()}
disabled={!state.alert()}
onInput={(event) => state.setVolume(event.currentTarget.value)}
</Card>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Attention</h2>
<p>Control when the TUI asks for attention and how it notifies you.</p>
</div>
</header>
<div class="ui-form attention-form">
<Toggle
label="Attention alerts"
description="Turn on TUI attention events."
checked={state.alert()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAlert(!state.alert())}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
<Toggle
label="Desktop notifications"
description="Show desktop notifications when attention alerts fire."
checked={state.notify()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setNotify(!state.notify())}
/>
<Toggle
label="Sound"
description="Play an attention sound when alerts fire."
checked={state.sound()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setSound(!state.sound())}
/>
<label class="ui-field">
<span>Volume</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={state.volume()}
disabled={!state.alert()}
onInput={(event) => state.setVolume(event.currentTarget.value)}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
</div>
</ConfigPage>
)
}
@@ -10,6 +10,7 @@ export type Theme = {
}
type Diff = "auto" | "stacked"
export type TitleIcon = NonNullable<TuiPatch["title_icon"]>
const fallback = ["#0c0a09", "#fafaf9", "#f9f76f", "#a6a09b", "#3794ff", "#44403b"]
@@ -204,15 +205,18 @@ export function useTuiNotificationSettings() {
const [notify, setNotify] = createSignal(true)
const [sound, setSound] = createSignal(true)
const [volume, setVolume] = createSignal("0.4")
const [icon, setIcon] = createSignal<TitleIcon>("none")
const [dirty, setDirty] = createSignal(false)
createEffect(() => {
if (dirty()) return
const cfg = ctx.data()?.tui.attention
const tui = ctx.data()?.tui
const cfg = tui?.attention
setAlert(bool(cfg?.enabled, false))
setNotify(bool(cfg?.notifications, true))
setSound(bool(cfg?.sound, true))
setVolume(String(cfg?.volume ?? 0.4))
setIcon(tui?.title_icon ?? "none")
})
function change(run: () => void) {
@@ -228,6 +232,7 @@ export function useTuiNotificationSettings() {
}
ctx.tui({
title_icon: icon(),
attention: {
enabled: alert(),
notifications: notify(),
@@ -248,6 +253,8 @@ export function useTuiNotificationSettings() {
setSound: (value: boolean) => change(() => setSound(value)),
volume,
setVolume: (value: string) => change(() => setVolume(value)),
icon,
setIcon: (value: TitleIcon) => change(() => setIcon(value)),
dirty,
save,
}
+6 -3
View File
@@ -8,17 +8,20 @@
background: var(--card);
}
.kilo-console .ui-card:has(.console-diff-select[open]) {
.kilo-console .ui-card:has(.console-diff-select[open]),
.kilo-console .ui-card:has(.title-icon-select[open]) {
position: relative;
z-index: 40;
overflow: visible;
}
.kilo-console .console-diff-select[open] {
.kilo-console .console-diff-select[open],
.kilo-console .title-icon-select[open] {
z-index: 41;
}
.kilo-console .console-diff-select .models-select-menu {
.kilo-console .console-diff-select .models-select-menu,
.kilo-console .title-icon-select .models-select-menu {
z-index: 42;
}
+5
View File
@@ -22,6 +22,11 @@ export const CustomizeNav: NavSection[] = [
children: "Custom Subagents",
platform: "new",
},
{
href: "/customize/agent-permissions",
children: "Agent Permissions",
platform: "new",
},
{ href: "/customize/agents-md", children: "agents.md" },
{ href: "/customize/workflows", children: "Workflows", platform: "new" },
{ href: "/customize/skills", children: "Skills" },
@@ -142,24 +142,40 @@ The model selection is remembered per mode across sessions.
For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents).
## Selecting a Model via a Link (VS Code)
## Selecting a Model or Agent via a Link (VS Code)
The VS Code extension supports a `vscode://` protocol handler that lets you open VS Code and automatically select a specific model — no manual picker interaction required. This is useful for sharing model recommendations, launching a specific model tier from a web page, or switching quickly to a newly announced model.
The VS Code extension supports a `vscode://` protocol handler that lets you open VS Code and automatically select a model, an agent, or both — no manual picker interaction required. This is useful for sharing model recommendations, launching a specific model tier from a web page, or switching quickly to a preferred agent.
### URL Format
Include at least one of the `model` or `agent` parameters:
```
vscode://kilocode.kilo-code/kilocode/model?model=<modelID>
vscode://kilocode.kilo-code/kilocode/switch?model=<modelID>
vscode://kilocode.kilo-code/kilocode/switch?agent=<agentName>
vscode://kilocode.kilo-code/kilocode/switch?model=<modelID>&agent=<agentName>
```
Replace `<modelID>` with the Kilo Gateway model ID you want to select (e.g. `kilo-auto/free`). Only models available in the Kilo Gateway catalog are accepted; the link is silently ignored if the model ID is not found.
Replace `<modelID>` with a Kilo Gateway model ID such as `kilo-auto/free`. Replace `<agentName>` with a visible primary agent ID such as `code` or `plan`, rather than its display name.
### Example: Auto Free
To open Kilo Code and switch to the [Auto Free](/docs/code-with-ai/agents/auto-model) tier (`kilo-auto/free`), use:
```
vscode://kilocode.kilo-code/kilocode/model?model=kilo-auto%2Ffree
vscode://kilocode.kilo-code/kilocode/switch?model=kilo-auto%2Ffree
```
To switch only to Plan and use its normal model selection, specify the agent without a model:
```
vscode://kilocode.kilo-code/kilocode/switch?agent=plan
```
To select both at the same time, include both parameters:
```
vscode://kilocode.kilo-code/kilocode/switch?model=kilo-auto%2Ffree&agent=plan
```
{% callout type="tip" %}
@@ -168,18 +184,20 @@ URL-encode the `/` in model IDs as `%2F` when embedding this URL in HTML links o
### How It Works
- **VS Code open**: the Kilo sidebar is focused and the model is selected in the active session immediately.
- **VS Code closed**: VS Code launches, then applies the model selection once the extension is ready.
- The model is validated against the current Kilo Gateway model catalog before being applied. If the model ID is not found in the catalog, the deep link is silently ignored.
- Model selection via deep link follows the same precedence as using the model picker: it applies as a session override for the active session (or sets the next-session model when no session is active), and updates your model recents list. It does **not** change your configured default model in settings.
- **VS Code open**: the Kilo sidebar is focused and the linked selection is applied to the active session immediately.
- **VS Code closed**: VS Code launches, then applies the selection once the extension is ready.
- When `model` is provided, it must identify a model in the current Kilo Gateway catalog. Invalid or unavailable models cause the deep link to be ignored.
- When `agent` is provided, it must identify a visible primary agent. Invalid or unavailable agents cause the deep link to be ignored.
- An agent-only link uses the model that would normally be selected for that agent. When both parameters are present, the agent is selected first so the linked model applies to it.
- The selection follows the same precedence as using the pickers: it updates the active session, or the next session when no session is active. It does **not** change your configured defaults in settings.
### Sharing and Embedding
You can embed these links in a web page:
```html
<a href="vscode://kilocode.kilo-code/kilocode/model?model=kilo-auto%2Ffree">
Open Kilo Code with Auto Free
<a href="vscode://kilocode.kilo-code/kilocode/switch?model=kilo-auto%2Ffree&amp;agent=plan">
Open Kilo Code with Auto Free in Plan
</a>
```
@@ -41,7 +41,7 @@ Enable and sign in to the Kilo provider to use voice input in prompt fields. Req
## Choose a model
You can optionally choose a transcription model in **Settings** > **Experimental** > **Speech to Text Model**. Kilo stores this choice as `experimental.speech_to_text_model` in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`).
You can optionally choose a transcription model in **Settings** > **Models** > **Speech to Text Model**. Kilo stores this choice as `experimental.speech_to_text_model` in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`).
---
@@ -0,0 +1,189 @@
---
title: "Agent Permissions"
description: "Configure Kilo Code agent permission rules for tools, shell commands, files, and subagents"
platform: new
---
# Agent Permissions
Agent permissions decide whether a tool call is allowed, asks for approval, or is denied.
This page focuses on Markdown agent files, where permission rules are written as YAML frontmatter under the `permission` key. For global defaults in `kilo.jsonc`, use the JSON examples in [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#glob-pattern-rules).
## Actions
Each permission rule uses one of these actions:
| Action | Behavior |
|---|---|
| `allow` | Run the matching tool call without asking. |
| `ask` | Prompt before running the matching tool call. |
| `deny` | Block the matching tool call. |
You can write each permission as one action for the whole tool or as a pattern map:
```yaml
permission:
read: allow
edit:
"*": deny
"*.md": allow
bash:
"*": ask
"git status *": allow
```
## Rule Precedence
Permission rules are evaluated in config order. When more than one rule matches the requested permission and target pattern, the last matching rule wins.
Put broad fallbacks first and exceptions after them:
```yaml
permission:
bash:
"*": ask
"uv *": allow
```
With that config, `uv pip install ...` is allowed because `uv *` appears after the catch-all `*`.
If you put the catch-all last, it overrides the earlier specific rule:
```yaml
permission:
bash:
"uv *": allow
"*": ask
```
With that config, `uv pip install ...` asks because the later `*` rule also matches.
Top-level permission keys follow the same rule. For example, this lets `bash` override the global fallback:
```yaml
permission:
"*": ask
bash: allow
```
This does the opposite because the top-level `*` is later:
```yaml
permission:
bash: allow
"*": ask
```
## Patterns
Permission patterns use glob matching:
| Pattern | Matches |
|---|---|
| `*` | Any target for that permission. |
| `git *` | `git`, `git status`, `git log --oneline`, and other `git` commands. |
| `git status *` | `git status` with or without extra arguments. |
| `src/*` | Paths under `src/`. |
| `*.env` | Files ending in `.env`, including nested paths such as `apps/web/.env`. |
The matcher normalizes Windows backslashes to forward slashes before matching. On Windows, matching is case-insensitive; on Unix-like systems, matching is case-sensitive. Prefer forward slashes in config because they work across platforms.
`~`, `~/...`, `$HOME`, and `$HOME/...` at the start of a pattern are expanded to your home directory when the config is loaded.
## File Paths
File tools such as `read`, `edit`, and `write` resolve the input path first, then check permissions against the path relative to the current worktree.
For project files, use workspace-relative patterns:
```yaml
permission:
read:
"*": ask
"docs/*": allow
"src/generated/*": deny
edit:
"*": deny
"*.md": allow
```
Absolute paths are mainly relevant for `external_directory` permissions and shell commands that touch paths outside the worktree.
## Shell Commands
The `bash` permission is checked against parsed shell command patterns. If a shell block contains multiple parsed commands, each relevant command must be permitted. A single denied command rejects the request.
For example:
```yaml
permission:
bash:
"*": ask
"cd *": allow
"git *": deny
```
For this command:
```bash
cd "/project"; git status
```
Kilo checks the parsed command patterns. The `git status` command matches `git *`, so the request is denied. Directory changes and commands that access paths outside the worktree can also trigger `external_directory` checks.
Built-in read-only agents include additional shell restrictions for write-like patterns such as output redirection, command substitution, pipes, and command chains. If you create your own read-only agent, prefer an explicit deny fallback and allow only the commands you trust:
```yaml
permission:
bash:
"*": deny
"cat *": allow
"grep *": allow
"git status *": allow
"git diff *": allow
```
## Sensitive Files
Kilo treats `.env` and `.env.*` reads as sensitive. Broad read approvals, such as `read: allow`, `read: { "*": allow }`, saved wildcard approvals, or allow-everything mode do not bypass the built-in prompt for these files. `.env.example` is treated as safe documentation and can be allowed by default.
Use explicit sensitive-file rules only when you intentionally want that behavior for a specific agent:
```yaml
permission:
read:
"*": allow
"*.env": ask
"*.env.*": ask
"*.env.example": allow
```
## Subagent Delegation
Use `task` permission rules to control which subagents another agent may invoke:
```yaml
permission:
task:
"*": deny
"code-reviewer": allow
"docs-writer": allow
```
This allows delegation only to `code-reviewer` and `docs-writer`.
## Troubleshooting
- If a specific rule appears to be ignored, check whether a later catch-all also matches.
- If a broad allow does not apply to `.env`, this is expected sensitive-file protection.
- If a shell command asks unexpectedly, check whether it was parsed into more than one command pattern or triggered `external_directory`.
- If path rules behave differently across operating systems, write patterns with forward slashes and workspace-relative paths where possible.
## Related
- [Custom Modes](/docs/customize/custom-modes)
- [Custom Subagents](/docs/customize/custom-subagents)
- [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions)
- [.kilocodeignore](/docs/customize/context/kilocodeignore)
- [Tool Use Overview](/docs/automate/tools)
@@ -191,7 +191,7 @@ The `permission` field controls what tools the subagent can use. Each tool permi
}
```
For bash commands, you can use glob patterns to set permissions per command. Rules are evaluated in order, with the **last matching rule winning**.
For bash commands, you can use glob patterns to set permissions per command. Rules are evaluated in order, with the **last matching rule winning**. See [Agent Permissions](/docs/customize/agent-permissions) for rule precedence, shell command patterns, path matching, and sensitive-file behavior.
You can also control which subagents an agent can invoke via `permission.task`:
@@ -17,6 +17,7 @@ Configure how Kilo Code behaves and responds:
- [**Custom Rules**](/docs/customize/custom-rules) - Define rules that apply to specific file types or situations
- [**Custom Instructions**](/docs/customize/custom-instructions) - Add project-specific guidelines and context
- [**Custom Subagents**](/docs/customize/custom-subagents) - Create specialized subagents with custom prompts, models, and permissions
- [**Agent Permissions**](/docs/customize/agent-permissions) - Configure rule precedence, shell command patterns, file paths, and subagent delegation
- [**agents.md**](/docs/customize/agents-md) - Configure agent behavior at the project level
- [**Workflows**](/docs/customize/workflows) - Automate multi-step processes
- [**Skills**](/docs/customize/skills) - Extend Kilo's capabilities with reusable skill definitions
@@ -125,6 +125,40 @@ Permissions are configured under the `permission` key in `kilo.jsonc`. The follo
Instead of a simple `"allow"` or `"deny"`, each tool can use glob-pattern rules for granular control. Patterns are matched against the tool's arguments (command strings, file paths, etc.), and the last matching rule wins.
### Rule Precedence
Permission rules are evaluated in config order. When more than one rule matches the requested permission and target pattern, the last matching rule wins.
Put broad fallbacks first and exceptions after them:
```json
{
"permission": {
"bash": {
"*": "ask",
"uv *": "allow"
}
}
}
```
With that config, `uv pip install ...` is allowed because `uv *` appears after the catch-all `*`.
If you put the catch-all last, it overrides the earlier specific rule:
```json
{
"permission": {
"bash": {
"uv *": "allow",
"*": "ask"
}
}
}
```
With that config, `uv pip install ...` asks because the later `*` rule also matches.
### Example: Shell Commands
Allow git commands automatically, but prompt for everything else:
@@ -133,8 +167,8 @@ Allow git commands automatically, but prompt for everything else:
{
"permission": {
"bash": {
"git *": "allow",
"*": "ask"
"*": "ask",
"git *": "allow"
}
}
}
@@ -148,8 +182,8 @@ Prompt before reading `.env` files, but allow all other reads:
{
"permission": {
"read": {
"*.env": "ask",
"*": "allow"
"*": "allow",
"*.env": "ask"
}
}
}
@@ -163,11 +197,11 @@ Deny `rm -rf` commands, allow common dev commands, and ask for anything else:
{
"permission": {
"bash": {
"*": "ask",
"rm -rf *": "deny",
"npm *": "allow",
"bun *": "allow",
"git *": "allow",
"*": "ask"
"git *": "allow"
}
}
}
@@ -185,7 +219,7 @@ Different agents can have different permission levels. Override the default perm
"agent": {
"code": {
"permission": {
"bash": { "git *": "allow", "*": "ask" }
"bash": { "*": "ask", "git *": "allow" }
}
},
"plan": {
@@ -199,6 +233,26 @@ Different agents can have different permission levels. Override the default perm
In this example, the `code` agent can run `git` commands automatically and asks for other shell commands, while the `plan` agent cannot run shell commands at all.
## Markdown Agent Files
If you define agents in Markdown files, the `permission` frontmatter uses the same `allow` / `ask` / `deny` values and glob patterns as `kilo.jsonc`:
```markdown
---
description: Reviews code for quality and best practices
mode: subagent
permission:
bash:
"*": ask
"git *": allow
read:
"*": allow
"*.env": ask
---
```
This is the same permission shape described in [Agent Permissions](/docs/customize/agent-permissions), just shown in the agent-file format.
## Runtime Permission Requests
When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt. You have three options:
@@ -234,12 +288,12 @@ This is a custom example showing the available configuration options — it does
```json
{
"permission": {
"read": { "*.env": "ask", "*": "allow" },
"edit": { "*.env": "ask", "*": "allow" },
"read": { "*": "allow", "*.env": "ask" },
"edit": { "*": "allow", "*.env": "ask" },
"glob": { "*": "allow" },
"grep": { "*": "allow" },
"list": { "*": "allow" },
"bash": { "git *": "allow", "npm *": "allow", "*": "ask" },
"bash": { "*": "ask", "git *": "allow", "npm *": "allow" },
"task": { "*": "allow" },
"skill": { "*": "allow" },
"lsp": { "*": "allow" },
@@ -253,7 +307,7 @@ This is a custom example showing the available configuration options — it does
"agent": {
"code": {
"permission": {
"bash": { "git *": "allow", "npm *": "allow", "*": "ask" }
"bash": { "*": "ask", "git *": "allow", "npm *": "allow" }
}
}
}
@@ -47,6 +47,18 @@ Use **Local Config** or **Global Config** in the Settings header to open the mat
If you check config files into version control, make sure they do not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead.
{% /callout %}
### Voice Transcription Model
When the Kilo provider is enabled and you are signed in, choose the transcription model under **Models** > **Speech to Text Model**. This stores `experimental.speech_to_text_model` in your global Kilo CLI config:
```json
{
"experimental": {
"speech_to_text_model": "openai/whisper-large-v3-turbo"
}
}
```
### Reasoning Blocks
Reasoning blocks stay expanded by default in the VS Code chat UI. Enable **Auto-Collapse Reasoning** in the Display tab, or set `auto_collapse_reasoning` in `kilo.jsonc`, to collapse them after the agent finishes writing them:
@@ -198,20 +210,9 @@ Available experimental settings include:
- **Share mode** - `manual`, `auto`, or `disabled` session sharing
- **LSP integration** - expose language server diagnostics to the agent
- **Paste summary** - summarize large clipboard pastes before including them
- **Speech to Text Model** - optionally select the transcription model
- **Batch tool** - allow the agent to batch multiple tool calls in one step
- **OpenTelemetry** - enable Kilo telemetry and optional OTLP export when configured
Voice input appears automatically when the Kilo provider is enabled and you are signed in. Choosing **Speech to Text Model** stores `experimental.speech_to_text_model` in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`):
```json
{
"experimental": {
"speech_to_text_model": "openai/gpt-4o-mini-transcribe"
}
}
```
Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`:
```json
@@ -8,7 +8,7 @@ description: "Connect Composio to your KiloClaw agent to access hundreds of tool
Connect Composio to your KiloClaw agent to instantly unlock access to 250+ tool integrations — from Salesforce and HubSpot to Notion, Jira, and beyond. Composio is a platform that handles the authentication and connection details for each service, so your agent can use them without you having to set up each one individually.
{% callout type="info" title="Tip" %}
Browse the full list of tools Composio supports at [composio.dev/tools](https://composio.dev/tools). If a tool you need is listed there, you can connect it to KiloClaw through Composio in minutes.
Browse the full list of toolkits Composio supports at [composio.dev/toolkits](https://composio.dev/toolkits). If a toolkit you need is listed there, you can connect it to KiloClaw through Composio in minutes.
{% /callout %}
## Prerequisites
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29bb5361de5ac4b7acf247b8f9976c715c4e300448f6e504ec791566eca91219
size 25750
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7ec87f1e046cf1a44688b952c0d994899119c6663bf627026688bf337b56127
size 2411
oid sha256:1370f2dcf5c50258971e0665ea151f2c522f566619745d58d6a10f3914949e6c
size 2521
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ee0856c65e445a230cf3e94bfe592c98306b7502e44a2602b5fac2f6d69937a
size 4601
oid sha256:b8af37e0c169811092437b71110c63571ae43c4d35e12f6890be8295a3cb67da
size 4569
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1ee2d880a055b03664d594598a6e8764ab27d443a578572fd01b37451cb0193
size 3193
oid sha256:16fdb6361199c58a8e99ad2661dc47791b0f4737ada16760151a26e7ebdd8bd1
size 3044
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c1b7ab570623823e73afbbf2ab7d6b66ab16d948405ef9ced0a012441b70301
size 25148
oid sha256:e872bc8ad05ad2a7ad1dd6de8a2ff427957fd278cee46940db0b12d84fe42ecf
size 25534
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce6ad833325a582ca6a2107c94f007c589816c149179fa9b317f060980e5e4fa
size 20250
oid sha256:ff33e800f4d2379bcc997228a1e4dd83433521b931ae85edb7c8f0c4c93405e7
size 20540
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
size 37836
oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22
size 48674
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
size 37836
oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22
size 48674
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ced53d4974eeacc629eede823c7e37191f40ea2816f80f18c1de1a833fbcc614
size 45341
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:57fa5c60dd92a2f4299e58ead8a682c4802de415a496c247af2eba2faf0d63e4
size 50341
oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413
size 53851
-1
View File
@@ -33,7 +33,6 @@
},
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/openai": "3.0.53",
@@ -44,6 +44,8 @@ export namespace FileIgnore {
"**/*.log",
"**/coverage/**",
"**/.nyc_output/**",
"**/.kilo/worktrees/**",
"**/.kilocode/worktrees/**",
]
export const PATTERNS = [...files, ...folders]
@@ -16,6 +16,7 @@ export class CacheManager implements ICacheManager {
private readonly cachePath: string
private fileHashes: Record<string, string> = {}
private saveTimer: ReturnType<typeof setTimeout> | undefined
private saveTask = Promise.resolve()
constructor(
private readonly cacheDirectory: string,
@@ -36,28 +37,36 @@ export class CacheManager implements ICacheManager {
private scheduleSave(): void {
if (this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.performSave(), 1500)
this.saveTimer = setTimeout(() => {
void this.flush().catch((err) => log.error("failed to save cache", { err }))
}, 1500)
}
private async performSave(): Promise<void> {
try {
await fs.mkdir(path.dirname(this.cachePath), { recursive: true })
const tmp = `${this.cachePath}.tmp`
await fs.writeFile(tmp, JSON.stringify(this.fileHashes), "utf-8")
await fs.rename(tmp, this.cachePath)
} catch (err) {
await fs.mkdir(path.dirname(this.cachePath), { recursive: true })
const tmp = `${this.cachePath}.tmp`
await fs.writeFile(tmp, JSON.stringify(this.fileHashes), "utf-8")
await fs.rename(tmp, this.cachePath)
}
async flush(): Promise<void> {
if (this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = undefined
const task = this.saveTask.then(() => this.performSave())
this.saveTask = task.catch((err) => {
log.error("failed to save cache", { err })
}
})
await task
}
seedHashes(hashes: Readonly<Record<string, string>>): void {
this.fileHashes = { ...hashes }
this.scheduleSave()
}
async clearCacheFile(): Promise<void> {
try {
this.fileHashes = {}
await fs.mkdir(path.dirname(this.cachePath), { recursive: true })
await fs.writeFile(this.cachePath, "{}", "utf-8")
} catch (err) {
log.error("failed to clear cache file", { err })
}
this.fileHashes = {}
await this.flush()
}
getHash(filePath: string): string | undefined {
@@ -77,4 +86,16 @@ export class CacheManager implements ICacheManager {
getAllHashes(): Record<string, string> {
return { ...this.fileHashes }
}
signature(): string {
const entries = Object.entries(this.fileHashes).sort(([left], [right]) => left.localeCompare(right))
return createHash("sha256").update(JSON.stringify(entries)).digest("hex")
}
async stamp(): Promise<string | undefined> {
return fs
.stat(this.cachePath)
.then((value) => `${value.mtimeMs}:${value.ctimeMs}:${value.size}`)
.catch(() => undefined)
}
}
@@ -1,6 +1,7 @@
import type { PointStruct } from "./vector-store"
import type { Disposable, Emitter } from "../runtime"
import type { IndexingTelemetryMode } from "./telemetry"
import type { WorktreeOverlay } from "../worktree-overlay"
export interface ICodeParser {
parseFile(
@@ -36,6 +37,8 @@ export interface IFileWatcher extends Disposable {
initialize(): Promise<void>
updateBatchSegmentThreshold(newThreshold: number): void
setCollecting(collecting: boolean): void
setOverlay?(overlay?: WorktreeOverlay): void
shutdown?(): Promise<void>
readonly onDidStartBatchProcessing: Emitter<string[]>
readonly onBatchProgressUpdate: Emitter<{
@@ -31,7 +31,7 @@ export interface ICodeIndexManager {
totalItems: number
currentItemUnit: string
}
dispose(): void
dispose(): Promise<void>
}
export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error"
@@ -8,6 +8,12 @@ export type PointStruct = {
}
export interface IVectorStore {
/**
* Opens an existing complete store without mutating it.
*/
openExisting?(): Promise<void>
close?(): Promise<void>
/**
* Initializes the vector store
* @returns Promise resolving to boolean indicating if a new collection was created
@@ -90,6 +96,7 @@ export interface VectorStoreSearchResult {
export interface Payload {
filePath: string
fileHash?: string
codeChunk: string
startLine: number
endLine: number
+135 -23
View File
@@ -1,4 +1,5 @@
import type { VectorStoreSearchResult } from "./interfaces"
import path from "path"
import type { IVectorStore, VectorStoreSearchResult } from "./interfaces"
import type { IndexingState } from "./interfaces/manager"
import type { IndexingTelemetryEvent, IndexingTelemetryMeta, IndexingTelemetryTrigger } from "./interfaces/telemetry"
import { CodeIndexConfigManager, type IndexingConfigInput } from "./config-manager"
@@ -12,8 +13,18 @@ import { Emitter } from "./runtime"
import { Log } from "../util/log"
import { loadIgnore } from "./shared/load-ignore"
import { sanitizeErrorMessage } from "./shared/validation-helpers"
import { WorktreeOverlay } from "./worktree-overlay"
const log = Log.create({ service: "indexing-manager" })
const BASELINE_CHECK_INTERVAL = 1_000
const BASELINE_SIGNATURE_INTERVAL = 30_000
type Baseline = {
store?: IVectorStore
signature: string
stamp?: string
overlay?: WorktreeOverlay
}
/**
* RATIONALE: Removed the static singleton Map and vscode.ExtensionContext.
@@ -29,6 +40,13 @@ export class CodeIndexManager {
private _orchestrator: CodeIndexOrchestrator | undefined
private _searchService: CodeIndexSearchService | undefined
private _cacheManager: CacheManager | undefined
private _baselineStore: IVectorStore | undefined
private _baselineSignature: string | undefined
private _baselineStamp: string | undefined
private _baselineChecked = 0
private _baselineSigned = 0
private _baselineRefresh: Promise<void> | undefined
private _overlay: WorktreeOverlay | undefined
private _isRecoveringFromError = false
private _retryTimer: ReturnType<typeof setTimeout> | undefined
private _retryResolve: (() => void) | undefined
@@ -41,6 +59,7 @@ export class CodeIndexManager {
constructor(
public readonly workspacePath: string,
private readonly cacheDirectory: string,
public readonly baselinePath?: string,
) {
this._stateManager = new CodeIndexStateManager()
}
@@ -130,7 +149,7 @@ export class CodeIndexManager {
}
if (event.type !== "error") return
if (event.location !== "orchestrator:startIndexing") return
if (event.location !== "orchestrator:startIndexing" && event.location !== "orchestrator:watcher") return
if (!this.isFeatureEnabled || !this.isFeatureConfigured) return
if (this._retryTask || this._isRecoveringFromError) return
@@ -405,15 +424,13 @@ export class CodeIndexManager {
await task
}
public dispose(): void {
public async dispose(): Promise<void> {
if (this._disposed) return
this._disposed = true
this.clearRetryTimer()
this._retryTask = undefined
// RATIONALE: cancelIndexing() sets _cancelRequested and calls stopWatcher() +
// scanner.cancel(), which cooperatively aborts any in-flight scan. Using only
// stopWatcher() left the orchestrator's _runScan() unaware it should exit.
this._orchestrator?.cancelIndexing()
await this._orchestrator?.shutdown?.()
await this._baselineStore?.close?.()
this._stateManager.dispose()
this._telemetry.dispose()
}
@@ -437,30 +454,103 @@ export class CodeIndexManager {
public async searchIndex(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {
if (!this.isFeatureEnabled) return []
this.assertInitialized()
await this.refreshBaseline()
return this._searchService!.searchIndex(query, directoryPrefix)
}
private async _recreateServices(): Promise<void> {
log.info("starting indexing service recreation", { workspacePath: this.workspacePath })
this._orchestrator?.stopWatcher()
this._orchestrator = undefined
this._searchService = undefined
private async refreshBaseline(): Promise<void> {
if (!this.baselinePath || this._disposed) return
if (this._baselineRefresh) return this._baselineRefresh
const now = Date.now()
if (now - this._baselineChecked < BASELINE_CHECK_INTERVAL) return
this._baselineChecked = now
const baselinePath = this.baselinePath
this._serviceFactory = new CodeIndexServiceFactory(
const task = (async () => {
const cache = new CacheManager(this.cacheDirectory, baselinePath)
const stamp = await cache.stamp()
const force = now - this._baselineSigned >= BASELINE_SIGNATURE_INTERVAL
if (!force && stamp === this._baselineStamp) return
await cache.initialize()
const signature = cache.signature()
this._baselineStamp = stamp
this._baselineSigned = now
if (signature === this._baselineSignature && this._baselineStore) return
const baseline = await this.createBaseline(this._serviceFactory!)
this._baselineStamp = baseline?.stamp ?? stamp
this._baselineSigned = now
if (!baseline?.store) {
if (!this._baselineStore) this._baselineSignature = signature
return
}
log.info("shared indexing baseline changed; rebuilding worktree delta", {
workspacePath: this.workspacePath,
baselinePath,
})
await this._recreateServices(baseline)
if (this._disposed) return
await this._orchestrator?.startIndexing("background")
})().finally(() => {
this._baselineRefresh = undefined
})
this._baselineRefresh = task
return task
}
private async createBaseline(factory: CodeIndexServiceFactory): Promise<Baseline | undefined> {
if (!this.baselinePath) return
const cache = new CacheManager(this.cacheDirectory, this.baselinePath)
await cache.initialize()
const signature = cache.signature()
const stamp = await cache.stamp()
const hashes = new Map<string, string>()
for (const [filePath, hash] of Object.entries(cache.getAllHashes())) {
const rel = path.relative(this.baselinePath, filePath)
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue
hashes.set(rel.replaceAll("\\", "/"), hash)
}
const store = factory.createVectorStore(this.baselinePath)
try {
if (!store.openExisting) throw new Error("The configured vector store cannot open a shared baseline")
// Validate compatibility without keeping every worktree baseline connection open.
await store.openExisting()
await store.close?.()
return {
store,
signature,
stamp,
overlay: new WorktreeOverlay(this.workspacePath, this.baselinePath, hashes),
}
} catch (err) {
await store.close?.()
log.warn("shared indexing baseline is unavailable; using an independent worktree index", {
workspacePath: this.workspacePath,
baselinePath: this.baselinePath,
err,
})
return { signature, stamp }
}
}
private async _recreateServices(prepared?: Baseline): Promise<void> {
log.info("starting indexing service recreation", { workspacePath: this.workspacePath })
const factory = new CodeIndexServiceFactory(
this._configManager!,
this.workspacePath,
this._cacheManager!,
this.cacheDirectory,
(event) => this.handleTelemetry(event),
)
const ignoreInstance = await loadIgnore(this.workspacePath)
const config = this._configManager!.getConfig()
const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices(
this._cacheManager!,
ignoreInstance,
)
const baseline = prepared ?? (await this.createBaseline(factory))
const { embedder, vectorStore, scanner, fileWatcher } = factory.createServices(this._cacheManager!, ignoreInstance)
fileWatcher.setOverlay?.(baseline?.overlay)
log.info("created indexing services", {
workspacePath: this.workspacePath,
provider: embedder.embedderInfo.name,
@@ -469,13 +559,12 @@ export class CodeIndexManager {
})
const shouldValidate = embedder && embedder.embedderInfo.name === config.embedderProvider
if (shouldValidate) {
log.info("validating embedder configuration", {
workspacePath: this.workspacePath,
provider: embedder.embedderInfo.name,
})
const validationResult = await this._serviceFactory.validateEmbedder(embedder)
const validationResult = await factory.validateEmbedder(embedder)
if (!validationResult.valid) {
const errorMessage = validationResult.error || "Embedder configuration validation failed"
this._stateManager.setSystemState("Error", errorMessage)
@@ -487,7 +576,7 @@ export class CodeIndexManager {
})
}
this._orchestrator = new CodeIndexOrchestrator(
const orchestrator = new CodeIndexOrchestrator(
this._configManager!,
this._stateManager,
this.workspacePath,
@@ -496,10 +585,33 @@ export class CodeIndexManager {
scanner,
fileWatcher,
(event) => this.handleTelemetry(event),
baseline?.overlay,
Boolean(this.baselinePath && !baseline?.store),
)
const search = new CodeIndexSearchService(
this._configManager!,
this._stateManager,
embedder,
vectorStore,
baseline?.store && baseline.overlay ? { store: baseline.store, overlay: baseline.overlay } : undefined,
)
this._searchService = new CodeIndexSearchService(this._configManager!, this._stateManager, embedder, vectorStore)
await this._orchestrator?.shutdown?.()
await this._baselineStore?.close?.()
if (this._disposed) {
await orchestrator.shutdown()
await baseline?.store?.close?.()
return
}
this._serviceFactory = factory
this._orchestrator = orchestrator
this._searchService = search
this._baselineStore = baseline?.store
this._baselineSignature = baseline?.signature
this._baselineStamp = baseline?.stamp
this._baselineSigned = Date.now()
this._overlay = baseline?.overlay
this._stateManager.setSystemState("Standby", "")
log.info("indexing services are ready", { workspacePath: this.workspacePath })
}
@@ -17,6 +17,7 @@ import type { Disposable } from "./runtime"
import { Log } from "../util/log"
import { sanitizeErrorMessage } from "./shared/validation-helpers"
import { DEFAULT_VECTOR_STORE } from "./constants"
import type { WorktreeOverlay } from "./worktree-overlay"
const log = Log.create({ service: "indexing-orchestrator" })
@@ -24,6 +25,7 @@ export class CodeIndexOrchestrator {
private _fileWatcherSubscriptions: Disposable[] = []
private _isProcessing = false
private _cancelRequested = false
private _active?: Promise<void>
constructor(
private readonly configManager: CodeIndexConfigManager,
@@ -34,6 +36,8 @@ export class CodeIndexOrchestrator {
private readonly scanner: DirectoryScanner,
private readonly fileWatcher: IFileWatcher,
private readonly onTelemetry?: IndexingTelemetryReporter,
private readonly overlay?: WorktreeOverlay,
private readonly independent = false,
) {}
private getTelemetryMeta(): IndexingTelemetryMeta {
@@ -82,6 +86,9 @@ export class CodeIndexOrchestrator {
this.stateManager.setSystemState("Indexing", "Initializing file watcher...")
try {
this.fileWatcher.setCollecting(false)
for (const sub of this._fileWatcherSubscriptions) sub.dispose()
this._fileWatcherSubscriptions = []
await this.fileWatcher.initialize()
log.info("file watcher initialized", { workspacePath: this.workspacePath })
@@ -106,6 +113,7 @@ export class CodeIndexOrchestrator {
workspacePath: this.workspacePath,
totalInBatch,
})
if (this.stateManager.state === "Error") return
if (totalInBatch > 0) {
this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.")
} else if (this.stateManager.state === "Indexing") {
@@ -114,9 +122,11 @@ export class CodeIndexOrchestrator {
}
}),
this.fileWatcher.onDidFinishBatchProcessing.on((summary: BatchProcessingSummary) => {
if (summary.batchError) {
log.error("batch processing failed", { err: summary.batchError })
}
if (!summary.batchError) return
log.error("batch processing failed", { err: summary.batchError })
this.overlay?.prepare()
this.stateManager.setSystemState("Error", `Failed to process file changes: ${summary.batchError.message}`)
this.emitError("orchestrator:watcher", summary.batchError, "watcher")
}),
]
this.fileWatcher.setCollecting(false)
@@ -127,7 +137,16 @@ export class CodeIndexOrchestrator {
}
}
public async startIndexing(trigger: IndexingTelemetryTrigger = "background"): Promise<void> {
public startIndexing(trigger: IndexingTelemetryTrigger = "background"): Promise<void> {
if (this._active) return this._active
const task = this.runIndexing(trigger).finally(() => {
if (this._active === task) this._active = undefined
})
this._active = task
return task
}
private async runIndexing(trigger: IndexingTelemetryTrigger): Promise<void> {
log.info("indexing start requested", {
workspacePath: this.workspacePath,
state: this.stateManager.state,
@@ -166,6 +185,7 @@ export class CodeIndexOrchestrator {
let mode: IndexingTelemetryMode | undefined
try {
this.overlay?.prepare()
await this._startWatcher()
if (this._cancelRequested) {
@@ -183,12 +203,27 @@ export class CodeIndexOrchestrator {
return
}
if (collectionCreated) {
if (this.overlay) {
if (!collectionCreated) await this.vectorStore.clearCollection()
await this.cacheManager.clearCacheFile()
log.info("cleared indexing cache after new collection creation", { workspacePath: this.workspacePath })
this.cacheManager.seedHashes(this.overlay.seed())
await this.cacheManager.flush?.()
log.info("seeded worktree index from shared baseline", {
workspacePath: this.workspacePath,
baselinePath: this.overlay.baselinePath,
files: this.overlay.baseline.size,
})
}
const hasExistingData = await this.vectorStore.hasIndexedData()
const hasExistingData = this.overlay || this.independent ? false : await this.vectorStore.hasIndexedData()
if (!this.overlay && !hasExistingData) {
if (!collectionCreated) await this.vectorStore.clearCollection()
await this.cacheManager.clearCacheFile()
log.info("cleared indexing cache before full scan", {
workspacePath: this.workspacePath,
collectionCreated,
})
}
log.info("checked vector store indexed data", {
workspacePath: this.workspacePath,
hasExistingData,
@@ -292,6 +327,10 @@ export class CodeIndexOrchestrator {
return
}
if (this.overlay && batchErrors.length > 0) {
throw batchErrors[0]
}
if (mode === "full") {
// Validate full scan results
if (cumulativeFilesIndexed === 0 && cumulativeFilesFound > 0) {
@@ -312,8 +351,11 @@ export class CodeIndexOrchestrator {
}
}
this.fileWatcher.setCollecting(true)
this.overlay?.reconcile(this.cacheManager.getAllHashes())
await this.cacheManager.flush?.()
await this.vectorStore.markIndexingComplete()
await this.vectorStore.close?.()
this.fileWatcher.setCollecting(true)
this.stateManager.setSystemState("Indexed", "File watcher started. Index up-to-date.")
log.info("workspace scan finalized", {
workspacePath: this.workspacePath,
@@ -335,6 +377,19 @@ export class CodeIndexOrchestrator {
})
}
public async shutdown(): Promise<void> {
this._cancelRequested = true
this.scanner.cancel()
this.fileWatcher.setCollecting(false)
await this._active
for (const sub of this._fileWatcherSubscriptions) sub.dispose()
this._fileWatcherSubscriptions = []
if (this.fileWatcher.shutdown) await this.fileWatcher.shutdown()
else this.fileWatcher.dispose()
await this.vectorStore.close?.()
this._isProcessing = false
}
public stopWatcher(): void {
log.info("stopping file watcher", { workspacePath: this.workspacePath })
this.fileWatcher.dispose()
@@ -31,6 +31,7 @@ import {
} from "../shared/get-relative-path"
import { FileIgnore } from "../../file/ignore"
import { Log } from "../../util/log"
import type { WorktreeOverlay } from "../worktree-overlay"
import { sanitizeErrorMessage } from "../shared/validation-helpers"
const log = Log.create({ service: "file-watcher" })
@@ -50,9 +51,11 @@ export class FileWatcher implements IFileWatcher {
private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10
private batchSegmentThreshold: number
private maxBatchRetries: number
private collecting = true
private collecting = false
private draining = false
private drainTask?: Promise<void>
private ready?: Promise<void>
private overlay?: WorktreeOverlay
public readonly onDidStartBatchProcessing = new Emitter<string[]>()
public readonly onBatchProgressUpdate = new Emitter<{
@@ -150,8 +153,16 @@ export class FileWatcher implements IFileWatcher {
log.info("file watcher ready", { workspacePath: this.workspacePath })
}
setOverlay(overlay?: WorktreeOverlay): void {
this.overlay = overlay
}
setCollecting(collecting: boolean): void {
this.collecting = collecting
if (!collecting && this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
}
log.info("updated watcher collection mode", {
workspacePath: this.workspacePath,
collecting,
@@ -170,11 +181,20 @@ export class FileWatcher implements IFileWatcher {
/**
* Disposes the file watcher and cleans up resources.
*/
async shutdown(): Promise<void> {
this.collecting = false
if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
await this.watcher?.close()
await this.drainTask
this.dispose()
}
dispose(): void {
this.watcher?.close()
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
this.collecting = false
void this.watcher?.close()
if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
this.onDidStartBatchProcessing.dispose()
this.onBatchProgressUpdate.dispose()
this.onDidFinishBatchProcessing.dispose()
@@ -187,6 +207,7 @@ export class FileWatcher implements IFileWatcher {
*/
private handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void {
if (!this.shouldIndex(filePath)) return
this.overlay?.block(filePath)
this.accumulatedEvents.set(filePath, { path: filePath, type })
if (!this.collecting) return
this.scheduleBatchProcessing()
@@ -196,11 +217,22 @@ export class FileWatcher implements IFileWatcher {
* Schedules batch processing with debounce.
*/
private scheduleBatchProcessing(): void {
if (!this.collecting) return
if (!this.collecting || this.drainTask) return
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.BATCH_DEBOUNCE_DELAY_MS)
this.batchProcessDebounceTimer = setTimeout(() => {
this.batchProcessDebounceTimer = undefined
const task = this.triggerBatchProcessing().catch((err) => {
const error = err instanceof Error ? err : new Error(String(err))
this.collecting = false
this.onDidFinishBatchProcessing.fire({ processedFiles: [], batchError: error })
})
this.drainTask = task.finally(() => {
this.drainTask = undefined
if (this.collecting && this.accumulatedEvents.size > 0) this.scheduleBatchProcessing()
})
}, this.BATCH_DEBOUNCE_DELAY_MS)
}
/**
@@ -217,17 +249,20 @@ export class FileWatcher implements IFileWatcher {
pendingEvents: this.accumulatedEvents.size,
})
while (this.collecting && this.accumulatedEvents.size > 0) {
const eventsToProcess = new Map(this.accumulatedEvents)
this.accumulatedEvents.clear()
try {
while (this.collecting && this.accumulatedEvents.size > 0) {
const eventsToProcess = new Map(this.accumulatedEvents)
this.accumulatedEvents.clear()
const filePathsInBatch = Array.from(eventsToProcess.keys())
this.onDidStartBatchProcessing.fire(filePathsInBatch)
await this.processBatch(eventsToProcess)
const filePathsInBatch = Array.from(eventsToProcess.keys())
this.onDidStartBatchProcessing.fire(filePathsInBatch)
await this.processBatch(eventsToProcess)
}
} finally {
this.draining = false
if (this.collecting && this.accumulatedEvents.size > 0) this.scheduleBatchProcessing()
log.info("completed watcher event drain", { workspacePath: this.workspacePath })
}
this.draining = false
log.info("completed watcher event drain", { workspacePath: this.workspacePath })
}
private shouldIndex(filePath: string) {
@@ -414,45 +449,49 @@ export class FileWatcher implements IFileWatcher {
batchResults: FileProcessingResult[],
overallBatchError?: Error,
): Promise<Error | undefined> {
if (pointsForBatchUpsert.length > 0 && this.vectorStore && !overallBatchError) {
if (!overallBatchError) {
try {
for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) {
const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold)
let retryCount = 0
let upsertError: Error | undefined
if (pointsForBatchUpsert.length > 0 && this.vectorStore) {
for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) {
const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold)
let retryCount = 0
let upsertError: Error | undefined
while (retryCount < this.maxBatchRetries) {
try {
await this.vectorStore.upsertPoints(batch)
break
} catch (error) {
upsertError = error as Error
retryCount++
if (retryCount === this.maxBatchRetries) {
log.error("upsert retry exhausted", {
error: sanitizeErrorMessage(upsertError.message),
location: "upsertPoints",
errorType: "upsert_retry_exhausted",
retryCount: this.maxBatchRetries,
})
this.emitError("file-watcher:upsert_retry_exhausted", upsertError, this.maxBatchRetries)
throw new Error(`Failed to upsert batch after ${this.maxBatchRetries} retries: ${upsertError.message}`)
while (retryCount < this.maxBatchRetries) {
try {
await this.vectorStore.upsertPoints(batch)
break
} catch (error) {
upsertError = error as Error
retryCount++
if (retryCount === this.maxBatchRetries) {
log.error("upsert retry exhausted", {
error: sanitizeErrorMessage(upsertError.message),
location: "upsertPoints",
errorType: "upsert_retry_exhausted",
retryCount: this.maxBatchRetries,
})
this.emitError("file-watcher:upsert_retry_exhausted", upsertError, this.maxBatchRetries)
throw new Error(
`Failed to upsert batch after ${this.maxBatchRetries} retries: ${upsertError.message}`,
)
}
this.emitRetry(retryCount, batch.length, upsertError)
await new Promise((resolve) =>
setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1)),
)
}
this.emitRetry(retryCount, batch.length, upsertError)
await new Promise((resolve) => setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1)))
}
}
}
for (const { path, newHash } of successfullyProcessedForUpsert) {
if (newHash) {
this.cacheManager.updateHash(path, newHash)
}
batchResults.push({ path, status: "success" })
for (const item of successfullyProcessedForUpsert) {
if (item.newHash) this.cacheManager.updateHash(item.path, item.newHash)
batchResults.push({ path: item.path, status: "success", newHash: item.newHash })
}
} catch (error) {
const err = error as Error
overallBatchError = overallBatchError || err
overallBatchError = err
this.emitError("file-watcher:batch_upsert_error", err)
log.error("batch upsert error", {
error: sanitizeErrorMessage(err.message),
@@ -460,13 +499,13 @@ export class FileWatcher implements IFileWatcher {
errorType: "batch_upsert_error",
affectedFiles: successfullyProcessedForUpsert.length,
})
for (const { path } of successfullyProcessedForUpsert) {
batchResults.push({ path, status: "error", error: err })
for (const item of successfullyProcessedForUpsert) {
batchResults.push({ path: item.path, status: "error", error: err })
}
}
} else if (overallBatchError && pointsForBatchUpsert.length > 0) {
for (const { path } of successfullyProcessedForUpsert) {
batchResults.push({ path, status: "error", error: overallBatchError })
} else {
for (const item of successfullyProcessedForUpsert) {
batchResults.push({ path: item.path, status: "error", error: overallBatchError })
}
}
@@ -497,16 +536,33 @@ export class FileWatcher implements IFileWatcher {
// Categorize events
const pathsToExplicitlyDelete: string[] = []
const filesToUpsertDetails: Array<{ path: string; originalType: "create" | "change" }> = []
const reverts = new Map<string, string>()
for (const event of eventsToProcess.values()) {
if (event.type === "delete") {
pathsToExplicitlyDelete.push(event.path)
} else {
filesToUpsertDetails.push({
path: event.path,
originalType: event.type,
})
continue
}
const cached = this.cacheManager.getHash(event.path)
const hash = await readFile(event.path, "utf-8")
.then((content) => createHash("sha256").update(content).digest("hex"))
.catch(() => undefined)
if (cached && hash === cached) {
batchResults.push({ path: event.path, status: "success", newHash: cached })
processedCountInBatch++
continue
}
if (hash && hash === this.overlay?.baselineHash(event.path)) {
pathsToExplicitlyDelete.push(event.path)
reverts.set(event.path, hash)
continue
}
filesToUpsertDetails.push({
path: event.path,
originalType: event.type,
})
}
log.info("processing file watcher batch", {
@@ -526,6 +582,9 @@ export class FileWatcher implements IFileWatcher {
)
overallBatchError = deletionError
processedCountInBatch = deletionCount
if (!deletionError) {
for (const [filePath, hash] of reverts) this.cacheManager.updateHash(filePath, hash)
}
// Phase 2: Process files and prepare upserts
const {
@@ -549,6 +608,16 @@ export class FileWatcher implements IFileWatcher {
overallBatchError,
)
const resultError = batchResults.find((item) => item.status === "error" || item.status === "local_error")?.error
overallBatchError ??= resultError
await this.cacheManager.flush()
for (const event of eventsToProcess.values()) {
const result = batchResults.findLast((item) => item.path === event.path)
if (result?.status !== "success") continue
this.overlay?.settle(event.path, this.cacheManager.getHash(event.path), this.accumulatedEvents.has(event.path))
}
// Finalize
this.onDidFinishBatchProcessing.fire({
processedFiles: batchResults,
@@ -668,6 +737,7 @@ export class FileWatcher implements IFileWatcher {
vector,
payload: {
filePath: generateRelativeFilePath(normalizedAbsolutePath, this.workspacePath),
fileHash: block.fileHash,
codeChunk: block.content,
startLine: block.start_line,
endLine: block.end_line,
@@ -295,6 +295,10 @@ export class DirectoryScanner implements IDirectoryScanner {
onFileParsed?.()
processedCount++
if (!isNewFile && this.vectorStore) {
await this.vectorStore.deletePointsByMultipleFilePaths([filePath])
}
// Process embeddings if configured
if (this.embedder && this.vectorStore && blocks.length > 0) {
// Add to batch accumulators
@@ -303,7 +307,7 @@ export class DirectoryScanner implements IDirectoryScanner {
const info = {
filePath,
fileHash: currentFileHash,
isNew: isNewFile,
isNew: true,
}
for (const block of blocks) {
if (this._cancelled) break
@@ -608,6 +612,7 @@ export class DirectoryScanner implements IDirectoryScanner {
vector,
payload: {
filePath: generateRelativeFilePath(normalizedAbsolutePath, scanWorkspace),
fileHash: block.fileHash,
codeChunk: block.content,
startLine: block.start_line,
endLine: block.end_line,
@@ -5,6 +5,12 @@ import type { IVectorStore } from "./interfaces/vector-store"
import type { CodeIndexConfigManager } from "./config-manager"
import type { CodeIndexStateManager } from "./state-manager"
import { Log } from "../util/log"
import type { WorktreeOverlay } from "./worktree-overlay"
interface BaselineSearch {
store: IVectorStore
overlay: WorktreeOverlay
}
const log = Log.create({ service: "indexing-search" })
@@ -14,6 +20,7 @@ export class CodeIndexSearchService {
private readonly stateManager: CodeIndexStateManager,
private readonly embedder: IEmbedder,
private readonly vectorStore: IVectorStore,
private readonly baseline?: BaselineSearch,
) {}
public async searchIndex(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {
@@ -37,7 +44,44 @@ export class CodeIndexSearchService {
}
const normalizedPrefix = directoryPrefix ? path.normalize(directoryPrefix) : undefined
return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults)
if (!this.baseline) return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults)
if (!this.baseline.overlay.ready) throw new Error("Worktree index reconciliation is not complete.")
const ceiling = Math.max(maxResults, Math.min(maxResults * 16, 1000))
const delta = (async () => {
const search = async (limit: number): Promise<VectorStoreSearchResult[]> => {
const results = await this.vectorStore.search(vector, normalizedPrefix, minScore, limit)
const filtered = results.filter((result) => this.baseline!.overlay.deltaResult(result))
if (filtered.length >= maxResults || results.length < limit || limit >= ceiling) return filtered
return search(Math.min(limit * 2, ceiling))
}
return search(maxResults)
})()
const base = (async () => {
const checks = new Map<string, Promise<boolean>>()
const search = async (limit: number): Promise<VectorStoreSearchResult[]> => {
const results = await this.baseline!.store.search(vector, normalizedPrefix, minScore, limit)
const accepted = await Promise.all(
results.map((result) => this.baseline!.overlay.baselineResult(result, checks)),
)
const filtered = results.filter((_, index) => accepted[index])
if (filtered.length >= maxResults || results.length < limit || limit >= ceiling) return filtered
return search(Math.min(limit * 2, ceiling))
}
return search(maxResults)
})()
const [baseline, current] = await Promise.all([base, delta])
const merged = new Map<string, VectorStoreSearchResult>()
const key = (result: VectorStoreSearchResult) =>
[result.payload?.filePath, result.payload?.startLine, result.payload?.endLine, result.payload?.codeChunk].join(
"\0",
)
for (const result of baseline) merged.set(key(result), result)
for (const result of current) {
if (this.baseline.overlay.deltaResult(result)) merged.set(key(result), result)
}
return [...merged.values()].sort((left, right) => right.score - left.score).slice(0, maxResults)
} catch (err) {
log.error("search failed", { err })
throw err
@@ -169,7 +169,7 @@ export class CodeIndexServiceFactory {
}
}
public createVectorStore(): IVectorStore {
public createVectorStore(workspacePath = this.workspacePath): IVectorStore {
const config = this.configManager.getConfig()
const profile = resolveEmbeddingProfile(config.embedderProvider, config.modelId, config.modelDimension)
@@ -191,7 +191,7 @@ export class CodeIndexServiceFactory {
vectorSize: profile.dimension,
dbDir,
})
return new LanceDBVectorStore(this.workspacePath, profile.dimension, dbDir, profile)
return new LanceDBVectorStore(workspacePath, profile.dimension, dbDir, profile)
}
if (!config.qdrantUrl) throw new Error("Qdrant URL is required.")
@@ -201,7 +201,7 @@ export class CodeIndexServiceFactory {
model: profile.modelId,
vectorSize: profile.dimension,
})
return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, profile.dimension, config.qdrantApiKey, profile)
return new QdrantVectorStore(workspacePath, config.qdrantUrl, profile.dimension, config.qdrantApiKey, profile)
}
public createDirectoryScanner(
@@ -10,8 +10,20 @@ import type { EmbeddingProfile } from "../embedding-profile"
import { loadLanceDB } from "./lancedb-loader"
const log = Log.create({ service: "lancedb-store" })
let nativeQueue = Promise.resolve()
function native<T>(run: () => Promise<T>): Promise<T> {
const task = nativeQueue.then(run)
nativeQueue = task.then(
() => undefined,
() => undefined,
)
return task
}
const SCHEMA = "2"
const KEY = {
schema: "index_schema",
size: "vector_size",
complete: "indexing_complete",
provider: "embedding_provider",
@@ -74,19 +86,16 @@ export class LanceDBVectorStore implements IVectorStore {
* @returns The LanceDB connection.
*/
private async getDb(): Promise<Connection> {
if (this.db) {
return this.db
}
if (this.db) return this.db
const lancedb = await this.loadLanceDBModule()
return native(async () => {
if (this.db) return this.db
const lancedb = await this.loadLanceDBModule()
// Create parent directory if needed
if (!fs.existsSync(this.dbPath)) {
fs.mkdirSync(this.dbPath, { recursive: true })
}
this.db = await lancedb.connect(this.dbPath)
return this.db as Connection
if (!fs.existsSync(this.dbPath)) fs.mkdirSync(this.dbPath, { recursive: true })
this.db = await lancedb.connect(this.dbPath)
return this.db as Connection
})
}
/**
@@ -102,7 +111,7 @@ export class LanceDBVectorStore implements IVectorStore {
try {
// Try to open existing table
const table = await db.openTable(this.vectorTableName)
const table = await native(() => db.openTable(this.vectorTableName))
this.table = table
return table
} catch (error) {
@@ -121,6 +130,7 @@ export class LanceDBVectorStore implements IVectorStore {
id: "sample",
vector: new Array(this.vectorSize).fill(0),
filePath: "sample",
fileHash: "sample",
codeChunk: "sample",
startLine: 0,
endLine: 0,
@@ -134,6 +144,10 @@ export class LanceDBVectorStore implements IVectorStore {
*/
private _createMetadataData() {
return [
{
key: KEY.schema,
value: SCHEMA,
},
{
key: KEY.size,
value: String(this.vectorSize),
@@ -162,7 +176,7 @@ export class LanceDBVectorStore implements IVectorStore {
* @param db The LanceDB connection.
*/
private async _createVectorTable(db: Connection): Promise<void> {
this.table = await db.createTable(this.vectorTableName, this._createSampleData())
this.table = await native(() => db.createTable(this.vectorTableName, this._createSampleData()))
if (this.table) {
await this.table.delete("id = 'sample'")
}
@@ -173,7 +187,7 @@ export class LanceDBVectorStore implements IVectorStore {
* @param db The LanceDB connection.
*/
private async _createMetadataTable(db: Connection): Promise<void> {
await db.createTable(this.metadataTableName, this._createMetadataData())
await native(() => db.createTable(this.metadataTableName, this._createMetadataData()))
}
/**
@@ -194,15 +208,10 @@ export class LanceDBVectorStore implements IVectorStore {
* @returns The stored vector size, or null if not found.
*/
private async _getStoredVectorSize(db: Connection): Promise<number | null> {
try {
const value = await this._getMetadataValue(db, KEY.size)
if (value === undefined) return null
const dim = this._parseNumber(value)
return dim ?? null
} catch (error) {
log.warn("Failed to read metadata table", { error })
return null
}
const value = await this._getMetadataValue(db, KEY.size)
if (value === undefined) return null
const dim = this._parseNumber(value)
return dim ?? null
}
private isValidMetadataKey(key: string): boolean {
@@ -219,27 +228,22 @@ export class LanceDBVectorStore implements IVectorStore {
if (!this.isValidMetadataKey(key)) {
throw new Error(`Invalid metadata key: ${key}`)
}
const metadataTable = await db.openTable(this.metadataTableName)
const metadataTable = await native(() => db.openTable(this.metadataTableName))
const rows = await metadataTable.query().where(`key = '${key}'`).toArray()
return rows.length > 0 ? rows[0].value : undefined
}
private async _getStoredEmbeddingProfile(db: Connection): Promise<EmbeddingProfile | undefined> {
try {
const provider = await this._getMetadataValue(db, KEY.provider)
const modelId = await this._getMetadataValue(db, KEY.model)
const dimension = await this._getMetadataValue(db, KEY.dimension)
if (typeof provider !== "string" || typeof modelId !== "string") return undefined
const dim = this._parseNumber(dimension)
if (!dim) return undefined
return {
provider: provider as EmbeddingProfile["provider"],
modelId,
dimension: dim,
}
} catch (error) {
log.warn("Failed to read embedding profile metadata", { error })
return undefined
const provider = await this._getMetadataValue(db, KEY.provider)
const modelId = await this._getMetadataValue(db, KEY.model)
const dimension = await this._getMetadataValue(db, KEY.dimension)
if (typeof provider !== "string" || typeof modelId !== "string") return undefined
const dim = this._parseNumber(dimension)
if (!dim) return undefined
return {
provider: provider as EmbeddingProfile["provider"],
modelId,
dimension: dim,
}
}
@@ -251,6 +255,27 @@ export class LanceDBVectorStore implements IVectorStore {
)
}
async openExisting(): Promise<void> {
if (!fs.existsSync(this.dbPath)) throw new Error("Baseline LanceDB store does not exist")
const db = await this.getDb()
const tables = await db.tableNames()
if (!tables.includes(this.vectorTableName) || !tables.includes(this.metadataTableName)) {
throw new Error("Baseline LanceDB store is incomplete")
}
const profile = await this._getStoredEmbeddingProfile(db)
if (!profile || !this._isEmbeddingProfileMatch(profile)) {
throw new Error("Baseline LanceDB embedding profile does not match the worktree")
}
const schema = await this._getMetadataValue(db, KEY.schema)
if (String(schema) !== SCHEMA) throw new Error("Baseline LanceDB index schema does not match the worktree")
const complete = await this._getMetadataValue(db, KEY.complete)
if (String(complete) !== "true") throw new Error("Baseline LanceDB index is not complete")
this.table = await native(() => db.openTable(this.vectorTableName))
}
async initialize(): Promise<boolean> {
try {
await this.closeConnect()
@@ -274,12 +299,13 @@ export class LanceDBVectorStore implements IVectorStore {
return true
}
this.table = await db.openTable(this.vectorTableName)
this.table = await native(() => db.openTable(this.vectorTableName))
const storedVectorSize = metadataTableExists ? await this._getStoredVectorSize(db) : null
const storedSchema = metadataTableExists ? await this._getMetadataValue(db, KEY.schema) : undefined
const pointCount = await this.table.countRows()
if (storedVectorSize === null || storedVectorSize !== this.vectorSize) {
if (String(storedSchema) !== SCHEMA || storedVectorSize === null || storedVectorSize !== this.vectorSize) {
needsRecreation = true
}
@@ -344,6 +370,7 @@ export class LanceDBVectorStore implements IVectorStore {
id: point.id,
vector: point.vector,
filePath: point.payload.filePath,
fileHash: point.payload.fileHash,
codeChunk: point.payload.codeChunk,
startLine: point.payload.startLine,
endLine: point.payload.endLine,
@@ -392,7 +419,7 @@ export class LanceDBVectorStore implements IVectorStore {
if (!payload) {
return false
}
const validKeys = ["filePath", "codeChunk", "startLine", "endLine"]
const validKeys = ["filePath", "fileHash", "codeChunk", "startLine", "endLine"]
const hasValidKeys = validKeys.every((key) => key in payload)
return hasValidKeys
}
@@ -431,6 +458,7 @@ export class LanceDBVectorStore implements IVectorStore {
score: 1 - result._distance, // Convert distance to similarity score
payload: {
filePath: result.filePath,
fileHash: result.fileHash,
codeChunk: result.codeChunk,
startLine: result.startLine,
endLine: result.endLine,
@@ -501,7 +529,7 @@ export class LanceDBVectorStore implements IVectorStore {
const tableNames = await db.tableNames()
if (tableNames.includes(this.metadataTableName)) {
const metadataTable = await db.openTable(this.metadataTableName)
const metadataTable = await native(() => db.openTable(this.metadataTableName))
await metadataTable.delete("true")
}
} catch (metadataError) {
@@ -526,6 +554,10 @@ export class LanceDBVectorStore implements IVectorStore {
}
}
async close(): Promise<void> {
await this.closeConnect()
}
private async closeConnect(): Promise<void> {
if (this.table) {
this.table = null
@@ -570,7 +602,7 @@ export class LanceDBVectorStore implements IVectorStore {
})
return false
}
const metadataTable = await db.openTable(this.metadataTableName)
const metadataTable = await native(() => db.openTable(this.metadataTableName))
const metadataResults = await metadataTable.query().where(`key = '${KEY.complete}'`).toArray()
const indexed = metadataResults.length > 0 ? String(metadataResults[0].value) === "true" : false
log.info("LanceDB indexing metadata evaluated", {
@@ -580,8 +612,8 @@ export class LanceDBVectorStore implements IVectorStore {
})
return indexed
} catch (error) {
log.warn("Failed to check if collection has data", { error })
return false
log.error("Failed to check if collection has data", { error })
throw error
}
}
@@ -596,6 +628,7 @@ export class LanceDBVectorStore implements IVectorStore {
}
private async _persistEmbeddingProfile(metadataTable: Table): Promise<void> {
await this._upsertMetadata(metadataTable, KEY.schema, SCHEMA)
await this._upsertMetadata(metadataTable, KEY.provider, this.profile.provider)
await this._upsertMetadata(metadataTable, KEY.model, this.profile.modelId)
await this._upsertMetadata(metadataTable, KEY.dimension, this.profile.dimension)
@@ -609,7 +642,7 @@ export class LanceDBVectorStore implements IVectorStore {
async markIndexingComplete(): Promise<void> {
try {
const db = await this.getDb()
const metadataTable = await db.openTable(this.metadataTableName)
const metadataTable = await native(() => db.openTable(this.metadataTableName))
await this._persistEmbeddingProfile(metadataTable)
await this._upsertMetadata(metadataTable, KEY.complete, "true")
log.info("Marked indexing as complete")
@@ -626,7 +659,7 @@ export class LanceDBVectorStore implements IVectorStore {
async markIndexingIncomplete(): Promise<void> {
try {
const db = await this.getDb()
const metadataTable = await db.openTable(this.metadataTableName)
const metadataTable = await native(() => db.openTable(this.metadataTableName))
await this._persistEmbeddingProfile(metadataTable)
await this._upsertMetadata(metadataTable, KEY.complete, "false")
log.info("Marked indexing as incomplete (in progress)")
@@ -9,7 +9,9 @@ import type { EmbeddingProfile } from "../embedding-profile"
const log = Log.create({ service: "qdrant-store" })
const SCHEMA = 2
const KEY = {
schema: "index_schema",
complete: "indexing_complete",
provider: "embedding_provider",
model: "embedding_model_id",
@@ -221,12 +223,12 @@ export class QdrantVectorStore implements IVectorStore {
})
}
private async recreateCollectionForProfile(stored?: EmbeddingProfile): Promise<boolean> {
private async recreateCollectionForCompatibility(stored?: EmbeddingProfile): Promise<boolean> {
const from = stored
? `${stored.provider}:${stored.modelId}:${stored.dimension}`
: "missing embedding metadata on populated collection"
const to = `${this.profile.provider}:${this.profile.modelId}:${this.profile.dimension}`
log.warn(`Collection ${this.collectionName} embedding profile changed (${from} -> ${to}). Recreating collection.`)
log.warn(`Collection ${this.collectionName} is incompatible (${from} -> ${to}). Recreating collection.`)
await this.client.deleteCollection(this.collectionName)
await new Promise((resolve) => setTimeout(resolve, 100))
@@ -240,6 +242,28 @@ export class QdrantVectorStore implements IVectorStore {
return true
}
async openExisting(): Promise<void> {
const info = await this.getCollectionInfo()
if (!info) throw new Error("Baseline Qdrant collection does not exist")
const vectors = info.config?.params?.vectors
const size =
typeof vectors === "number"
? vectors
: vectors && typeof vectors === "object" && "size" in vectors && typeof vectors.size === "number"
? vectors.size
: 0
if (size !== this.vectorSize) throw new Error("Baseline Qdrant vector dimension does not match the worktree")
const payload = await this.getMetadataPayload()
const profile = this.getStoredProfile(payload)
if (!profile || !this.isProfileMatch(profile)) {
throw new Error("Baseline Qdrant embedding profile does not match the worktree")
}
if (payload?.[KEY.schema] !== SCHEMA) throw new Error("Baseline Qdrant index schema does not match the worktree")
if (payload?.[KEY.complete] !== true) throw new Error("Baseline Qdrant index is not complete")
}
/**
* Initializes the vector store
* @returns Promise resolving to boolean indicating if a new collection was created
@@ -278,8 +302,8 @@ export class QdrantVectorStore implements IVectorStore {
} else {
const payload = await this.getMetadataPayload()
const profile = this.getStoredProfile(payload)
created =
!profile || !this.isProfileMatch(profile) ? await this.recreateCollectionForProfile(profile) : false
const compatible = payload?.[KEY.schema] === SCHEMA && profile && this.isProfileMatch(profile)
created = compatible ? false : await this.recreateCollectionForCompatibility(profile)
}
} else {
// Exists but wrong vector size, recreate with enhanced error handling
@@ -453,7 +477,7 @@ export class QdrantVectorStore implements IVectorStore {
if (!payload) {
return false
}
const validKeys = ["filePath", "codeChunk", "startLine", "endLine"]
const validKeys = ["filePath", "fileHash", "codeChunk", "startLine", "endLine"]
const hasValidKeys = validKeys.every((key) => key in payload)
return hasValidKeys
}
@@ -523,7 +547,7 @@ export class QdrantVectorStore implements IVectorStore {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
}
@@ -655,14 +679,7 @@ export class QdrantVectorStore implements IVectorStore {
*/
async hasIndexedData(): Promise<boolean> {
try {
const collectionInfo = await this.getCollectionInfo()
if (!collectionInfo) {
log.info("Qdrant collection has no indexed data", {
collection: this.collectionName,
reason: "collection_missing",
})
return false
}
const collectionInfo = await this.client.getCollection(this.collectionName)
// Check if the collection has any points indexed
const pointsCount = collectionInfo.points_count ?? 0
if (pointsCount === 0) {
@@ -692,8 +709,8 @@ export class QdrantVectorStore implements IVectorStore {
log.info("No indexing metadata marker found. Using backward compatibility mode (checking points_count > 0).")
return pointsCount > 0
} catch (error) {
log.warn("Failed to check if collection has data", { error })
return false
log.error("Failed to check if collection has data", { error })
throw error
}
}
@@ -710,6 +727,7 @@ export class QdrantVectorStore implements IVectorStore {
vector: new Array(this.vectorSize).fill(0),
payload: {
type: "metadata",
[KEY.schema]: SCHEMA,
[KEY.complete]: true,
[KEY.provider]: this.profile.provider,
[KEY.model]: this.profile.modelId,
@@ -740,6 +758,7 @@ export class QdrantVectorStore implements IVectorStore {
vector: new Array(this.vectorSize).fill(0),
payload: {
type: "metadata",
[KEY.schema]: SCHEMA,
[KEY.complete]: false,
[KEY.provider]: this.profile.provider,
[KEY.model]: this.profile.modelId,
@@ -0,0 +1,92 @@
import { createHash } from "crypto"
import { readFile } from "fs/promises"
import path from "path"
import type { VectorStoreSearchResult } from "./interfaces/vector-store"
const normalize = (value: string) => value.replaceAll("\\", "/")
export class WorktreeOverlay {
readonly shadows = new Set<string>()
readonly blocked = new Set<string>()
ready = false
constructor(
readonly workspacePath: string,
readonly baselinePath: string,
readonly baseline: ReadonlyMap<string, string>,
) {}
relative(filePath: string): string | undefined {
const rel = path.isAbsolute(filePath) ? path.relative(this.workspacePath, filePath) : filePath
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return
return normalize(path.normalize(rel))
}
baselineHash(filePath: string): string | undefined {
const rel = this.relative(filePath)
if (!rel) return
return this.baseline.get(rel)
}
seed(): Record<string, string> {
return Object.fromEntries(
[...this.baseline].map(([filePath, hash]) => [path.join(this.workspacePath, ...filePath.split("/")), hash]),
)
}
prepare(): void {
this.ready = false
this.shadows.clear()
this.blocked.clear()
}
block(filePath: string): void {
const rel = this.relative(filePath)
if (rel) this.blocked.add(rel)
}
settle(filePath: string, hash: string | undefined, pending = false): void {
const rel = this.relative(filePath)
if (!rel) return
const baseline = this.baseline.get(rel)
if (baseline !== undefined && baseline !== hash) this.shadows.add(rel)
if (baseline === undefined || baseline === hash) this.shadows.delete(rel)
if (!pending) this.blocked.delete(rel)
}
reconcile(current: Readonly<Record<string, string>>): void {
this.shadows.clear()
for (const [filePath, hash] of this.baseline) {
const absolute = path.join(this.workspacePath, ...filePath.split("/"))
if (current[absolute] !== hash) this.shadows.add(filePath)
}
this.ready = true
}
async baselineResult(result: VectorStoreSearchResult, checks: Map<string, Promise<boolean>>): Promise<boolean> {
const filePath = result.payload?.filePath
if (typeof filePath !== "string") return false
const rel = this.relative(filePath)
if (!rel || this.shadows.has(rel) || this.blocked.has(rel)) return false
const expected = this.baseline.get(rel)
if (!expected || result.payload?.fileHash !== expected) return false
const existing = checks.get(rel)
const valid =
existing ??
readFile(path.join(this.workspacePath, ...rel.split("/")), "utf-8")
.then((content) => createHash("sha256").update(content).digest("hex") === expected)
.catch(() => false)
checks.set(rel, valid)
return valid
}
deltaResult(result: VectorStoreSearchResult): boolean {
const filePath = result.payload?.filePath
if (typeof filePath !== "string") return false
const rel = this.relative(filePath)
if (!rel) return false
return !this.blocked.has(rel)
}
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { CacheManager } from "../../../src/indexing/cache-manager"
describe("CacheManager", () => {
test("flushes a stable signature used to detect baseline changes", async () => {
const cacheDir = await mkdtemp(path.join(tmpdir(), "index-cache-"))
const workspace = path.join(cacheDir, "workspace")
const first = new CacheManager(cacheDir, workspace)
await first.initialize()
first.seedHashes({
[path.join(workspace, "b.ts")]: "b",
[path.join(workspace, "a.ts")]: "a",
})
await first.flush()
const second = new CacheManager(cacheDir, workspace)
await second.initialize()
expect(second.signature()).toBe(first.signature())
second.updateHash(path.join(workspace, "a.ts"), "changed")
expect(second.signature()).not.toBe(first.signature())
})
})
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CacheManager } from "../../../src/indexing/cache-manager"
import { CodeIndexManager } from "../../../src/indexing/manager"
import type { IndexingConfigInput } from "../../../src/indexing/config-manager"
import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry"
@@ -76,6 +77,59 @@ function createStartError(location = "orchestrator:startIndexing"): IndexingTele
}
describe("CodeIndexManager", () => {
test("falls back when the shared baseline is not ready", async () => {
const mgr = new CodeIndexManager("/tmp/worktree", "/tmp/cache", "/tmp/main")
let closed = 0
const data = mgr as unknown as {
createBaseline(factory: { createVectorStore(): unknown }): Promise<{ store?: unknown }>
}
const baseline = await data.createBaseline({
createVectorStore() {
return {
async openExisting() {
throw new Error("baseline rebuilding")
},
async close() {
closed += 1
},
}
},
})
expect(baseline.store).toBeUndefined()
expect(closed).toBe(1)
})
test("throttles unchanged baseline cache checks between searches", async () => {
const mgr = new CodeIndexManager("/tmp/worktree", "/tmp/cache", "/tmp/main")
const data = createData(mgr) as Data & {
_baselineStamp: string
_baselineSigned: number
_orchestrator: { state: string }
_searchService: { searchIndex(): Promise<[]> }
}
data._baselineStamp = "same"
data._baselineSigned = Date.now()
data._orchestrator = { state: "Indexed" }
data._searchService = {
async searchIndex() {
return []
},
}
const stamp = spyOn(CacheManager.prototype, "stamp").mockResolvedValue("same")
const initialize = spyOn(CacheManager.prototype, "initialize").mockResolvedValue()
try {
await mgr.searchIndex("first")
await mgr.searchIndex("second")
expect(stamp).toHaveBeenCalledTimes(1)
expect(initialize).not.toHaveBeenCalled()
} finally {
stamp.mockRestore()
initialize.mockRestore()
}
})
test("returns standby state before services are initialized", () => {
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
const data = mgr as unknown as {
@@ -315,6 +369,30 @@ describe("CodeIndexManager", () => {
expect(calls).toBe(1)
})
test("schedules auto-recovery for watcher failures", async () => {
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
const data = createData(mgr)
let calls = 0
data._recreateServices = async () => {
data._orchestrator = {
state: "Standby",
stopWatcher() {},
async startIndexing() {
calls += 1
this.state = "Indexed"
data._stateManager.setSystemState("Indexed", "done")
},
}
data._searchService = {}
}
data.handleTelemetry(createStartError("orchestrator:watcher"))
await data._retryTask
expect(calls).toBe(1)
})
test("ignores non-orchestrator telemetry errors for auto-recovery", async () => {
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
const data = createData(mgr)
@@ -393,30 +471,24 @@ describe("CodeIndexManager", () => {
expect(mgr.getCurrentStatus().systemStatus).toBe("Indexed")
})
test("dispose calls cancelIndexing on orchestrator", () => {
test("dispose waits for orchestrator shutdown", async () => {
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
let cancel = 0
let stop = 0
let shutdown = 0
const data = mgr as unknown as {
_orchestrator?: {
stopWatcher(): void
cancelIndexing(): void
shutdown(): Promise<void>
}
}
data._orchestrator = {
stopWatcher() {
stop += 1
},
cancelIndexing() {
cancel += 1
async shutdown() {
shutdown += 1
},
}
mgr.dispose()
await mgr.dispose()
expect(cancel).toBe(1)
expect(stop).toBe(0)
expect(shutdown).toBe(1)
})
test("dispose during service recreation cancels the recreated orchestrator", async () => {
@@ -456,7 +528,7 @@ describe("CodeIndexManager", () => {
const init = mgr.initialize(createInput({ openAiKey: "sk-test" }))
await new Promise((resolve) => setTimeout(resolve, 0))
mgr.dispose()
await mgr.dispose()
gate.resolve()
await init
@@ -484,7 +556,7 @@ describe("CodeIndexManager", () => {
const task = data.handleTelemetry(createStartError())
await new Promise((resolve) => setTimeout(resolve, 0))
mgr.dispose()
await mgr.dispose()
gate.resolve()
await data._retryTask
@@ -17,6 +17,7 @@ import { Emitter } from "../../../src/indexing/runtime"
class Store {
public clearCount = 0
public closeCount = 0
public deleteCount = 0
constructor(
@@ -47,6 +48,9 @@ class Store {
async deleteCollection(): Promise<void> {
this.deleteCount += 1
}
async close(): Promise<void> {
this.closeCount += 1
}
async collectionExists(): Promise<boolean> {
return true
}
@@ -117,6 +121,27 @@ class Watcher {
}
}
class BlockingScanner {
public isCancelled = false
public finished = false
private readonly gate = Promise.withResolvers<void>()
readonly started = Promise.withResolvers<void>()
async scanDirectory(): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> {
this.started.resolve()
await this.gate.promise
this.finished = true
return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 }
}
cancel(): void {
this.isCancelled = true
this.gate.resolve()
}
updateBatchSegmentThreshold(_newThreshold: number): void {}
}
class FailScanner {
public readonly isCancelled = false
@@ -225,6 +250,111 @@ describe("CodeIndexOrchestrator telemetry", () => {
expect(orchestrator.state).not.toBe("Indexing")
})
test("shutdown waits for an active scan before closing the store", async () => {
const scanner = new BlockingScanner()
const store = new Store(false)
const orchestrator = new CodeIndexOrchestrator(
createConfig(),
new CodeIndexStateManager(),
"/tmp/ws",
{ async clearCacheFile() {}, async flush() {} } as unknown as CacheManager,
store as unknown as IVectorStore,
scanner as unknown as DirectoryScanner,
new Watcher() as unknown as IFileWatcher,
)
const active = orchestrator.startIndexing("background")
await scanner.started.promise
await orchestrator.shutdown()
await active
expect(scanner.finished).toBe(true)
expect(store.closeCount).toBe(1)
})
test("clears stale vectors and hashes before rebuilding an incomplete store", async () => {
const cache = {
clears: 0,
async clearCacheFile() {
this.clears += 1
},
async flush() {},
}
const store = new Store(false, false)
const orchestrator = new CodeIndexOrchestrator(
createConfig(),
new CodeIndexStateManager(),
"/tmp/ws",
cache as unknown as CacheManager,
store as unknown as IVectorStore,
new Scanner(1, 1, 1) as unknown as DirectoryScanner,
new Watcher() as unknown as IFileWatcher,
)
await orchestrator.startIndexing("background")
expect(store.clearCount).toBe(1)
expect(cache.clears).toBe(1)
expect(orchestrator.state).toBe("Indexed")
})
test("does not clear data when index completeness cannot be read", async () => {
const cache = {
clears: 0,
async clearCacheFile() {
this.clears += 1
},
}
const store = new Store(true, false)
store.hasIndexedData = async () => {
throw new Error("metadata unavailable")
}
const orchestrator = new CodeIndexOrchestrator(
createConfig(),
new CodeIndexStateManager(),
"/tmp/ws",
cache as unknown as CacheManager,
store as unknown as IVectorStore,
new Scanner(1, 1, 1) as unknown as DirectoryScanner,
new Watcher() as unknown as IFileWatcher,
)
await orchestrator.startIndexing("background")
expect(store.clearCount).toBe(0)
expect(cache.clears).toBe(0)
expect(orchestrator.state).toBe("Error")
})
test("rebuilds a complete independent index when the shared baseline is unavailable", async () => {
const cache = {
clears: 0,
async clearCacheFile() {
this.clears += 1
},
async flush() {},
}
const store = new Store(true, false)
const orchestrator = new CodeIndexOrchestrator(
createConfig(),
new CodeIndexStateManager(),
"/tmp/ws",
cache as unknown as CacheManager,
store as unknown as IVectorStore,
new Scanner(1, 1, 1) as unknown as DirectoryScanner,
new Watcher() as unknown as IFileWatcher,
undefined,
undefined,
true,
)
await orchestrator.startIndexing("background")
expect(store.clearCount).toBe(1)
expect(cache.clears).toBe(1)
expect(orchestrator.state).toBe("Indexed")
})
test("preserves cache and collection data on retryable start failures", async () => {
const events: IndexingTelemetryEvent[] = []
const cache = {
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { mkdtemp, mkdir, writeFile } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { createHash } from "crypto"
import { v5 as uuidv5 } from "uuid"
import { CacheManager } from "../../../../src/indexing/cache-manager"
import { QDRANT_CODE_BLOCK_NAMESPACE } from "../../../../src/indexing/constants"
@@ -14,6 +15,7 @@ import type {
} from "../../../../src/indexing/interfaces"
import { FileWatcher } from "../../../../src/indexing/processors/file-watcher"
import { loadIgnore } from "../../../../src/indexing/shared/load-ignore"
import { WorktreeOverlay } from "../../../../src/indexing/worktree-overlay"
function createEmbedder(): IEmbedder {
return {
@@ -32,6 +34,8 @@ function createEmbedder(): IEmbedder {
}
class RetryStore implements IVectorStore {
public readonly points: PointStruct[] = []
constructor(private readonly fail: number) {}
private calls = 0
@@ -40,11 +44,12 @@ class RetryStore implements IVectorStore {
return false
}
async upsertPoints(_points: PointStruct[]): Promise<void> {
async upsertPoints(points: PointStruct[]): Promise<void> {
this.calls += 1
if (this.calls <= this.fail) {
throw new Error("watcher upsert failure for /tmp/watcher/path.ts")
}
this.points.push(...points)
}
async search(
@@ -214,6 +219,86 @@ describe("FileWatcher", () => {
expect(error?.error).toContain("[REDACTED_PATH]")
})
test("updates worktree shadows when a baseline file changes and reverts", async () => {
const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-"))
const cacheDir = path.join(root, ".cache")
const file = path.join(root, "file.ts")
const baseline = "export const baseline = '" + "x".repeat(100) + "'\n"
const changed = "export const changed = '" + "y".repeat(100) + "'\n"
const baselineHash = createHash("sha256").update(baseline).digest("hex")
await mkdir(cacheDir, { recursive: true })
await writeFile(file, changed)
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
cache.seedHashes({ [file]: baselineHash })
const overlay = new WorktreeOverlay(root, path.join(root, "baseline"), new Map([["file.ts", baselineHash]]))
const store = new RetryStore(0)
const watcher = new FileWatcher(root, cache, createEmbedder(), store)
watcher.setOverlay(overlay)
const data = watcher as unknown as {
processBatch(events: Map<string, { path: string; type: "create" | "change" | "delete" }>): Promise<void>
}
overlay.block(file)
await data.processBatch(new Map([[file, { path: file, type: "change" }]]))
expect(overlay.shadows.has("file.ts")).toBe(true)
expect(overlay.blocked.has("file.ts")).toBe(false)
expect(cache.getHash(file)).toBe(createHash("sha256").update(changed).digest("hex"))
await writeFile(file, baseline)
overlay.block(file)
await data.processBatch(new Map([[file, { path: file, type: "change" }]]))
expect(overlay.shadows.has("file.ts")).toBe(false)
expect(overlay.blocked.has("file.ts")).toBe(false)
expect(cache.getHash(file)).toBe(baselineHash)
expect(store.points.length).toBeGreaterThan(0)
const count = store.points.length
await writeFile(file, changed)
overlay.block(file)
await data.processBatch(new Map([[file, { path: file, type: "change" }]]))
await writeFile(file, baseline)
overlay.block(file)
await data.processBatch(new Map([[file, { path: file, type: "change" }]]))
expect(store.points).toHaveLength(count * 2)
})
test("reports unexpected drain failures for recovery", async () => {
const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-"))
const cacheDir = path.join(root, ".cache")
const file = path.join(root, "file.ts")
await mkdir(cacheDir, { recursive: true })
await writeFile(file, "export const value = '" + "x".repeat(100) + "'\n")
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
cache.flush = async () => {
throw new Error("cache flush failed")
}
const watcher = new FileWatcher(root, cache, createEmbedder(), new RetryStore(0))
const summary = new Promise<{ batchError?: Error }>((resolve) => {
watcher.onDidFinishBatchProcessing.on(resolve)
})
const data = watcher as unknown as {
handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void
}
watcher.setCollecting(true)
data.handleFileEvent(file, "create")
const result = await Promise.race([
summary,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("watcher did not report failure")), 2000)),
])
expect(result.batchError?.message).toBe("cache flush failed")
await watcher.shutdown()
})
test("processFile skips files matched by .kilocodeignore during incremental updates", async () => {
const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-"))
const cacheDir = path.join(root, ".cache")
@@ -172,6 +172,40 @@ class RetryStore extends Store {
}
describe("DirectoryScanner", () => {
test("uses seeded baseline hashes to index only worktree changes", async () => {
const root = await mkdtemp(join(tmpdir(), "scanner-test-"))
const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-"))
const same = join(root, "same.ts")
const changed = join(root, "changed.ts")
const added = join(root, "added.ts")
const deleted = join(root, "deleted.ts")
const sameContent = "export const same = 1\n"
const changedContent = "export const changed = 2\n"
await Bun.write(same, sameContent)
await Bun.write(changed, changedContent)
await Bun.write(added, "export const added = 1\n")
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
cache.seedHashes({
[same]: createHash("sha256").update(sameContent).digest("hex"),
[changed]: "baseline-changed",
[deleted]: "baseline-deleted",
})
const store = new Store()
const scan = new DirectoryScanner(new Emb(), store, new Parser(), cache, ignore(), 1, 1)
const result = await scan.scanDirectory(root)
expect(result.stats.processed).toBe(2)
expect(store.points).toBe(2)
expect(cache.getHash(same)).toBe(createHash("sha256").update(sameContent).digest("hex"))
expect(cache.getHash(changed)).toBe(createHash("sha256").update(changedContent).digest("hex"))
expect(cache.getHash(added)).toBeDefined()
expect(cache.getHash(deleted)).toBeUndefined()
})
test("keeps file metadata when threshold flush is triggered by that file", async () => {
const root = await mkdtemp(join(tmpdir(), "scanner-test-"))
const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-"))
@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test"
import { createHash } from "crypto"
import { mkdir, mkdtemp, writeFile } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { CodeIndexConfigManager } from "../../../src/indexing/config-manager"
import type { IEmbedder, IVectorStore, VectorStoreSearchResult } from "../../../src/indexing/interfaces"
import { CodeIndexSearchService } from "../../../src/indexing/search-service"
import { CodeIndexStateManager } from "../../../src/indexing/state-manager"
import { WorktreeOverlay } from "../../../src/indexing/worktree-overlay"
const result = (filePath: string, score: number, codeChunk = filePath, fileHash?: string): VectorStoreSearchResult => ({
id: `${filePath}:${score}`,
score,
payload: { filePath, fileHash, codeChunk, startLine: 1, endLine: 1 },
})
const store = (results: VectorStoreSearchResult[], limits: number[]): IVectorStore =>
({
async search(_vector, _prefix, _score, limit) {
limits.push(limit ?? results.length)
return results.slice(0, limit)
},
}) as unknown as IVectorStore
const embedder = (calls: string[][]): IEmbedder => ({
async createEmbeddings(texts) {
calls.push(texts)
return { embeddings: [[1, 2, 3]] }
},
async validateConfiguration() {
return { valid: true }
},
get embedderInfo() {
return { name: "openai" }
},
})
const config = () =>
new CodeIndexConfigManager({
enabled: true,
embedderProvider: "openai",
openAiKey: "test",
vectorStoreProvider: "lancedb",
searchMaxResults: 2,
})
describe("CodeIndexSearchService worktree search", () => {
test("embeds once, hides baseline paths, and merges the current delta", async () => {
const root = await mkdtemp(path.join(tmpdir(), "search-worktree-"))
const main = await mkdtemp(path.join(tmpdir(), "search-main-"))
const content = "src/base.ts"
const hash = createHash("sha256").update(content).digest("hex")
await mkdir(path.join(root, "src"), { recursive: true })
await mkdir(path.join(main, "src"), { recursive: true })
await writeFile(path.join(root, "src/base.ts"), content)
await writeFile(path.join(main, "src/base.ts"), content)
const overlay = new WorktreeOverlay(
root,
main,
new Map([
["src/changed.ts", "base"],
["src/base.ts", hash],
]),
)
overlay.reconcile({
[path.join(root, "src/changed.ts")]: "changed",
[path.join(root, "src/base.ts")]: hash,
})
const baseLimits: number[] = []
const deltaLimits: number[] = []
const calls: string[][] = []
const state = new CodeIndexStateManager()
state.setSystemState("Indexed")
const service = new CodeIndexSearchService(
config(),
state,
embedder(calls),
store([result("src/changed.ts", 0.9, "worktree")], deltaLimits),
{
store: store([result("src/changed.ts", 0.99), result("src/base.ts", 0.8, "src/base.ts", hash)], baseLimits),
overlay,
},
)
const results = await service.searchIndex("query")
expect(calls).toEqual([["query"]])
expect(results.map((item) => item.payload?.codeChunk)).toEqual(["worktree", "src/base.ts"])
expect(baseLimits).toEqual([2, 4])
expect(deltaLimits).toEqual([2])
})
test("rejects live baseline results after the primary checkout changes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "search-worktree-"))
const main = await mkdtemp(path.join(tmpdir(), "search-main-"))
const content = "export const value = 'worktree'"
const hash = createHash("sha256").update(content).digest("hex")
await writeFile(path.join(root, "file.ts"), content)
await writeFile(path.join(main, "file.ts"), "export const value = 'primary-only'")
const overlay = new WorktreeOverlay(root, main, new Map([["file.ts", hash]]))
overlay.reconcile({ [path.join(root, "file.ts")]: hash })
const state = new CodeIndexStateManager()
state.setSystemState("Indexed")
const service = new CodeIndexSearchService(config(), state, embedder([]), store([], []), {
store: store(
[
result(
"file.ts",
0.99,
"export const value = 'primary-only'",
createHash("sha256").update("export const value = 'primary-only'").digest("hex"),
),
],
[],
),
overlay,
})
expect(await service.searchIndex("query")).toEqual([])
})
test("over-fetches when shadowed baseline results consume the first page", async () => {
const root = await mkdtemp(path.join(tmpdir(), "search-worktree-"))
const main = await mkdtemp(path.join(tmpdir(), "search-main-"))
const hash = (content: string) => createHash("sha256").update(content).digest("hex")
await mkdir(path.join(root, "src"), { recursive: true })
await mkdir(path.join(main, "src"), { recursive: true })
await Promise.all(
["c", "d"].flatMap((name) => [
writeFile(path.join(root, `src/${name}.ts`), `src/${name}.ts`),
writeFile(path.join(main, `src/${name}.ts`), `src/${name}.ts`),
]),
)
const overlay = new WorktreeOverlay(
root,
main,
new Map([
["src/a.ts", "a"],
["src/b.ts", "b"],
["src/c.ts", hash("src/c.ts")],
["src/d.ts", hash("src/d.ts")],
]),
)
overlay.reconcile({
[path.join(root, "src/a.ts")]: "changed-a",
[path.join(root, "src/b.ts")]: "changed-b",
[path.join(root, "src/c.ts")]: hash("src/c.ts"),
[path.join(root, "src/d.ts")]: hash("src/d.ts"),
})
const limits: number[] = []
const state = new CodeIndexStateManager()
state.setSystemState("Indexed")
const service = new CodeIndexSearchService(config(), state, embedder([]), store([], []), {
store: store(
[
result("src/a.ts", 0.99),
result("src/b.ts", 0.98),
result("src/c.ts", 0.8, "src/c.ts", hash("src/c.ts")),
result("src/d.ts", 0.7, "src/d.ts", hash("src/d.ts")),
],
limits,
),
overlay,
})
const results = await service.searchIndex("query")
expect(limits).toEqual([2, 4])
expect(results.map((item) => item.payload?.filePath)).toEqual(["src/c.ts", "src/d.ts"])
})
})
@@ -173,9 +173,50 @@ describe("LocalVectorStore", () => {
expect(mockLoadLanceDB).toHaveBeenCalledTimes(1)
expect(mockLanceDBModule.connect).not.toHaveBeenCalled()
})
test("serializes native connections across stores", async () => {
const other = new LanceDBVectorStore(path.join("mock", "other"), vectorSize, dbDirectory)
store["db"] = null
other["lancedbModule"] = mockLanceDBModule
let active = 0
let maximum = 0
mockLanceDBModule.connect.mockImplementation(async () => {
active += 1
maximum = Math.max(maximum, active)
await Bun.sleep(10)
active -= 1
return mockDb
})
try {
await Promise.all([store.collectionExists(), other.collectionExists()])
} finally {
await other.close()
}
expect(maximum).toBe(1)
})
})
describe("initialize", () => {
test("opens a complete compatible baseline without mutating it", async () => {
spyOn(fs, "existsSync").mockReturnValue(true as any)
store["_getStoredEmbeddingProfile"] = mock().mockResolvedValue({
provider: "openai",
modelId: "",
dimension: vectorSize,
})
store["_getMetadataValue"] = mock((_: unknown, key: string) =>
Promise.resolve(key === "index_schema" ? "2" : "true"),
)
await store.openExisting()
expect(mockDb.createTable).not.toHaveBeenCalled()
expect(mockDb.dropTable).not.toHaveBeenCalled()
expect(mockTable.delete).not.toHaveBeenCalled()
})
test("should create tables if not exist", async () => {
mockDb.tableNames.mockResolvedValue([])
mockDb.createTable.mockResolvedValue(mockTable)
@@ -195,19 +236,49 @@ describe("LocalVectorStore", () => {
expect(mockDb.dropTable).toHaveBeenCalled()
})
test("should not recreate if vector size matches", async () => {
test("should not recreate if vector size and schema match", async () => {
mockDb.tableNames.mockResolvedValue(["vector", "metadata"])
mockDb.openTable.mockResolvedValue(mockTable)
store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize)
store["_getMetadataValue"] = mock().mockResolvedValue("2")
const result = await store.initialize()
expect(result).toBe(false)
})
test("recreates an index using the legacy payload schema", async () => {
mockDb.tableNames.mockResolvedValue(["vector", "metadata"])
mockDb.openTable.mockResolvedValue(mockTable)
store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize)
store["_getMetadataValue"] = mock().mockResolvedValue("1")
expect(await store.initialize()).toBe(true)
expect(mockDb.dropTable).toHaveBeenCalledTimes(2)
})
test("should throw error on LanceDB failure", async () => {
mockDb.tableNames.mockRejectedValue(new Error("fail"))
await expect(store.initialize()).rejects.toThrow()
})
test("does not recreate when vector metadata cannot be read", async () => {
store["_getStoredVectorSize"] = mock().mockRejectedValue(new Error("metadata unavailable"))
await expect(store.initialize()).rejects.toThrow("metadata unavailable")
expect(mockDb.dropTable).not.toHaveBeenCalled()
expect(mockDb.createTable).not.toHaveBeenCalled()
})
test("does not recreate when profile metadata cannot be read", async () => {
mockTable.countRows.mockResolvedValue(1)
store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize)
store["_getMetadataValue"] = mock().mockResolvedValue("2")
store["_getStoredEmbeddingProfile"] = mock().mockRejectedValue(new Error("profile unavailable"))
await expect(store.initialize()).rejects.toThrow("profile unavailable")
expect(mockDb.dropTable).not.toHaveBeenCalled()
expect(mockDb.createTable).not.toHaveBeenCalled()
})
test("should recreate tables when stored embedding identity differs", async () => {
const identity = {
provider: "openai",
@@ -316,7 +387,7 @@ describe("LocalVectorStore", () => {
{
id: "123e4567-e89b-12d3-a456-426614174000",
vector: [1, 2, 3],
payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 },
payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 },
},
]
mockTable.delete.mockResolvedValue(undefined)
@@ -331,7 +402,7 @@ describe("LocalVectorStore", () => {
{
id: "123e4567-e89b-12d3-a456-426614174000",
vector: [1, 2, 3],
payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 },
payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 },
},
]
mockTable.delete.mockResolvedValue(undefined)
@@ -349,7 +420,7 @@ describe("LocalVectorStore", () => {
distanceRange: distanceRangeSpy,
limit: mock().mockReturnThis(),
toArray: mock().mockResolvedValue([
{ id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 },
{ id: "2", _distance: 0.2, filePath: "a", fileHash: "hash-a", codeChunk: "c", startLine: 3, endLine: 4 },
]),
})
const results = await store.search([1, 2, 3], "a", 0.7, 1)
@@ -373,7 +444,7 @@ describe("LocalVectorStore", () => {
distanceRange: distanceRangeSpy,
limit: mock().mockReturnThis(),
toArray: mock().mockResolvedValue([
{ id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 },
{ id: "2", _distance: 0.2, filePath: "a", fileHash: "hash-a", codeChunk: "c", startLine: 3, endLine: 4 },
]),
})
const results = await store.search([1, 2, 3], "a", 0.1, 2)
@@ -490,7 +561,7 @@ describe("LocalVectorStore", () => {
})
test("should return true for valid payload", () => {
const payload: Payload = { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }
const payload: Payload = { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 }
expect(store["isPayloadValid"](payload)).toBe(true)
})
})
@@ -590,6 +661,7 @@ describe("LocalVectorStore", () => {
vector: [1, 2, 3],
payload: {
filePath: "test.ts",
fileHash: "hash-test",
codeChunk: "code",
startLine: 1,
endLine: 2,
@@ -609,12 +681,12 @@ describe("LocalVectorStore", () => {
{
id: "123e4567-e89b-12d3-a456-426614174000",
vector: [1, 2, 3],
payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 },
payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 },
},
{
id: "' OR '1'='1",
vector: [4, 5, 6],
payload: { filePath: "c", codeChunk: "d", startLine: 3, endLine: 4 },
payload: { filePath: "c", fileHash: "hash-c", codeChunk: "d", startLine: 3, endLine: 4 },
},
]
@@ -1,4 +1,5 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { createHash } from "crypto"
import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../../../src/indexing/constants"
@@ -29,14 +30,6 @@ mock.module("@qdrant/js-client-rest", () => ({
QdrantClient: MockQdrantClientConstructor,
}))
const mockDigest = mock()
const mockUpdate = mock(() => ({ update: mockUpdate, digest: mockDigest }))
const mockCreateHash = mock(() => ({ update: mockUpdate, digest: mockDigest }))
mock.module("crypto", () => ({
createHash: mockCreateHash,
}))
// Now import the module under test
import { QdrantVectorStore } from "../../../../src/indexing/vector-store/qdrant-client"
@@ -46,8 +39,7 @@ describe("QdrantVectorStore", () => {
const mockQdrantUrl = "http://mock-qdrant:6333"
const mockApiKey = "test-api-key"
const mockVectorSize = 1536
const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
const expectedCollectionName = `ws-${mockHashedPath.substring(0, 16)}`
const expectedCollectionName = `ws-${createHash("sha256").update(mockWorkspacePath).digest("hex").substring(0, 16)}`
beforeEach(() => {
// Reset all mocks
@@ -61,14 +53,6 @@ describe("QdrantVectorStore", () => {
mockQuery.mockReset()
mockDelete.mockReset()
mockRetrieve.mockReset()
mockCreateHash.mockReset()
mockUpdate.mockReset()
mockDigest.mockReset()
// Mock crypto.createHash chain
mockCreateHash.mockReturnValue({ update: mockUpdate, digest: mockDigest })
mockUpdate.mockReturnValue({ update: mockUpdate, digest: mockDigest })
mockDigest.mockReturnValue(mockHashedPath)
vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
})
@@ -83,9 +67,6 @@ describe("QdrantVectorStore", () => {
mockQuery.mockReset()
mockDelete.mockReset()
mockRetrieve.mockReset()
mockCreateHash.mockReset()
mockUpdate.mockReset()
mockDigest.mockReset()
})
test("should correctly initialize QdrantClient and collectionName in constructor", () => {
@@ -99,9 +80,6 @@ describe("QdrantVectorStore", () => {
"User-Agent": "Kilo-Code",
},
})
expect(mockCreateHash).toHaveBeenCalledWith("sha256")
expect(mockUpdate).toHaveBeenCalledWith(mockWorkspacePath)
expect(mockDigest).toHaveBeenCalledWith("hex")
expect((vectorStore as any).collectionName).toBe(expectedCollectionName)
expect((vectorStore as any).vectorSize).toBe(mockVectorSize)
})
@@ -524,6 +502,31 @@ describe("QdrantVectorStore", () => {
})
describe("initialize", () => {
test("opens a complete compatible baseline without mutating it", async () => {
mockGetCollection.mockResolvedValue({
points_count: 3,
config: { params: { vectors: { size: mockVectorSize } } },
})
mockRetrieve.mockResolvedValue([
{
payload: {
index_schema: 2,
indexing_complete: true,
embedding_provider: "openai",
embedding_model_id: "",
embedding_dimension: mockVectorSize,
},
},
])
await vectorStore.openExisting()
expect(mockCreateCollection).not.toHaveBeenCalled()
expect(mockDeleteCollection).not.toHaveBeenCalled()
expect(mockCreatePayloadIndex).not.toHaveBeenCalled()
expect(mockUpsert).not.toHaveBeenCalled()
})
test("should create a new collection if none exists and return true", async () => {
mockGetCollection.mockRejectedValue({
response: { status: 404 },
@@ -598,6 +601,33 @@ describe("QdrantVectorStore", () => {
expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6)
})
test("recreates a populated collection using the legacy payload schema", async () => {
mockGetCollection
.mockResolvedValueOnce({
points_count: 7,
config: { params: { vectors: { size: mockVectorSize } } },
} as any)
.mockRejectedValueOnce({ response: { status: 404 }, message: "Not found" })
mockRetrieve.mockResolvedValue([
{
payload: {
index_schema: 1,
indexing_complete: true,
embedding_provider: "openai",
embedding_model_id: "",
embedding_dimension: mockVectorSize,
},
},
] as any)
mockDeleteCollection.mockResolvedValue(true as any)
mockCreateCollection.mockResolvedValue(true as any)
mockCreatePayloadIndex.mockResolvedValue({} as any)
expect(await vectorStore.initialize()).toBe(true)
expect(mockDeleteCollection).toHaveBeenCalledTimes(1)
expect(mockCreateCollection).toHaveBeenCalledTimes(1)
})
test("should recreate collection when stored embedding identity mismatches", async () => {
const identity = {
provider: "openai",
@@ -1277,6 +1307,7 @@ describe("QdrantVectorStore", () => {
score: 0.85,
payload: {
filePath: "src/test.ts",
fileHash: "test-hash",
codeChunk: "test code",
startLine: 1,
endLine: 5,
@@ -1288,6 +1319,7 @@ describe("QdrantVectorStore", () => {
score: 0.75,
payload: {
filePath: "src/utils.ts",
fileHash: "test-hash",
codeChunk: "utility code",
startLine: 10,
endLine: 15,
@@ -1312,7 +1344,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs.filter).toEqual({
@@ -1332,6 +1364,7 @@ describe("QdrantVectorStore", () => {
score: 0.85,
payload: {
filePath: "src/components/Button.tsx",
fileHash: "test-hash",
codeChunk: "button code",
startLine: 1,
endLine: 5,
@@ -1351,7 +1384,7 @@ describe("QdrantVectorStore", () => {
score_threshold: DEFAULT_SEARCH_MIN_SCORE,
limit: DEFAULT_MAX_SEARCH_RESULTS,
params: { hnsw_ef: 128, exact: false },
with_payload: { include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"] },
with_payload: { include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"] },
})
expect(callArgs2.filter).toEqual({
must: [
@@ -1383,7 +1416,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs3.filter).toEqual({
@@ -1410,7 +1443,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs4.filter).toEqual({
@@ -1427,6 +1460,7 @@ describe("QdrantVectorStore", () => {
score: 0.85,
payload: {
filePath: "src/test.ts",
fileHash: "test-hash",
codeChunk: "test code",
startLine: 1,
endLine: 5,
@@ -1444,6 +1478,7 @@ describe("QdrantVectorStore", () => {
score: 0.55,
payload: {
filePath: "src/test2.ts",
fileHash: "test-hash",
codeChunk: "test code 2",
startLine: 10,
endLine: 15,
@@ -1470,6 +1505,7 @@ describe("QdrantVectorStore", () => {
score: 0.85,
payload: {
filePath: "src/test.ts",
fileHash: "test-hash",
codeChunk: "test code",
startLine: 1,
endLine: 5,
@@ -1490,6 +1526,7 @@ describe("QdrantVectorStore", () => {
score: 0.55,
payload: {
filePath: "src/test2.ts",
fileHash: "test-hash",
codeChunk: "test code 2",
startLine: 10,
endLine: 15,
@@ -1538,7 +1575,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs5.filter).toEqual({
@@ -1586,6 +1623,7 @@ describe("QdrantVectorStore", () => {
score: 0.85,
payload: {
filePath: "src/test.ts",
fileHash: "test-hash",
codeChunk: "test code",
startLine: 1,
endLine: 5,
@@ -1609,7 +1647,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs7.filter).toEqual({
@@ -1638,7 +1676,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs6.filter).toEqual({
@@ -1665,7 +1703,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs8.filter).toEqual({
@@ -1692,7 +1730,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs9.filter).toEqual({
@@ -1719,7 +1757,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs10.filter).toEqual({
@@ -1746,7 +1784,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs11.filter).toEqual({
@@ -1779,7 +1817,7 @@ describe("QdrantVectorStore", () => {
exact: false,
},
with_payload: {
include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"],
include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"],
},
})
expect(callArgs12.filter).toEqual({
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { WorktreeOverlay } from "../../../src/indexing/worktree-overlay"
describe("WorktreeOverlay", () => {
test("seeds baseline hashes and shadows changed or deleted files", () => {
const root = path.resolve("/tmp/worktree")
const overlay = new WorktreeOverlay(
root,
path.resolve("/tmp/main"),
new Map([
["src/same.ts", "same"],
["src/changed.ts", "base"],
["src/deleted.ts", "deleted"],
]),
)
expect(overlay.seed()).toEqual({
[path.join(root, "src/same.ts")]: "same",
[path.join(root, "src/changed.ts")]: "base",
[path.join(root, "src/deleted.ts")]: "deleted",
})
overlay.reconcile({
[path.join(root, "src/same.ts")]: "same",
[path.join(root, "src/changed.ts")]: "worktree",
})
expect(overlay.ready).toBe(true)
expect([...overlay.shadows].sort()).toEqual(["src/changed.ts", "src/deleted.ts"])
})
test("blocks updates and restores baseline visibility after a revert", () => {
const root = path.resolve("/tmp/worktree")
const file = path.join(root, "src/file.ts")
const overlay = new WorktreeOverlay(root, path.resolve("/tmp/main"), new Map([["src/file.ts", "base"]]))
overlay.block(file)
expect(overlay.blocked.has("src/file.ts")).toBe(true)
overlay.settle(file, "changed")
expect(overlay.blocked.has("src/file.ts")).toBe(false)
expect(overlay.shadows.has("src/file.ts")).toBe(true)
overlay.block(file)
overlay.settle(file, "base")
expect(overlay.blocked.has("src/file.ts")).toBe(false)
expect(overlay.shadows.has("src/file.ts")).toBe(false)
})
})
+1
View File
@@ -183,6 +183,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries.
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first).
- **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode.
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
- **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
- **Run split backend**: `./gradlew runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
@@ -6,6 +6,9 @@ import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.history.HistoryController
import ai.kilocode.client.session.history.HistoryPanel
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.UiTimer
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.application.ApplicationManager
@@ -20,17 +23,18 @@ import kotlinx.coroutines.cancel
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
class SessionSidePanelManager(
private val project: Project,
private val root: Workspace,
private val create: (Project, Workspace, SessionManager, SessionRef?) -> SessionUi = { project, workspace, manager, ref ->
service<SessionUiFactory>().create(project, workspace, manager, ref)
},
private val create: (Project, Workspace, SessionManager, SessionRef?, UiTimerSource) -> SessionUi =
{ project, workspace, manager, ref, timers ->
service<SessionUiFactory>().create(project, workspace, manager, ref, timers)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
private val status: () -> Map<String, SessionActivityKind> = { project.service<KiloSessionService>().activity() },
private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
private val timers: UiTimerSource = UiTimers,
) : SessionManager, Disposable {
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
@@ -42,7 +46,7 @@ class SessionSidePanelManager(
private val opened = mutableMapOf<String, SessionUi>()
private val all = mutableSetOf<SessionUi>()
private val timers = mutableMapOf<SessionUi, Timer>()
private val activeTimers = mutableMapOf<SessionUi, UiTimer>()
private var current: SessionUi? = null
private var latest: SessionUi? = null
private var panel: JComponent? = null
@@ -53,7 +57,7 @@ class SessionSidePanelManager(
val active = current
if (active?.blank == true) return
register(active)
show(create(project, root, this, null))
show(create(project, root, this, null, timers))
}
override fun openSession(ref: SessionRef) {
@@ -100,7 +104,7 @@ class SessionSidePanelManager(
is SessionRef.Local -> ref.session?.directory?.let(resolve) ?: root
is SessionRef.Cloud -> root
}
return create(project, workspace, this, ref).also {
return create(project, workspace, this, ref, timers).also {
all.add(it)
opened[ref.key] = it
val local = (ref as? SessionRef.Local)?.session?.id
@@ -147,7 +151,7 @@ class SessionSidePanelManager(
deleted = this::removeSession,
)
Disposer.register(this) { cs.cancel() }
return HistoryPanel(this, controller, nav = this::back, manager = this).component
return HistoryPanel(this, controller, nav = this::back, manager = this, timers = timers).component
}
private fun back() {
@@ -206,24 +210,23 @@ class SessionSidePanelManager(
private fun schedule(ui: SessionUi) {
cancel(ui)
val delay = Registry.intValue("kilo.session.inactive.disposeTimeoutMs").coerceAtLeast(0)
val timer = Timer(delay) {
timers.remove(ui)
if (ui === current || ui !in all) return@Timer
val timer = timers.timer(delay, repeats = false) {
activeTimers.remove(ui)
if (ui === current || ui !in all) return@timer
disposeUi(ui)
}
timer.isRepeats = false
timers[ui] = timer
activeTimers[ui] = timer
timer.start()
}
private fun cancel(ui: SessionUi) {
timers.remove(ui)?.stop()
activeTimers.remove(ui)?.stop()
}
override fun dispose() {
val items = all.toList()
timers.values.forEach { it.stop() }
timers.clear()
activeTimers.values.forEach { it.stop() }
activeTimers.clear()
opened.clear()
all.clear()
current = null
@@ -41,6 +41,8 @@ import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.PromptDto
@@ -77,7 +79,6 @@ import java.net.URI
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
import javax.swing.UIManager
/**
@@ -97,6 +98,7 @@ class SessionUi(
private val manager: SessionManager? = null,
private val workspaces: KiloWorkspaceService = service(),
private val migration: MigrationUiController = service<KiloMigrationService>(),
private val timers: UiTimerSource = UiTimers,
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider {
companion object {
@@ -133,17 +135,16 @@ class SessionUi(
afterUpdate = { if (!opening) scroll.followBottom(it) },
loaded = ::onSessionLoaded,
openProfileAction = ::openProfileSettings,
timers = timers,
)
private lateinit var root: SessionRootPanel
private lateinit var account: SessionAccountOverlay
private lateinit var drop: SessionDropOverlay
private val hide = Timer(HIDE_MS) {
if (disposed || !this::drop.isInitialized) return@Timer
private val hide = timers.timer(HIDE_MS, repeats = false) {
if (disposed || !this::drop.isInitialized) return@timer
drop.setActive(false)
}.apply {
isRepeats = false
}
private lateinit var sessionContent: JPanel
@@ -418,6 +419,7 @@ class SessionUi(
history = { manager?.showHistory() },
activity = { manager?.activity() ?: sessions.activity() },
titles = { manager?.titles().orEmpty() },
timers = timers,
)
empty = panel
scroll.show(panel.view)
@@ -3,6 +3,8 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
@@ -19,6 +21,7 @@ class SessionUiFactory(
workspace: Workspace,
manager: SessionManager,
ref: SessionRef? = null,
timers: UiTimerSource = UiTimers,
): SessionUi = SessionUi(
project = project,
workspace = workspace,
@@ -27,6 +30,7 @@ class SessionUiFactory(
cs = scope(),
ref = ref,
manager = manager,
timers = timers,
)
fun scope(): CoroutineScope {
@@ -1,18 +1,20 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import javax.swing.Timer
internal class DelayedState(
private val ms: Long,
private val timers: UiTimerSource = UiTimers,
) : Disposable {
private val tick = when {
ms <= 0 -> 1
ms > Int.MAX_VALUE -> Int.MAX_VALUE
else -> ms.coerceAtMost(TICK_MS).toInt()
}
private val timer = Timer(tick) { flush() }
private val timer = timers.timer(tick) { flush() }
private val pending = mutableListOf<Pending<*>>()
@Volatile private var alive = true
@@ -36,7 +38,7 @@ internal class DelayedState(
}
}
internal fun active() = timer.isRunning
internal fun active() = timer.isRunning()
private fun <T : Any> apply(item: Pending<T>) {
if (!alive) return
@@ -47,7 +49,7 @@ internal class DelayedState(
private fun flush() {
if (!alive) return
val now = System.currentTimeMillis()
val now = timers.now()
for (item in pending.toList()) {
if (item.due > now) continue
apply(item)
@@ -56,7 +58,7 @@ internal class DelayedState(
}
private fun due(): Long {
val now = System.currentTimeMillis()
val now = timers.now()
return now + ms.coerceAtMost(Long.MAX_VALUE - now)
}
@@ -72,9 +74,6 @@ internal class DelayedState(
override fun dispose() {
alive = false
cancel()
edt {
timer.stop()
}
}
private data class Pending<T : Any>(
@@ -23,6 +23,8 @@ import ai.kilocode.client.session.model.Text
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -89,6 +91,7 @@ class SessionController(
private val loaded: (Boolean) -> Unit = {},
private val openProfileAction: () -> Unit = {},
private val telemetry: (String, Map<String, String>) -> Unit = { event, props -> Telemetry.send(event, props) },
private val timers: UiTimerSource = UiTimers,
) : Disposable {
private data class OrganizationTarget(val org: String?)
@@ -138,7 +141,7 @@ class SessionController(
private var viewState: SessionControllerEvent.ViewChanged? = null
private var connectionState: SessionControllerEvent.ConnectionChanged? = null
private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null
private val connectionDelay = DelayedState(displayMs)
private val connectionDelay = DelayedState(displayMs, timers)
private var acctState: SessionControllerEvent.AccountOverlayChanged =
SessionControllerEvent.AccountOverlayChanged.Hide
private var acctAllowed = false
@@ -7,6 +7,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.iconButton
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager
@@ -49,7 +51,6 @@ import javax.swing.JList
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.SwingUtilities
import javax.swing.Timer
import javax.swing.event.DocumentEvent
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
@@ -59,6 +60,7 @@ class HistoryPanel(
private val controller: HistoryController,
private val nav: () -> Unit = {},
private val manager: SessionManager? = null,
private val timers: UiTimerSource = UiTimers,
) : BorderLayoutPanel(), Disposable, DataProvider {
private val localSearch = search(controller.local)
private val cloudSearch = search(controller.cloud)
@@ -83,7 +85,7 @@ class HistoryPanel(
.setText(KiloBundle.message("history.tab.cloud"))
.setForeSideComponent(back())
private var stale = false
private val timer = Timer(ACTIVITY_MS) { syncActivity() }
private val timer = timers.timer(ACTIVITY_MS) { syncActivity() }
private val tabs: JBTabs = JBTabsFactory.createTabs(null, this).apply {
presentation.setSingleRow(true)
presentation.setTabsPosition(JBTabsPosition.top)
@@ -12,6 +12,8 @@ import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
@@ -37,7 +39,6 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.Timer
/**
* Empty-session panel.
@@ -53,10 +54,11 @@ class EmptySessionPanel(
private val activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
private val titles: () -> Map<String, String> = { emptyMap() },
private val browse: (String) -> Unit = BrowserUtil::browse,
private val timers: UiTimerSource = UiTimers,
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val timer = Timer(ACTIVITY_MS) { syncActivity() }
private val timer = timers.timer(ACTIVITY_MS) { syncActivity() }
internal val recent = RecentsList(recents, controller)
private val historyButton = ShowHistoryButton().apply {
@@ -180,7 +182,7 @@ class EmptySessionPanel(
internal fun activeView() = getComponent(0)
internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) =
internal fun text(session: SessionDto, now: Long = timers.now()) =
recent.text(session, now)
internal fun rendererComponent(
@@ -4,6 +4,8 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import com.intellij.icons.AllIcons
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.ui.popup.Balloon
@@ -29,7 +31,6 @@ import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JPanel
import javax.swing.SwingConstants
import javax.swing.Timer
internal data class DeviceOAuthInfo(
val url: String,
@@ -48,6 +49,7 @@ internal class DeviceOAuthPanel(
private val cancel: () -> Unit,
private val browse: (String) -> Unit,
private val prefix: String,
private val timers: UiTimerSource = UiTimers,
) : JPanel(GridBagLayout()) {
val urlField = JBTextField().apply {
isEditable = false
@@ -100,7 +102,7 @@ internal class DeviceOAuthPanel(
private var started = 0L
private var expires = 900
private var last: String? = null
private val timer = Timer(1000) { syncTime() }
private val timer = timers.timer(1000) { syncTime() }
init {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
@@ -182,7 +184,7 @@ internal class DeviceOAuthPanel(
@RequiresEdt
private fun syncTime() {
val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt()
val elapsed = ((timers.now() - started) / 1000).toInt()
val remain = (expires - elapsed).coerceAtLeast(0)
val min = remain / 60
val sec = remain % 60
@@ -5,6 +5,8 @@ import ai.kilocode.client.settings.auth.DeviceOAuthInfo
import ai.kilocode.client.settings.auth.DeviceOAuthPanel
import ai.kilocode.client.settings.auth.DeviceOAuthText
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
@@ -33,6 +35,7 @@ internal class LoggedOutProfileUi(
private val retry: () -> Unit,
private val cancel: () -> Unit,
private val browse: (String) -> Unit,
private val timers: UiTimerSource = UiTimers,
) : JPanel(BorderLayout()) {
private val cards = JPanel(CardLayout())
@@ -60,6 +63,7 @@ internal class LoggedOutProfileUi(
cancel = cancel,
browse = browse,
prefix = "kilo.login",
timers = timers,
)
private val initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() }
@@ -3,6 +3,8 @@ package ai.kilocode.client.settings.profile
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ProfileDto
@@ -42,6 +44,7 @@ internal class ProfileUi(
private val cs: CoroutineScope,
private val app: KiloAppService = service(),
private val browse: (String) -> Unit = { BrowserUtil.browse(it) },
private val timers: UiTimerSource = UiTimers,
) : JPanel(BorderLayout()) {
private val cards = JPanel(CardLayout())
@@ -52,6 +55,7 @@ internal class ProfileUi(
retry = { app.retryAsync() },
cancel = ::cancel,
browse = browse,
timers = timers,
)
private val account = LoggedInProfileUi(
dashboard = {
@@ -191,7 +195,7 @@ internal class ProfileUi(
val next = app.startLogin()
withContext(edt) {
if (id != attempt) return@withContext
login = LoginState.Pending(next, System.currentTimeMillis())
login = LoginState.Pending(next, timers.now())
sync()
browse(next.verificationUrl)
}
@@ -0,0 +1,41 @@
package ai.kilocode.client.util
import javax.swing.Timer
interface UiTimer {
fun start()
fun stop()
fun restart()
fun isRunning(): Boolean
}
interface UiTimerSource {
fun now(): Long
fun timer(ms: Int, repeats: Boolean = true, action: () -> Unit): UiTimer
}
object UiTimers : UiTimerSource {
override fun now(): Long = System.currentTimeMillis()
override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer {
val timer = Timer(ms.coerceAtLeast(0)) { action() }
timer.isRepeats = repeats
return SwingUiTimer(timer)
}
private class SwingUiTimer(private val timer: Timer) : UiTimer {
override fun start() {
timer.start()
}
override fun stop() {
timer.stop()
}
override fun restart() {
timer.restart()
}
override fun isRunning(): Boolean = timer.isRunning
}
}
@@ -16,6 +16,7 @@ import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.TestUiTimers
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
@@ -39,11 +40,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import java.awt.event.ActionEvent
import javax.swing.JLabel
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
@Suppress("UnstableApiUsage")
class SessionSidePanelManagerTest : BasePlatformTestCase() {
@@ -53,6 +52,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
private lateinit var workspace: Workspace
private lateinit var sessions: KiloSessionService
private lateinit var app: KiloAppService
private lateinit var timers: TestUiTimers
private val managers = mutableListOf<SessionSidePanelManager>()
private val created = mutableListOf<Pair<String, String?>>()
private val refs = mutableListOf<SessionRef?>()
@@ -60,6 +60,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
override fun setUp() {
super.setUp()
timers = TestUiTimers()
scope = CoroutineScope(SupervisorJob())
rpc = FakeSessionRpcApi()
sessions = KiloSessionService(project, scope, rpc)
@@ -223,7 +224,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
expire(manager, first)
expire()
assertFalse(ui.contains(first))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created)
@@ -236,7 +237,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
expire(manager, first)
expire()
manager.openSession(session("ses_1"))
assertNotSame(first, active(manager))
@@ -257,7 +258,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant")))
flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale"))
}
expire(manager, first)
expire()
manager.openSession(session("ses_1"))
val second = active(manager)
settle()
@@ -590,7 +591,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1")))
}
manager.openSession(session("ses_2"))
expire(manager, first)
expire()
assertFalse(ui.contains(first))
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
@@ -605,7 +606,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
first.controller().model.setState(SessionState.Busy("running"))
}
manager.openSession(session("ses_2"))
expire(manager, first)
expire()
assertFalse(ui.contains(first))
}
@@ -616,7 +617,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager)
manager.openSession(session("ses_2"))
expire(manager, first)
expire()
assertFalse(ui.contains(first))
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
@@ -628,7 +629,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
val manager = SessionSidePanelManager(
project = project,
root = workspace,
create = { project, workspace, owner, ref ->
create = { project, workspace, owner, ref, timers ->
val id = when (ref) {
is SessionRef.Local -> ref.id
is SessionRef.Cloud -> ref.key
@@ -636,7 +637,17 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
created.add(workspace.directory to id)
refs.add(ref)
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner, workspaces = workspaces).also {
SessionUi(
project,
workspace,
sessions,
app,
scope,
ref = ref,
manager = owner,
workspaces = workspaces,
timers = timers,
).also {
ui.add(it)
Disposer.register(it) { ui.remove(it) }
}
@@ -644,6 +655,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
resolve = { workspaces.workspace(it) },
status = { sessions.activity() },
history = history,
timers = timers,
)
managers.add(manager)
return manager
@@ -700,14 +712,8 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
method.invoke(manager)
}
private fun expire(manager: SessionSidePanelManager, ui: JPanel) {
val field = SessionSidePanelManager::class.java.getDeclaredField("timers")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val timers = field.get(manager) as Map<JPanel, Timer>
val timer = requireNotNull(timers[ui]) { "Expected an inactive session disposal timer" }
timer.stop()
timer.actionListeners.single().actionPerformed(ActionEvent(timer, ActionEvent.ACTION_PERFORMED, "expire"))
private fun expire() {
timers.advanceBy(10)
}
private fun settle() = kotlinx.coroutines.runBlocking {
@@ -12,6 +12,7 @@ import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.scroll.SessionScroll
import ai.kilocode.rpc.dto.ChatEventDto
@@ -29,8 +30,6 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.openapi.util.Disposer
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
@@ -42,6 +41,7 @@ import javax.swing.JScrollBar
@Suppress("UnstableApiUsage")
abstract class SessionUiTestBase : BasePlatformTestCase() {
private lateinit var coroutines: TestCoroutines
protected lateinit var scope: CoroutineScope
protected lateinit var sessions: KiloSessionService
protected lateinit var app: KiloAppService
@@ -53,7 +53,8 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob())
coroutines = TestCoroutines()
scope = coroutines.scope
rpc = FakeSessionRpcApi()
appRpc = FakeAppRpcApi().also {
@@ -75,7 +76,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
override fun tearDown() {
try {
Disposer.dispose(ui)
scope.cancel()
coroutines.close { UIUtil.dispatchAllInvocationEvents() }
} finally {
super.tearDown()
}
@@ -116,11 +117,8 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
(scrollView() as? Container)?.doLayout()
}
protected fun settle() = runBlocking {
repeat(5) {
delay(100)
UIUtil.dispatchAllInvocationEvents()
}
protected fun settle() {
coroutines.drain { UIUtil.dispatchAllInvocationEvents() }
}
protected fun settleShort(ms: Long) = runBlocking {
@@ -141,7 +139,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("part_$i", id, "text", text(i))), flush = false)
}
settleShort(100)
settle()
forceFlush()
drainScroll()
}
@@ -149,7 +147,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
protected fun emit(event: ChatEventDto, flush: Boolean = true) {
runBlocking { rpc.events.emit(event) }
if (flush) {
settleShort(20)
settle()
forceFlush()
}
}
@@ -1,14 +1,18 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.testing.TestUiTimers
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
class DelayedStateTest : BasePlatformTestCase() {
private val states = mutableListOf<DelayedState>()
private lateinit var timers: TestUiTimers
override fun setUp() {
super.setUp()
timers = TestUiTimers()
}
override fun tearDown() {
try {
@@ -25,7 +29,7 @@ class DelayedStateTest : BasePlatformTestCase() {
val delay = delayed(30)
delay.run("loading", { state }) { out.add(it) }
pause(120)
timers.advanceBy(30)
assertEquals(listOf("loading"), out)
}
@@ -37,7 +41,7 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("loading", { state }) { out.add(it) }
edt { state = "loaded" }
pause(120)
timers.advanceBy(30)
assertTrue(out.isEmpty())
}
@@ -50,7 +54,7 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("loading", { first }) { out.add("first:$it") }
delay.run("connecting", { second }) { out.add("second:$it") }
pause(120)
timers.advanceBy(30)
assertEquals(listOf("first:loading", "second:connecting"), out)
}
@@ -63,7 +67,7 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("first", { state }) { out.add(it) }
edt { state = "second" }
delay.run("second", { state }) { out.add(it) }
pause(120)
timers.advanceBy(30)
assertEquals(listOf("second"), out)
}
@@ -75,7 +79,7 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("loading", { state }) { out.add(it) }
delay.cancel()
pause(120)
timers.advanceBy(30)
assertTrue(out.isEmpty())
}
@@ -85,9 +89,9 @@ class DelayedStateTest : BasePlatformTestCase() {
val delay = delayed(30)
delay.run("loading", { state }) {}
pause(10)
timers.advanceBy(10)
delay.cancel()
pause(10)
timers.advanceBy(10)
assertFalse(delay.active())
}
@@ -97,7 +101,7 @@ class DelayedStateTest : BasePlatformTestCase() {
val delay = delayed(30)
delay.run("loading", { state }) {}
pause(120)
timers.advanceBy(30)
assertFalse(delay.active())
}
@@ -110,7 +114,7 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("loading", { state }) { out.add(it) }
Disposer.dispose(delay)
states.remove(delay)
pause(120)
timers.advanceBy(30)
assertTrue(out.isEmpty())
}
@@ -123,25 +127,17 @@ class DelayedStateTest : BasePlatformTestCase() {
delay.run("loading", { state }) {
out.add(ApplicationManager.getApplication().isDispatchThread)
}
pause(30)
timers.advanceBy(0)
assertEquals(listOf(true), out)
}
private fun delayed(ms: Long): DelayedState {
val state = DelayedState(ms)
val state = DelayedState(ms, timers)
states.add(state)
return state
}
private fun pause(ms: Long) = runBlocking {
val tick = 10L
repeat((ms / tick).coerceAtLeast(1).toInt()) {
delay(tick)
edt { UIUtil.dispatchAllInvocationEvents() }
}
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
@@ -8,6 +8,8 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.TestUiTimers
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionRef
@@ -35,8 +37,6 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import java.awt.event.HierarchyEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
@@ -95,7 +95,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
protected lateinit var app: KiloAppService
protected lateinit var workspaces: KiloWorkspaceService
protected lateinit var workspace: Workspace
protected lateinit var timers: TestUiTimers
private lateinit var coroutines: TestCoroutines
protected lateinit var scope: CoroutineScope
protected lateinit var parent: Disposable
@@ -104,8 +106,10 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
rpc = FakeSessionRpcApi()
appRpc = FakeAppRpcApi()
projectRpc = FakeWorkspaceRpcApi()
timers = TestUiTimers()
scope = CoroutineScope(SupervisorJob())
coroutines = TestCoroutines()
scope = coroutines.scope
parent = Disposer.newDisposable("test")
sessions = KiloSessionService(project, scope, rpc)
@@ -117,7 +121,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
coroutines.close { edt { UIUtil.dispatchAllInvocationEvents() } }
} finally {
super.tearDown()
}
@@ -169,6 +173,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
beforeUpdate = beforeUpdate,
afterUpdate = afterUpdate,
telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) },
timers = timers,
)
controllers.add(m)
roots[m] = root
@@ -222,33 +227,38 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
// ------ EDT + coroutine helpers ------
/** Let coroutines settle without forcing buffered controller delivery. */
protected fun settle() = runBlocking {
repeat(5) {
delay(100)
edt { UIUtil.dispatchAllInvocationEvents() }
}
/** Drain background coroutine and EDT work without forcing buffered controller delivery. */
protected fun settle() {
drain(false)
}
/** Let coroutines settle, force buffered controller delivery, then drain EDT. */
protected fun flush() = runBlocking {
repeat(5) {
delay(100)
edt {
controllers.forEach { it.flushEvents() }
UIUtil.dispatchAllInvocationEvents()
}
}
/** Drain background work, force buffered controller delivery, then drain EDT. */
protected fun flush() {
drain(true)
}
protected fun pause(ms: Long) = runBlocking {
val tick = 10L
repeat((ms / tick).coerceAtLeast(1).toInt()) {
delay(tick)
settleFast()
timers.advanceBy(ms)
settleFast()
}
private suspend fun settleFast() {
repeat(3) {
delay(1)
edt { UIUtil.dispatchAllInvocationEvents() }
}
}
private fun drain(force: Boolean) {
coroutines.drain {
edt {
if (force) controllers.forEach { it.flushEvents() }
UIUtil.dispatchAllInvocationEvents()
}
}
}
protected fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
@@ -404,11 +404,10 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
val sb = StringBuilder()
for (token in tokens) {
sb.append(token)
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = sb.toString())))
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", token))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = sb.toString())), flush = false)
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", token), flush = false)
flush()
}
settle()
flush()
val text = (m.model.message("msg1")!!.parts["prt1"] as ai.kilocode.client.session.model.Text).content.toString()
assertEquals(sb.toString(), text)
@@ -433,8 +433,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
repeat(12) { idx ->
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")), flush = false)
}
flush()
panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height)
panel.timelineViewport().doLayout()
panel.timelineViewport().viewPosition = Point(0, 0)
@@ -471,8 +472,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
repeat(12) { idx ->
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_touch_$idx", "bash", "running", "Touch $idx")))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_touch_$idx", "bash", "running", "Touch $idx")), flush = false)
}
flush()
panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height)
panel.timelineViewport().doLayout()
panel.timelineViewport().viewPosition = Point(0, 0)
@@ -505,8 +507,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
repeat(12) { idx ->
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_wheel_$idx", "bash", "running", "Wheel $idx")))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_wheel_$idx", "bash", "running", "Wheel $idx")), flush = false)
}
flush()
panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height)
panel.timelineViewport().doLayout()
panel.timelineViewport().viewPosition = Point(0, 0)
@@ -539,8 +542,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
repeat(12) { idx ->
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")), flush = false)
}
flush()
panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height)
panel.timelineViewport().doLayout()
panel.timelineViewport().viewPosition = Point(0, 0)
@@ -580,16 +584,17 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
edt { c.prompt("go") }
flush()
emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title")))
emit(ChatEventDto.MessageUpdated("ses_test", assistant()))
emit(ChatEventDto.PartUpdated("ses_test", reasoning(done = false, text = "Thinking")))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_1", "bash", "running", "Run tests", input = mapOf("cmd" to "test", "files" to "src"))))
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_2", "edit", "error", "Edit file", input = mapOf("cmd" to "test", "files" to "src"))))
emit(ChatEventDto.PartUpdated("ses_test", stepFinish()))
emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title")), flush = false)
emit(ChatEventDto.MessageUpdated("ses_test", assistant()), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", reasoning(done = false, text = "Thinking")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_1", "bash", "running", "Run tests", input = mapOf("cmd" to "test", "files" to "src"))), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", tool("tool_2", "edit", "error", "Edit file", input = mapOf("cmd" to "test", "files" to "src"))), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", stepFinish()), flush = false)
emit(ChatEventDto.TodoUpdated("ses_test", listOf(
TodoDto("Write tests", "completed", "high"),
TodoDto("Ship it", "pending", "medium"),
)))
)), flush = false)
flush()
return c
}
@@ -0,0 +1,39 @@
package ai.kilocode.client.testing
import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
class TestCoroutines {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val job = SupervisorJob()
val scope = CoroutineScope(job + dispatcher)
fun drain(pump: () -> Unit) {
repeat(5) {
await(scope.launch {}, pump)
pump()
}
}
fun close(pump: () -> Unit) {
job.cancel()
try {
await(job, pump)
} finally {
dispatcher.close()
}
}
private fun await(job: kotlinx.coroutines.Job, pump: () -> Unit) {
val end = System.nanoTime() + 5_000_000_000L
while (!job.isCompleted) {
check(System.nanoTime() < end) { "Timed out draining test coroutines" }
pump()
Thread.yield()
}
}
}
@@ -0,0 +1,81 @@
package ai.kilocode.client.testing
import ai.kilocode.client.util.UiTimer
import ai.kilocode.client.util.UiTimerSource
import com.intellij.openapi.application.ApplicationManager
class TestUiTimers : UiTimerSource {
private val timers = linkedSetOf<TestTimer>()
private var time = 0L
override fun now(): Long = time
override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer {
return TestTimer(ms.coerceAtLeast(0), repeats, action)
}
fun advanceBy(ms: Long) {
edt { }
time += ms.coerceAtLeast(0)
runDue()
}
fun runDue() {
while (true) {
val due = timers.filter { it.running && it.due <= time }
if (due.isEmpty()) return
due.forEach { it.fire() }
}
}
private fun edt(action: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
action()
return
}
app.invokeAndWait(action)
}
private inner class TestTimer(
private val ms: Int,
private val repeats: Boolean,
private val action: () -> Unit,
) : UiTimer {
var running = false
private set
var due = Long.MAX_VALUE
private set
override fun start() {
if (running) return
running = true
due = time + ms
timers.add(this)
}
override fun stop() {
running = false
due = Long.MAX_VALUE
timers.remove(this)
}
override fun restart() {
running = true
due = time + ms
timers.add(this)
}
override fun isRunning(): Boolean = running
fun fire() {
if (!running || due > time) return
if (repeats) {
due = time + ms.coerceAtLeast(1)
} else {
stop()
}
edt(action)
}
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ export function Diff<T>(props: DiffProps<T>) {
}
const perf = large() ? { ...base, ...largeOptions } : base
if (!mobile()) return perf
if (!mobile() || props.disableLineNumbers === false) return perf
return {
...perf,

Some files were not shown because too many files have changed in this diff Show More