mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support closing other Agent Manager tabs from the tab context menu.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Clean up incomplete worktrees when moving a session fails to transfer its Git changes.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent concurrent Kilo processes from reusing a ChatGPT Codex refresh token.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support VS Code switch links that select a Kilo model, agent, or both.
|
||||
@@ -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.
|
||||
@@ -3,7 +3,7 @@ name: containers
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main # kilocode_change
|
||||
paths:
|
||||
- packages/containers/**
|
||||
- .github/workflows/containers.yml
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||

|
||||
|
||||
<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/)
|
||||
|
||||
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
Vendored
-10
@@ -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 {}
|
||||
Vendored
-10
@@ -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 {}
|
||||
Regular → Executable
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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&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
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:29bb5361de5ac4b7acf247b8f9976c715c4e300448f6e504ec791566eca91219
|
||||
size 25750
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7ec87f1e046cf1a44688b952c0d994899119c6663bf627026688bf337b56127
|
||||
size 2411
|
||||
oid sha256:1370f2dcf5c50258971e0665ea151f2c522f566619745d58d6a10f3914949e6c
|
||||
size 2521
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7ee0856c65e445a230cf3e94bfe592c98306b7502e44a2602b5fac2f6d69937a
|
||||
size 4601
|
||||
oid sha256:b8af37e0c169811092437b71110c63571ae43c4d35e12f6890be8295a3cb67da
|
||||
size 4569
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f1ee2d880a055b03664d594598a6e8764ab27d443a578572fd01b37451cb0193
|
||||
size 3193
|
||||
oid sha256:16fdb6361199c58a8e99ad2661dc47791b0f4737ada16760151a26e7ebdd8bd1
|
||||
size 3044
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c1b7ab570623823e73afbbf2ab7d6b66ab16d948405ef9ced0a012441b70301
|
||||
size 25148
|
||||
oid sha256:e872bc8ad05ad2a7ad1dd6de8a2ff427957fd278cee46940db0b12d84fe42ecf
|
||||
size 25534
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce6ad833325a582ca6a2107c94f007c589816c149179fa9b317f060980e5e4fa
|
||||
size 20250
|
||||
oid sha256:ff33e800f4d2379bcc997228a1e4dd83433521b931ae85edb7c8f0c4c93405e7
|
||||
size 20540
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
|
||||
size 37836
|
||||
oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22
|
||||
size 48674
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
|
||||
size 37836
|
||||
oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22
|
||||
size 48674
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ced53d4974eeacc629eede823c7e37191f40ea2816f80f18c1de1a833fbcc614
|
||||
size 45341
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57fa5c60dd92a2f4299e58ead8a682c4802de415a496c247af2eba2faf0d63e4
|
||||
size 50341
|
||||
oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413
|
||||
size 53851
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
+80
-8
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
+19
-16
@@ -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
|
||||
|
||||
+7
-5
@@ -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)
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+7
-8
@@ -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>(
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+4
@@ -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() }
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+24
-18
@@ -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 {
|
||||
|
||||
+9
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+18
-22
@@ -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)
|
||||
}
|
||||
|
||||
+32
-22
@@ -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)
|
||||
}
|
||||
|
||||
+3
-4
@@ -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)
|
||||
|
||||
+16
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user