diff --git a/.changeset/agent-manager-stop-session.md b/.changeset/agent-manager-stop-session.md deleted file mode 100644 index d892ae27db..0000000000 --- a/.changeset/agent-manager-stop-session.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": minor -"kilo-code": minor ---- - -Allow agents to stop and remove a targeted Agent Manager session. diff --git a/.changeset/align-cli-usage.md b/.changeset/align-cli-usage.md deleted file mode 100644 index 4cfc10fe32..0000000000 --- a/.changeset/align-cli-usage.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Improve CLI sidebar usage sections with cent-formatted costs, collapsible details, and aligned model totals. diff --git a/.changeset/background-process-display.md b/.changeset/background-process-display.md deleted file mode 100644 index e43a4e9878..0000000000 --- a/.changeset/background-process-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Respect the Terminal Command Blocks display setting for background process tool cards. diff --git a/.changeset/calm-heredocs-explain.md b/.changeset/calm-heredocs-explain.md deleted file mode 100644 index 179aa51222..0000000000 --- a/.changeset/calm-heredocs-explain.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Display here-document content as plain text in terminal approval prompts. diff --git a/.changeset/chat-bidi-text.md b/.changeset/chat-bidi-text.md deleted file mode 100644 index 7f984c6eea..0000000000 --- a/.changeset/chat-bidi-text.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Support bidirectional text in chat messages so right-to-left and mixed-language conversations render in the correct direction. diff --git a/.changeset/cloud-session-import.md b/.changeset/cloud-session-import.md deleted file mode 100644 index 4d32b07923..0000000000 --- a/.changeset/cloud-session-import.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"@kilocode/kilo-gateway": patch ---- - -Fix Cloud Agent session imports in installed CLI builds and prevent malformed exports or write failures from leaving partial imports. diff --git a/.changeset/default-worktree-dialog.md b/.changeset/default-worktree-dialog.md deleted file mode 100644 index 0fd935c7e7..0000000000 --- a/.changeset/default-worktree-dialog.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Open the Agent Manager new-worktree dialog from primary actions and keyboard shortcuts, with an explicit quick-create action in its dropdown. diff --git a/.changeset/finish-deprecated-review-command.md b/.changeset/finish-deprecated-review-command.md deleted file mode 100644 index e4bf8725a6..0000000000 --- a/.changeset/finish-deprecated-review-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep sessions usable after deprecated local review commands complete without a session status transition. diff --git a/.changeset/indexing-file-extensions.md b/.changeset/indexing-file-extensions.md deleted file mode 100644 index c464e7a19a..0000000000 --- a/.changeset/indexing-file-extensions.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@kilocode/cli": minor -"@kilocode/kilo-indexing": minor -"kilo-code": minor ---- - -Configure a custom file extension allowlist for codebase indexing to limit scans to relevant project files and support additional text formats. diff --git a/.changeset/jetbrains-skills-settings.md b/.changeset/jetbrains-skills-settings.md new file mode 100644 index 0000000000..128e35207d --- /dev/null +++ b/.changeset/jetbrains-skills-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Support viewing, opening, editing, deleting, and configuring JetBrains skill sources. diff --git a/.changeset/refine-memory-controls.md b/.changeset/refine-memory-controls.md new file mode 100644 index 0000000000..97d452f7c2 --- /dev/null +++ b/.changeset/refine-memory-controls.md @@ -0,0 +1,8 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-memory": patch +"@kilocode/kilo-ui": patch +"kilo-code": patch +--- + +Simplify project memory settings and activity visibility, replace direct editing with folder inspection, add nested memory slash-command completion and status views, improve empty-project handling, compact native tool-call summaries, and remove legacy memory audit logs. diff --git a/.changeset/remote-embedders-retry-timeouts.md b/.changeset/remote-embedders-retry-timeouts.md deleted file mode 100644 index ab0db53a16..0000000000 --- a/.changeset/remote-embedders-retry-timeouts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-indexing": patch ---- - -Allow remote embedder validation timeouts to retry before indexing reports a connection failure. diff --git a/.changeset/remote-session-slash-commands.md b/.changeset/remote-session-slash-commands.md deleted file mode 100644 index eb48e0c938..0000000000 --- a/.changeset/remote-session-slash-commands.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Remote CLI: expose slash command discovery, execution, and `/new` session creation over the relay. `list_commands` and `send_command` (including the built-in `compact` flow) are scoped to the current session's directory. `create_session` creates a root session in that directory, attaches it to the relay heartbeat set, and returns the new session id only after the heartbeat completes so the mobile client can navigate immediately. Failures are sanitized; the command is not auto-retried and the user may retry manually after a transient relay failure. diff --git a/.changeset/reset-diff-scroll.md b/.changeset/reset-diff-scroll.md new file mode 100644 index 0000000000..c5efe834f8 --- /dev/null +++ b/.changeset/reset-diff-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix scroll position not resetting when switching between diff files in the chat and virtual diff viewer diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f07f6b4cf..1575f61ab5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,7 +160,7 @@ jobs: if: matrix.settings.run run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='@kilocode/cli' env: - KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in. KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }} KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }} # kilocode_change end diff --git a/.kilo/plans/jetbrains-context-settings-page.md b/.kilo/plans/jetbrains-context-settings-page.md new file mode 100644 index 0000000000..1b76e7b6ce --- /dev/null +++ b/.kilo/plans/jetbrains-context-settings-page.md @@ -0,0 +1,423 @@ +# JetBrains Context Settings Page + +Implement the Tier 1 Context settings from `docs/jetbrains-vscode-settings-parity.md` in the JetBrains plugin. This is a pure `kilo.json` settings UI: no CLI feature work, no SDK regen, and no session-rendering changes. + +## Goal + +Add a new JetBrains settings page under `Settings -> Tools -> Kilo Code -> Context` for: + +| Setting | Config key | Type | +|---|---|---| +| Auto-compaction | `compaction.auto` | boolean | +| Compaction threshold percent | `compaction.threshold_percent` | number or null | +| Prune on compaction | `compaction.prune` | boolean | +| Watcher ignore patterns | `watcher.ignore` | string array | + +Do not include VS Code Context-tab memory/indexing controls in this first pass. JetBrains does not have the equivalent memory/indexing settings service yet, and the parity doc excludes indexing from easy wins. + +Do not put `snapshot` on this page unless product explicitly decides to combine Context and Checkpoints. The parity doc suggests `snapshot` belongs on a new Checkpoints page. + +## Context Verified + +- Source parity doc: `docs/jetbrains-vscode-settings-parity.md`. +- JetBrains settings guidance: `packages/kilo-jetbrains/AGENTS.md`, especially `Settings UI`. +- Existing settings pages are registered in `packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml`. +- Existing page pattern to mirror: + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt` + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt` + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt` +- Existing global config write path is sufficient once DTO/parser support is added: + - Frontend: `KiloAppService.updateConfigAsync(...)` + - RPC: `KiloAppRpcApi.updateConfig(patch: ConfigPatchDto)` + - Backend: `KiloBackendAppService.updateConfig(...)` + - HTTP: `PATCH /global/config`, then `GET /global/config` +- Existing backend parser currently only serializes selected string keys from `ConfigPatchDto.values`; Context needs typed booleans, numbers, explicit null, and string arrays. + +## Decisions + +- Use global config for the first implementation, matching the existing app-level settings write path. +- Add typed DTO fields instead of overloading `ConfigPatchDto.values` for non-string values. +- Use an explicit `clear` list for nullable compaction fields, because `Double?` cannot distinguish absent from explicit `null`. +- Reuse `BaseSettingsUi`, `DraftReadyConfigurable`, `SettingsDraftState`, `SettingsRows`, `SettingsRow`, and `SettingsToggle`. +- Use the shared settings list primitives for `watcher.ignore`; do not build a bespoke add/remove list if `SettingsListPanel` or adjacent list primitives fit. +- Keep all UI strings in `KiloBundle.properties`. Let other locale bundles fall back unless the repo's resource-bundle checks require duplicated English keys. + +## Part A - Shared DTOs + +File: `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt` + +Add config read DTOs: + +```kotlin +@Serializable +data class WatcherConfigDto( + val ignore: List = emptyList(), +) + +@Serializable +data class CompactionConfigDto( + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) +``` + +Extend `ConfigDto`: + +```kotlin +val watcher: WatcherConfigDto? = null, +val compaction: CompactionConfigDto? = null, +``` + +Add patch DTOs: + +```kotlin +@Serializable +data class WatcherPatchDto( + val ignore: List? = null, +) + +@Serializable +data class CompactionPatchDto( + val clear: List = emptyList(), + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) +``` + +Extend `ConfigPatchDto`: + +```kotlin +val watcher: WatcherPatchDto? = null, +val compaction: CompactionPatchDto? = null, +``` + +Notes: + +- `watcher.ignore = null` means no change. +- `watcher.ignore = emptyList()` means explicitly save an empty list. +- `compaction.threshold_percent = null` alone means no change. +- `compaction.clear = listOf("threshold_percent")` means emit JSON `"threshold_percent": null`. +- `false` boolean values must be serialized; do not treat `false` as absent. + +## Part B - Backend Config Parser And Serializer + +File: `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt` + +### Parse + +Extend `parseConfig(raw)` to read: + +- `watcher.ignore` +- `compaction.auto` +- `compaction.threshold_percent` +- `compaction.prune` + +Add private helpers near `parseSkillsConfig` / `parseMcpConfig`: + +```kotlin +private fun parseWatcherConfig(obj: JsonObject?): WatcherConfigDto? +private fun parseCompactionConfig(obj: JsonObject?): CompactionConfigDto? +``` + +Use existing helper style: + +- strings: `str(...)` +- booleans: `flagOrNull(...)` +- numbers: `num(...)` +- arrays: `arr()?.mapNotNull { it.jsonPrimitive.contentOrNull }` + +### Serialize + +Extend `buildConfigPatch(patch)` to emit typed context patches: + +```json +{ + "watcher": { + "ignore": ["**/node_modules/**"] + }, + "compaction": { + "auto": true, + "threshold_percent": 80, + "prune": false + } +} +``` + +For explicit threshold clearing: + +```json +{ + "compaction": { + "threshold_percent": null + } +} +``` + +Keep the existing `values` allowlist for string model keys. Do not pass Context values through `values`. + +## Part C - Frontend State Model + +Add package: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/` + +New file: `ContextSettingsState.kt` + +Define: + +```kotlin +internal data class ContextDraft( + val auto: Boolean? = null, + val threshold: String = "", + val prune: Boolean? = null, + val ignore: List = emptyList(), +) +``` + +Use a string for the threshold draft so the UI can represent blank/invalid intermediate input without losing user text. Convert only when building a patch. + +Functions to add: + +- `contextDraft(config: ConfigDto?): ContextDraft` +- `patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto` +- `savedMatches(base: ContextDraft, draft: ContextDraft): Boolean` +- `threshold(value: String): Double?` or equivalent parsing helper +- validation helper for threshold range if desired + +Patch behavior: + +- Only emit changed fields. +- Emit `CompactionPatchDto(auto = false)` when the user turns auto-compaction off. +- Emit `CompactionPatchDto(prune = false)` when the user turns pruning off. +- Emit `CompactionPatchDto(threshold_percent = 80.0)` for a non-blank valid number. +- Emit `CompactionPatchDto(clear = listOf("threshold_percent"))` when an existing threshold is cleared. +- Emit `WatcherPatchDto(ignore = emptyList())` when the last ignore pattern is removed. +- Return no change from the page when all fields match the baseline. + +## Part D - Frontend UI Page + +New file: `ContextConfigurable.kt` + +Mirror `ModelsConfigurable`: + +- Extend `DraftReadyConfigurable`. +- `ID = "ai.kilocode.jetbrains.settings.context"`. +- `getDisplayName()` returns `KiloBundle.message("settings.context.displayName")`. +- `create(cs)` returns `ContextSettingsUi(cs)`. + +New file: `ContextSettingsUi.kt` + +Mirror the simple parts of `ModelsSettingsUi`: + +- Extend `BaseSettingsUi`. +- Initial draft is `ContextDraft()`. +- `save(change, done)` calls `app.updateConfigAsync(change, done)`. +- `base(result)` and `draft(state)` call `contextDraft(state.config)`. +- `saved(base, draft)` calls `savedMatches(base, draft)`. +- `pendingText()` uses `settings.context.save.pending`. +- `failedText()` uses `settings.context.save.failed`. +- `loadWorkspace(root)` returns `Unit`; `applyWorkspace(result)` is `Unit`. +- `models(state)` is `Unit`. +- `syncContent()` updates enabled states, field values, save/progress overlay, and validation messaging. + +New content class: `ContextSettingsContent` + +Suggested layout: + +- Section `settings.context.compaction.title` + - Toggle row `settings.context.compaction.auto.title` + - Numeric row `settings.context.compaction.threshold.title` + - Toggle row `settings.context.compaction.prune.title` +- Section `settings.context.watcher.title` + - List editor row/panel for ignore patterns + +Controls: + +- Use `SettingsToggle` for booleans. +- Use `JBTextField` or a small reusable numeric field pattern based on `AgentEditDialog` for threshold. +- Use shared list primitives (`SettingsListPanel` / `SettingsListView` / `SettingsListItem` / `SettingsListCell`) for `watcher.ignore` where practical. +- Keep the page editable while app status is ready and no save is pending. +- Disable controls while saving. + +Validation: + +- Blank threshold is valid and means clear/reset the config value if it differs from baseline. +- Non-numeric threshold is invalid and should prevent `apply()` from sending a patch. +- Suggested accepted range is `0..100`; if existing CLI allows a broader range, follow CLI behavior. +- Show validation through existing settings messaging rather than custom ad hoc labels. + +## Part E - Settings Registration And Root Navigation + +File: `packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml` + +Add a child configurable: + +```xml + +``` + +Adjust weights so the desired order is stable. Recommended order: + +| Page | Weight | +|---|---| +| User Profile | 5 | +| Models | 4 | +| Context | 3 | +| Providers | 2 | +| Agent Behavior | 1 | + +File: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt` + +Add a root-page `ActionLink` for Context between Models and Providers: + +- Import `ContextConfigurable`. +- Link text: `settings.context.displayName`. +- Link target: `ContextConfigurable.ID`. + +`KiloSettingsSelection.kt` probably needs no code changes because child IDs already share the root prefix. + +## Part F - Strings + +File: `packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties` + +Add base strings near the other settings strings: + +```properties +settings.context.displayName=Context +settings.context.description=Configure compaction and file-watcher context behavior. +settings.context.save.pending=Saving context settings... +settings.context.save.failed=Failed to save context settings +settings.context.compaction.title=Compaction +settings.context.compaction.description=Control when Kilo summarizes long sessions to reduce context usage. +settings.context.compaction.auto.title=Auto-compaction +settings.context.compaction.auto.description=Automatically compact long conversations before they exceed the model context window. +settings.context.compaction.threshold.title=Compaction threshold +settings.context.compaction.threshold.description=Percent of the context window to use before auto-compaction starts. Leave blank to use the default. +settings.context.compaction.threshold.invalid=Enter a number from 0 to 100, or leave the field blank. +settings.context.compaction.prune.title=Prune on compaction +settings.context.compaction.prune.description=Drop older raw conversation details after compaction to keep the session context smaller. +settings.context.watcher.title=Watcher ignore patterns +settings.context.watcher.description=Glob patterns Kilo should ignore when watching repository file changes. +settings.context.watcher.add=Add pattern +settings.context.watcher.empty=No ignore patterns configured. +settings.context.watcher.placeholder=e.g. **/dist/** +settings.context.watcher.remove=Remove {0} +``` + +If resource-bundle tests require every key in every locale bundle, copy English values into the localized bundles and leave translation work for a later i18n pass. + +## Part G - Test Updates + +### Frontend state tests + +Add: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt` + +Cover: + +- Draft reads `ConfigDto.watcher` and `ConfigDto.compaction`. +- Unchanged draft emits no patch. +- Boolean changes emit `false` and `true` correctly. +- Threshold set emits `threshold_percent`. +- Threshold clear emits `clear = listOf("threshold_percent")`. +- Watcher list add/remove emits the whole new `ignore` list, including empty list. +- Invalid threshold is rejected before save if validation lives in state helpers. + +### Frontend UI tests + +Add: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt` + +Use `ModelsSettingsUiTest` as the main pattern: + +- `BasePlatformTestCase`. +- Real EDT. +- `FakeAppRpcApi`. +- `KiloAppService`. +- `flushUntil` helpers. +- Assert `rpc.configPatches` after user interaction. +- Assert controls disable during pending save. +- Assert failed save leaves page modified and shows `settings.context.save.failed`. + +Update `FakeAppRpcApi`: + +- File: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt` +- Apply `patch.watcher` and `patch.compaction` to fake config state. +- Preserve explicit empty lists. +- Preserve boolean `false`. +- Honor `compaction.clear` by setting cleared fields to `null`. + +### Backend parser tests + +Update: `packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt` + +Add exact JSON tests for: + +- `parseConfig` reads watcher and compaction fields. +- `buildConfigPatch` emits watcher ignore arrays. +- `buildConfigPatch` emits `auto=false` and `prune=false`. +- `buildConfigPatch` emits numeric `threshold_percent`. +- `buildConfigPatch` emits explicit `threshold_percent:null` when `clear` includes the field. + +### Backend app service tests + +Update: `packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt` + +Add a test similar to the existing model config update test: + +- Call `updateConfig(ConfigPatchDto(watcher = ..., compaction = ...))`. +- Assert `MockCliServer.lastConfigPatchBody` exactly matches the expected nested JSON. +- Assert the returned/reloaded `ConfigDto` includes the saved Context values. + +### Root settings tests + +Update: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt` + +Add: + +- `ContextConfigurable.ID == "ai.kilocode.jetbrains.settings.context"`. +- Root page includes a Context link. +- Link order matches XML order. + +## Validation + +Run from `packages/kilo-jetbrains/`: + +```bash +./gradlew typecheck +./gradlew test +``` + +Focused checks while iterating: + +```bash +./gradlew :shared:test --tests '*ContextSettingsStateTest' +./gradlew :frontend:test --tests '*ContextSettingsUiTest' +./gradlew :backend:test --tests '*KiloCliDataParserTest' +./gradlew :backend:test --tests '*KiloBackendAppServiceTest' +``` + +If the exact Gradle module test selectors differ, run the package-level `./gradlew test` before marking the implementation ready. + +Manual verification: + +1. Run `./gradlew runIde` from `packages/kilo-jetbrains/`. +2. Open `Settings -> Tools -> Kilo Code -> Context`. +3. Toggle auto-compaction and prune. +4. Set threshold to a number, apply, reopen settings, and verify it persists. +5. Clear threshold, apply, reopen settings, and verify it resets. +6. Add and remove watcher ignore patterns, apply, reopen settings, and verify the list persists. +7. Inspect the global Kilo config file through the existing `Open: global ...` action if needed. + +## Risks And Follow-ups + +- Global vs project-local config: this plan uses the existing global config write path. Project-local Context settings would need new workspace config RPC plumbing. +- Threshold null semantics: implement explicit clear handling; otherwise clearing the field will silently do nothing. +- String-array UI: reuse list primitives even if it takes a small adapter type; avoid one-off list widgets. +- VS Code memory/indexing parity: defer because it is not pure config and is excluded by the easy-win criteria. +- Checkpoints page: implement `snapshot` separately unless product asks to combine it with Context. +- Changeset: when implementing this user-facing JetBrains settings feature, add a patch changeset for `kilo-code`/JetBrains according to repo release guidance. diff --git a/bun.lock b/bun.lock index 932d1ada0b..d87df9a65a 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.11", + "version": "7.4.13", "bin": { "opencode": "./bin/opencode", }, @@ -127,7 +127,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -141,7 +141,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "effect": "catalog:", }, @@ -153,7 +153,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -174,7 +174,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -197,7 +197,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -227,7 +227,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -263,7 +263,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.11", + "version": "7.4.13", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -273,7 +273,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -309,7 +309,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -323,7 +323,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -338,7 +338,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -352,7 +352,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -389,7 +389,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -458,7 +458,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -475,7 +475,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -493,7 +493,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.11", + "version": "7.4.13", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -660,7 +660,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -688,7 +688,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -702,7 +702,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "semver": "^7.6.3", }, @@ -713,7 +713,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -728,7 +728,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -742,7 +742,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.11", + "version": "7.4.13", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -765,7 +765,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -792,7 +792,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.11", + "version": "7.4.13", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -844,23 +844,21 @@ }, }, "trustedDependencies": [ - "tree-sitter-powershell", - "web-tree-sitter", - "tree-sitter-bash", "esbuild", "protobufjs", + "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@ai-sdk/xai@3.0.92": "patches/@ai-sdk%2Fxai@3.0.92.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/docs/jetbrains-vscode-settings-parity.md b/docs/jetbrains-vscode-settings-parity.md new file mode 100644 index 0000000000..fe55973eed --- /dev/null +++ b/docs/jetbrains-vscode-settings-parity.md @@ -0,0 +1,87 @@ +# JetBrains ↔ VS Code Settings Parity: Easy Wins + +## How parity works here + +Both clients edit the **same shared `kilo.json`** through the CLI. So any setting whose +behavior lives entirely in the CLI is an "easy win" for JetBrains: the CLI already does the +work, JetBrains just needs a UI row that writes the config key. No CLI changes, no new feature. + +Structural gap: today JetBrains only has **Models / Providers / Agent Behavior / Profile** +settings pages. There is **no General / Display / Experimental / Context / Checkpoints** page. +The lift for most easy wins is: + +1. Add a new `Configurable` page (using existing `settings/base/` primitives — + `BaseSettingsUi`, `SettingsRow`, `SettingsToggle`, `SettingsListPanel`), register it in + `kilo.jetbrains.frontend.xml`. +2. Extend the `buildConfigPatch` allowlist in `KiloCliDataParser.kt` (currently only + `model`, `small_model`, `subagent_model`, `subagent_variant`, `default_agent`) and add + boolean/number JSON serialization — it currently only emits strings. +3. Add localized labels to `KiloBundle.properties`. + +No CLI/SDK change and no new runtime feature. + +## Excluded from "easy" + +| Excluded | Reason | +|---|---| +| Agent Behavior, Auto-Approve | Skipped by request | +| Indexing, Sandboxing | Imply enabling new features | +| Browser Automation | Playwright feature not present in JetBrains | +| Autocomplete (provider/model/toggles) | No autocomplete feature (flags exist only as migration stubs) | +| Agent Manager (auto-branch, prefix) | VS Code-only feature | +| Notification/attention sounds | Client must implement sound playback | +| `maxCost` alert | Client must render the alert UI | +| Commit message (`commit_message.prompt`, `languageCommitMessage`) | No commit-message generation feature in JetBrains | +| `language`, `fontSize`, `diff.renderMarkdown`, `agentWorkStyle` | VS Code-webview/onboarding-specific | + +## Tier 1 — Genuinely easy (CLI does all the work; just add UI + config key) + +| Setting | Config key | Type | Suggested page | +|---|---|---|---| +| Hide prompt-training models | `hide_prompt_training_models` | bool | Models | +| Enable checkpoints | `snapshot` | bool | new "Checkpoints" | +| Auto-compaction | `compaction.auto` | bool | new "Context" | +| Compaction threshold % | `compaction.threshold_percent` | number | Context | +| Prune on compaction | `compaction.prune` | bool | Context | +| Watcher ignore patterns | `watcher.ignore` | string[] | Context (list editor) | +| Display username | `username` | string | new "Display/General" | +| Share mode | `share` | enum (manual/auto/disabled) | new "Experimental" | +| Remote control on startup | `remote_control` | bool | Experimental | +| Formatter integration | `formatter` | bool | Experimental | +| LSP integration | `lsp` | bool | Experimental | +| Batch tool | `experimental.batch_tool` | bool | Experimental | +| Native notebook tools | `experimental.native_notebook_tools` | bool | Experimental | +| Continue loop on deny | `experimental.continue_loop_on_deny` | bool | Experimental | +| SWE pruner (+ model) | `experimental.swe_pruner`, `..._model` | bool + string | Experimental | +| MCP timeout | `experimental.mcp_timeout` | number | Experimental | +| Per-tool toggles | `tools.` | bool | Experimental | + +**Claude Code compatibility**: lives under "Agent Behavior" in VS Code, but in JetBrains the +entire backend (`KiloClaudeCompatSettings` + RPC getter/setter + spawn-env wiring) already +exists with no UI. Exposing it is the single lowest-effort item — just a checkbox bound to the +existing RPC, no config plumbing. + +⚠️ Hold back `experimental.codebase_search` (leans on indexing) and +`experimental.image_generation` (adds a tool) — arguably "enabling a feature." + +## Tier 2 — Config is easy, but honoring it needs JetBrains rendering work + +| Setting | Config key | Extra work | +|---|---|---| +| Auto-collapse reasoning | `auto_collapse_reasoning` | Reasoning-card default collapse | +| Terminal command display | `terminal_command_display` (expanded/collapsed) | Tool-card default state | +| Code edit display | `code_edit_display` (expanded/collapsed) | Edit-card default state | + +## Recommendation + +Add a new **"General/Display" page + "Experimental" page** driven entirely by CLI config, +seeded with Tier 1 behavioral settings, plus wire the already-built **Claude Code compat** +toggle. This closes most of the non-feature gap with: + +- zero CLI/SDK changes, +- one allowlist extension in `KiloCliDataParser.buildConfigPatch` (add keys + boolean/number serialization), +- reuse of existing `settings/base/` UI primitives and test patterns (`FakeAppRpcApi` + frontend test + `MockCliServer` backend body assertion). + +Do Tier 2 (reasoning/terminal/edit display defaults) after Tier 1, since it touches the +session-rendering layer rather than being pure config. diff --git a/package.json b/package.json index 634b06aa4a..e489015ad5 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.11", + "version": "7.4.13", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index bcf809158b..d098834e43 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.11", + "version": "7.4.13", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index b951eea34d..68af4065ac 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -555,6 +555,7 @@ const SessionRevert = Schema.Struct({ partID: optionalOmitUndefined(PartID), snapshot: optionalOmitUndefined(Schema.String), diff: optionalOmitUndefined(Schema.String), + workspace: optionalOmitUndefined(Schema.Literals(["restored", "snapshots-disabled", "unavailable"])), // kilocode_change }) const SessionModel = Schema.Struct({ diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 344ea15818..f86239c9a7 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.11", + "version": "7.4.13", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 8e3e2fecc7..feeceb9b70 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.11", + "version": "7.4.13", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7f85174b31..60064f526e 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.11" +version = "7.4.13" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.13/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.13/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.13/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.13/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.13/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 0cbdb4bcce..840ef84bc5 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.11", + "version": "7.4.13", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index e74bb203fa..8b1acff374 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.11", + "version": "7.4.13", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 8af9d791d0..22005b3436 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.11", + "version": "7.4.13", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png deleted file mode 100644 index f7283e81eb..0000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-200-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6a89d1a9a31f7de1780d772b7caf71d96f0b4b895df954c77dcd508a7febe7c9 -size 1950 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png deleted file mode 100644 index ca5db9f648..0000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:431a0c12cb844b5d4e535cc4c7bf5546fd9e66a178b8023b00978d07c291a531 -size 3919 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index cd9e5cc0ec..c64a23bfc5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42c43e9ba12bc73da98c915dafca274beee1d0c893c168d8af4e3e3f41edd367 -size 630896 +oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c +size 658647 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index be62870f8c..9dabb917b0 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 5c1d4b24f9..eccf90afe9 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -97,11 +97,6 @@ export async function fetchKiloModels(options?: { const models: Record = {} for (const model of raw.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { - continue - } - // Skip models that don't support tools — Kilo requires tool calling if (!model.supported_parameters?.includes("tools")) { continue diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 9bf34793b1..656d27eaf0 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -248,3 +248,85 @@ test("returns error with kind=schema when response body is invalid JSON", async expect(result.models).toEqual({}) expect(result.error?.kind).toBe("schema") }) + +const MIXED_MODALITY_RESPONSE = JSON.stringify({ + data: [ + { + id: "openrouter/auto", + name: "Auto Router", + context_length: 2000000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text", "image"], + }, + supported_parameters: ["tools", "temperature"], + }, + { + id: "openrouter/auto-beta", + name: "Auto Router (Beta)", + context_length: 2000000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text", "image"], + }, + supported_parameters: ["tools", "temperature"], + }, + { + id: "black-forest-labs/flux-1.1-pro", + name: "FLUX 1.1 Pro", + context_length: 4096, + max_completion_tokens: 4096, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["image"], + }, + supported_parameters: ["tools"], + }, + { + id: "test/no-tools", + name: "No Tools Model", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["temperature"], + }, + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + }, + ], +}) + +test("keeps image-output models with tools and drops models without tools", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response(MIXED_MODALITY_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["openrouter/auto"]).toBeDefined() + expect(result.models["openrouter/auto-beta"]).toBeDefined() + expect(result.models["black-forest-labs/flux-1.1-pro"]).toBeDefined() + expect(result.models["test/model-a"]).toBeDefined() + expect(result.models["test/no-tools"]).toBeUndefined() +}) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 59bbc97467..1e948a37f1 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 2a48d919ca..fc5d092bb1 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index ab9bd7ff27..404f18168b 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 7.4.12 + +### Patch Changes + +- [#12191](https://github.com/Kilo-Org/kilocode/pull/12191) [`4d676b6`](https://github.com/Kilo-Org/kilocode/commit/4d676b68d2d0dd025c7d1a6684f49f3d03e9d12d) - Use Kilo Core for JetBrains @ file completion. + ## 7.4.10 ### Patch Changes diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 5bbfae2186..0318e49d11 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -820,11 +820,13 @@ class KiloBackendAppService private constructor( } } "global.disposed" -> { + logSessionDisposalRisk("global.disposed") log.info("SSE global.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() } "server.instance.disposed" -> { + logSessionDisposalRisk("server.instance.disposed") log.info("SSE server.instance.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() @@ -835,6 +837,12 @@ class KiloBackendAppService private constructor( } } + private fun logSessionDisposalRisk(event: String) { + val active = sessions.statuses.value.filterValues { it.type != "idle" } + if (active.isEmpty()) return + log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + } + private suspend fun clear() { synchronized(loadLock) { val jobs = listOfNotNull(loader, eventWatcher) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index f5b9fc4f71..f48dbf9038 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -86,6 +86,10 @@ class KiloBackendSessionManager( } fun stop() { + val active = _statuses.value.filterValues { it.type != "idle" } + if (active.isNotEmpty()) { + log.warn("Session manager stopping with active sessions count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + } watcher?.cancel() watcher = null client = null diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index fee794849b..63f15c05ec 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -20,6 +20,7 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderSaveDto @@ -73,6 +74,7 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.ToolRefDto +import ai.kilocode.rpc.dto.WatcherConfigDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -521,6 +523,8 @@ object KiloCliDataParser { subagentModel = obj.str("subagent_model"), subagentVariant = obj.str("subagent_variant"), defaultAgent = obj.str("default_agent"), + watcher = parseWatcherConfig(obj["watcher"].obj()), + compaction = parseCompactionConfig(obj["compaction"].obj()), instructions = obj["instructions"].arr() ?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } ?: emptyList(), @@ -530,6 +534,24 @@ object KiloCliDataParser { ) }.getOrDefault(ConfigDto()) + private fun parseWatcherConfig(obj: JsonObject?): WatcherConfigDto? { + if (obj == null) return null + return WatcherConfigDto( + ignore = obj["ignore"].arr() + ?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } + ?: emptyList(), + ) + } + + private fun parseCompactionConfig(obj: JsonObject?): CompactionConfigDto? { + if (obj == null) return null + return CompactionConfigDto( + auto = runCatching { obj.flagOrNull("auto") }.getOrNull(), + threshold_percent = runCatching { obj.num("threshold_percent") }.getOrNull(), + prune = runCatching { obj.flagOrNull("prune") }.getOrNull(), + ) + } + private fun parseSkillsConfig(obj: JsonObject?): SkillsConfigDto? { if (obj == null) return null return SkillsConfigDto( @@ -625,7 +647,12 @@ object KiloCliDataParser { val obj = item.obj() ?: return@mapNotNull null val name = obj.str("name") ?: return@mapNotNull null val location = obj.str("location") ?: return@mapNotNull null - SkillDto(name = name, description = obj.str("description"), location = location) + SkillDto( + name = name, + description = obj.str("description"), + location = location, + content = obj.str("content"), + ) } fun parseAgentBehaviorCommands(raw: String): List = @@ -838,6 +865,24 @@ object KiloCliDataParser { val instructions = patch.instructions if (instructions != null) put("instructions", JsonArray(instructions.map(::JsonPrimitive))) + val watcher = patch.watcher + if (watcher != null) { + put("watcher", buildJsonObject { + val ignore = watcher.ignore + if (ignore != null) put("ignore", JsonArray(ignore.map(::JsonPrimitive))) + }) + } + + val compaction = patch.compaction + if (compaction != null) { + put("compaction", buildJsonObject { + for (field in compaction.clear) put(field, JsonNull) + if (compaction.auto != null) put("auto", compaction.auto) + if (compaction.threshold_percent != null) put("threshold_percent", compaction.threshold_percent) + if (compaction.prune != null) put("prune", compaction.prune) + }) + } + val skills = patch.skills if (skills != null) { put("skills", buildJsonObject { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index d728a6b6aa..95185f2f20 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.PermissionRuleItemDto +import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,7 +23,11 @@ import kotlinx.serialization.json.JsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import com.intellij.openapi.util.SystemInfo import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path import java.nio.charset.StandardCharsets import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger @@ -33,6 +38,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = private val JSON = "application/json".toMediaType() private val saved = ConcurrentHashMap() private val port = AtomicInteger(-1) + private val extensions = setOf("md", "markdown", "txt", "text", "html", "htm") } private val app: KiloBackendAppService get() = backend ?: service() @@ -56,11 +62,59 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = } } - override suspend fun skills(directory: String) = KiloCliDataParser.parseAgentBehaviorSkills(request(directory, "/skill", null)) + override suspend fun skills(directory: String): List { + val items = KiloCliDataParser.parseAgentBehaviorSkills(request(directory, "/skill", null)) + return items.map { item -> + val editable = editable(item) + item.copy(content = skillContent(item) ?: item.content, editable = editable) + } + } override suspend fun removeSkill(directory: String, location: String): Boolean = post(directory, "/kilocode/skill/remove", JsonObject(mapOf("location" to JsonPrimitive(location)))) + override suspend fun reloadSkills(directory: String): Boolean { + LOG.info("Skills reload requested dir=$directory") + if (hasActiveSession(directory)) { + LOG.warn("Skills reload blocked by active session dir=$directory") + return false + } + runCatching { post(directory, "/instance/reload") }.onFailure { err -> + LOG.warn("Skills reload failed dir=$directory", err) + }.getOrThrow() + LOG.info("Skills reload succeeded dir=$directory") + return true + } + + override suspend fun saveSkill(directory: String, location: String, content: String): Boolean { + LOG.info("Skill save requested dir=$directory location=$location") + app.requireReady() + val paths = knownSkills(directory) + val path = writablePath(directory, location, paths) ?: return false + withContext(Dispatchers.IO) { + Files.writeString(path, content, StandardCharsets.UTF_8) + } + LOG.info("Skill file saved dir=$directory path=$path bytes=${content.toByteArray(StandardCharsets.UTF_8).size}") + LOG.info("Skill save reload deferred dir=$directory path=$path") + return true + } + + override suspend fun saveSkills(directory: String, edits: Map): Boolean { + LOG.info("Skills save requested dir=$directory count=${edits.size}") + app.requireReady() + val known = knownSkills(directory) + val paths = edits.mapNotNull { (location, content) -> + val path = writablePath(directory, location, known) ?: return false + path to content + } + withContext(Dispatchers.IO) { + for ((path, content) in paths) Files.writeString(path, content, StandardCharsets.UTF_8) + } + LOG.info("Skill files saved dir=$directory count=${paths.size}") + LOG.info("Skills save reload deferred dir=$directory count=${paths.size}") + return true + } + override suspend fun removeAgent(directory: String, name: String): Boolean = post(directory, "/kilocode/agent/remove", JsonObject(mapOf("name" to JsonPrimitive(name)))) @@ -139,6 +193,104 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return true } + private fun hasActiveSession(directory: String): Boolean { + val active = app.sessions.statuses.value.filterValues { it.type != "idle" } + if (active.isNotEmpty()) { + LOG.info("Skills reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}") + return true + } + val permissions = runCatching { app.chat.pendingPermissions(directory) }.onFailure { err -> + LOG.warn("Skills reload pending permission check failed dir=$directory", err) + }.getOrDefault(emptyList()) + if (permissions.isNotEmpty()) { + LOG.info("Skills reload pending permissions dir=$directory count=${permissions.size}") + return true + } + val questions = runCatching { app.chat.pendingQuestions(directory) }.onFailure { err -> + LOG.warn("Skills reload pending question check failed dir=$directory", err) + }.getOrDefault(emptyList()) + if (questions.isNotEmpty()) { + LOG.info("Skills reload pending questions dir=$directory count=${questions.size}") + return true + } + return false + } + + private suspend fun skillContent(skill: SkillDto): String? { + val path = resolveSkillPath(skill.location) ?: return null + return runCatching { + withContext(Dispatchers.IO) { + if (!Files.isRegularFile(path)) null else Files.readString(path, StandardCharsets.UTF_8) + } + }.onFailure { err -> + LOG.warn("Skill content read failed: $path", err) + }.getOrNull() + } + + private fun editable(skill: SkillDto): Boolean { + val path = resolveSkillPath(skill.location) ?: return false + if (urlCached(path)) return false + return true + } + + private suspend fun knownSkills(directory: String): Set { + val items = KiloCliDataParser.parseAgentBehaviorSkills(request(directory, "/skill", null)) + return items.mapNotNull { item -> resolveEditablePath(item) }.toSet() + } + + private fun writablePath(directory: String, location: String, known: Set): Path? { + val path = resolveSkillPath(location) + if (path == null) { + LOG.warn("Skill save rejected: invalid location dir=$directory location=$location") + return null + } + if (path !in known) { + LOG.warn("Skill save rejected: unknown skill dir=$directory path=$path") + return null + } + return path + } + + private fun resolveEditablePath(skill: SkillDto): Path? { + val path = resolveSkillPath(skill.location) ?: return null + if (urlCached(path)) return null + return path + } + + private fun resolveSkillPath(location: String): Path? { + val raw = normalizeWorkspacePath(location) ?: return null + val path = try { + Path.of(raw).normalize() + } catch (_: InvalidPathException) { + return null + } + if (!path.isAbsolute || !isSkillFile(path)) return null + return path + } + + private fun urlCached(path: Path): Boolean { + return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) } + } + + private fun cacheRoots(): Set = buildSet { + val home = System.getProperty("user.home") + add(Path.of(cacheRoot()).normalize()) + add(Path.of(home, ".cache").normalize()) + add(Path.of(home, "Library", "Caches").normalize()) + System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }?.let { add(Path.of(it).normalize()) } + add(Path.of(home, "AppData", "Local").normalize()) + } + + private fun cacheRoot(): String { + val xdg = System.getenv("XDG_CACHE_HOME")?.takeIf { it.isNotBlank() } + if (xdg != null) return xdg + val home = System.getProperty("user.home") + if (SystemInfo.isMac) return Path.of(home, "Library", "Caches").toString() + if (SystemInfo.isWindows) return System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() } + ?: Path.of(home, "AppData", "Local").toString() + return Path.of(home, ".cache").toString() + } + private suspend fun patchConfig(path: String, body: String): Unit = withContext(Dispatchers.IO) { val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") val url = "http://127.0.0.1:${app.port}$path" @@ -234,6 +386,12 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = private fun encodePath(value: String): String = encode(value).replace("+", "%20") + private fun isSkillFile(path: Path): Boolean { + val name = path.fileName?.toString() ?: return false + if (name == "SKILL.md") return true + return name.substringAfterLast('.', "").lowercase() in extensions + } + private data class SavedMcp( val directory: String, val name: String, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt index 8c61e1e4db..981ea1ad28 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt @@ -64,6 +64,8 @@ internal object KiloWorkspaceDtoMapper { name = s.name, description = s.description, location = s.location, + content = s.content, + editable = false, ) private fun provider(p: ProviderInfo) = ProviderDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 7d0311b6c6..04125f33f3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -22,33 +22,22 @@ import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor -import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider -import com.intellij.ide.util.gotoByName.ChooseByNamePopup -import com.intellij.ide.util.gotoByName.ChooseByNameViewModel -import com.intellij.ide.util.gotoByName.GotoFileModel import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.readAction import com.intellij.openapi.components.service -import com.intellij.openapi.progress.EmptyProgressIndicator -import com.intellij.openapi.project.DumbService -import com.intellij.openapi.project.IndexNotReadyException import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.platform.project.ProjectId import com.intellij.platform.project.findProjectOrNull -import com.intellij.navigation.NavigationItem -import com.intellij.psi.PsiFileSystemItem -import com.intellij.psi.search.GlobalSearchScope -import com.intellij.util.indexing.FindSymbolParameters +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -56,6 +45,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Request import java.net.URI import java.net.URLDecoder @@ -74,7 +65,9 @@ import kotlin.coroutines.resume * for the given directory. Project lookup is only used to resolve the * calling frontend project to the correct backend directory. */ -class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { +class KiloWorkspaceRpcApiImpl internal constructor( + private val svc: KiloBackendAppService? = null, +) : KiloWorkspaceRpcApi { companion object { private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) private const val SCHEMA = "https://app.kilo.ai/config.json" @@ -82,15 +75,15 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { private val LEGACY = listOf("opencode.jsonc", "opencode.json") private val GLOBAL = MODERN + LEGACY + "config.json" private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") - private const val SEARCH_CAP = 2_000 private const val DIFF_CAP = 200_000 + private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ "${'$'}schema": "$SCHEMA" } """ } - private val app: KiloBackendAppService get() = service() + private val app: KiloBackendAppService get() = svc ?: service() private val gitCache = ConcurrentHashMap() @@ -196,19 +189,56 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto() val git = withContext(Dispatchers.IO) { gitAvailable(base) } - val project = project(base) ?: return FileSearchResultDto(git = git) - if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git) + LOG.debug { "workspace file search directory=$directory query=$query limit=$limit" } + return searchKilo(directory, query, limit, git) + } + + private suspend fun searchKilo(directory: String, query: String, limit: Int, git: Boolean): FileSearchResultDto { return try { - val files = readAction { search(project, base, query, limit.coerceIn(1, 200)) } - FileSearchResultDto(files = files, git = git) - } catch (e: IndexNotReadyException) { - FileSearchResultDto(indexing = true, git = git) - } catch (e: LinkageError) { - LOG.warn("file search API unavailable; returning no suggestions", e) + val cap = limit.coerceIn(1, 200) + val (files, dirs) = coroutineScope { + val files = async { kiloResults(directory, query, "file", cap, false) } + val dirs = async { kiloResults(directory, query, "directory", cap, true) } + files.await() to dirs.await() + } + val found = linkedMapOf() + (dirs + files).forEach { file -> found.putIfAbsent(file.path, file) } + FileSearchResultDto(files = found.values.take(cap), git = git) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("Kilo Core file search failed for directory=$directory query=$query", e) FileSearchResultDto(git = git) } } + private suspend fun kiloResults( + directory: String, + query: String, + type: String, + limit: Int, + dir: Boolean, + ): List { + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val raw = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("http://127.0.0.1:${app.port}/find/file?directory=${encode(directory)}&query=${encode(query)}&type=$type&limit=$limit") + .get() + .build() + http.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $body") + body + } + } + return JSON.decodeFromString>(raw) + .asSequence() + .map { it.trimEnd('/') } + .filter { it.isNotBlank() && !isManagedWorktreeStorage(it) } + .map { WorkspaceFileDto(it, it.substringAfterLast('/'), dir) } + .toList() + } + override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) { val base = file(clean(directory) ?: directory) ?: return@withContext null if (!gitAvailable(base)) return@withContext null @@ -311,10 +341,11 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } descriptor.navigate(true) if (cont.isActive) cont.resume(Unit) - }, ModalityState.any()) + }, ModalityState.nonModal()) } private fun project(path: Path): Project? { + if (ApplicationManager.getApplication() == null) return null val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault } return projects.firstOrNull { item -> val base = item.basePath?.let(::file) ?: return@firstOrNull false @@ -322,83 +353,6 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } ?: projects.firstOrNull() } - // Uses the IDE Go-to-File engine (com.intellij.ide.util.gotoByName.*). These are public but - // unstable lang-impl classes (not @ApiStatus.Internal) -- the same engine behind Search Everywhere, - // chosen for proven large-repo performance. searchFiles() degrades gracefully on LinkageError. - @Suppress("UnstableApiUsage") - private fun search(project: Project, base: Path, query: String, limit: Int): List { - val text = query.trim() - if (text.isBlank()) return roots(project, base, limit) - val scope = GlobalSearchScope.projectScope(project) - val model = object : GotoFileModel(project) { - override fun acceptItem(item: NavigationItem): Boolean { - val psi = item as? PsiFileSystemItem ?: return false - val path = file(psi.virtualFile.path) ?: return false - return relativeWithinWorkspace(base, path) != null && super.acceptItem(item) - } - - override fun loadInitialCheckBoxState(): Boolean = false - - override fun saveInitialCheckBoxState(state: Boolean) {} - } - val view = object : ChooseByNameViewModel { - override fun getProject(): Project = project - - override fun getModel() = model - - override fun isSearchInAnyPlace(): Boolean = model.useMiddleMatching() - - override fun transformPattern(pattern: String): String = ChooseByNamePopup.getTransformedPattern(pattern, model) - - override fun canShowListForEmptyPattern(): Boolean = false - - override fun getMaximumListSizeLimit(): Int = limit - } - val provider = model.getItemProvider(null) - val params = FindSymbolParameters.wrap(text, scope) - val found = mutableListOf>() - val indicator = EmptyProgressIndicator() - if (provider is ChooseByNameInScopeItemProvider) { - provider.filterElementsWithWeights(view, params, indicator) { item -> - found += item - found.size < SEARCH_CAP - } - } else { - provider.filterElements(view, text, false, indicator) { item -> - found += FoundItemDescriptor(item, 0) - found.size < SEARCH_CAP - } - } - return found.asSequence() - .sortedByDescending { it.weight } - .mapNotNull { item -> (item.item as? PsiFileSystemItem)?.virtualFile } - .mapNotNull { vf -> fileDto(base, vf) } - .distinctBy { it.path } - .take(limit) - .toList() - } - - private fun roots(project: Project, base: Path, limit: Int): List { - val root = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(base) ?: return emptyList() - val index = ProjectFileIndex.getInstance(project) - return root.children.asSequence() - .filter { it.name != ".git" } - .filterNot { index.isExcluded(it) } - .mapNotNull { fileDto(base, it) } - .sortedWith( - compareByDescending { it.directory } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, - ) - .take(limit) - .toList() - } - - private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? { - val path = file(vf.path) ?: return null - val rel = relativeWithinWorkspace(base, path) ?: return null - return WorkspaceFileDto(rel, vf.name, vf.isDirectory) - } - private fun gitAvailable(base: Path): Boolean { return workspaceGitAvailable(base, gitCache) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index b948b4822d..27e19d1f06 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -254,6 +254,7 @@ class KiloBackendWorkspace( name = s.name, description = s.description, location = s.location, + content = s.content, ) }) } catch (e: CancellationException) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 0308089c27..37c58d853d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -143,4 +143,5 @@ data class SkillInfo( val name: String, val description: String?, val location: String, + val content: String?, ) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index f5d613cecb..f059fa8e0c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -9,7 +9,9 @@ import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -222,6 +224,28 @@ class KiloBackendAppServiceTest { assertEquals("fast", svc.config?.agent?.get("code")?.variant) } + @Test + fun `update config patches context settings and reloads`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + + val state = svc.updateConfig(ConfigPatchDto( + watcher = WatcherPatchDto(ignore = listOf("**/dist/**", "tmp/**")), + compaction = CompactionPatchDto(auto = false, threshold_percent = 75.5, prune = false), + )) + + assertEquals( + "{\"watcher\":{\"ignore\":[\"**/dist/**\",\"tmp/**\"]},\"compaction\":{\"auto\":false,\"threshold_percent\":75.5,\"prune\":false}}", + mock.lastConfigPatchBody, + ) + val cfg = appStateDto(state).config + assertEquals(listOf("**/dist/**", "tmp/**"), cfg?.watcher?.ignore) + assertEquals(false, cfg?.compaction?.auto) + assertEquals(75.5, cfg?.compaction?.threshold_percent) + assertEquals(false, svc.config?.compaction?.prune) + } + @Test fun `ready dto maps model config`() = runBlocking { mock.config = """{"model":"openai/gpt","agent":{"plan":{"model":"anthropic/claude","variant":"high"}}}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 853b77f25b..254417db09 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.Request import okhttp3.sse.EventSource import okhttp3.sse.EventSourceListener @@ -36,6 +37,10 @@ import kotlin.test.assertTrue class KiloConnectionServiceTest { + private companion object { + const val WAIT_MS = 15_000L + } + private val mock = MockCliServer() private val fake = FakeCliServer(mock) private val log = TestLog() @@ -101,20 +106,23 @@ class KiloConnectionServiceTest { val svc = KiloConnectionService(scope, server, {}, log) val job = scope.launch { svc.connect() } - val downloading = withTimeout(5_000) { + val downloading = withTimeout(WAIT_MS) { svc.state.first { it is ConnectionState.Downloading } } assertEquals(ConnectionState.Downloading(42, "1.2.3", "darwin-arm64"), downloading) resolved.complete(Unit) - withTimeout(5_000) { + withTimeout(WAIT_MS) { svc.state.first { it == ConnectionState.Connecting } } ready.complete(Unit) - withTimeout(5_000) { + val connected = withTimeoutOrNull(WAIT_MS) { svc.state.first { it is ConnectionState.Connected } } + if (connected == null) { + error("Timed out waiting for Connected after CLI ready; state=${svc.state.value}; logs=${log.messages.joinToString("\n")}") + } job.join() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 74479094e7..59b6467140 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -19,6 +20,7 @@ import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.SkillsPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto import org.junit.jupiter.api.Nested import kotlin.test.Test import kotlin.test.assertEquals @@ -1173,6 +1175,38 @@ class KiloCliDataParserTest { assertEquals(listOf("https://example.test/skill.md"), cfg.skills?.urls) } + @Test + fun `parseConfig - context settings`() { + val cfg = KiloCliDataParser.parseConfig( + """{ + "watcher":{"ignore":["**/dist/**","tmp/**"]}, + "compaction":{"auto":true,"threshold_percent":75.5,"prune":false} + }""" + ) + + assertEquals(listOf("**/dist/**", "tmp/**"), cfg.watcher?.ignore) + assertEquals(true, cfg.compaction?.auto) + assertEquals(75.5, cfg.compaction?.threshold_percent) + assertEquals(false, cfg.compaction?.prune) + } + + @Test + fun `parseConfig - malformed compaction fields do not discard config`() { + val cfg = KiloCliDataParser.parseConfig( + """{ + "model":"openai/gpt", + "watcher":{"ignore":["tmp/**"]}, + "compaction":{"auto":{},"threshold_percent":[],"prune":false} + }""" + ) + + assertEquals("openai/gpt", cfg.model) + assertEquals(listOf("tmp/**"), cfg.watcher?.ignore) + assertNull(cfg.compaction?.auto) + assertNull(cfg.compaction?.threshold_percent) + assertEquals(false, cfg.compaction?.prune) + } + @Test fun `parseConfig - agent overrides and permissions`() { val cfg = KiloCliDataParser.parseConfig( @@ -2123,6 +2157,29 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildConfigPatch - context watcher and compaction fields`() { + val patch = ConfigPatchDto( + watcher = WatcherPatchDto(ignore = listOf("**/dist/**", "tmp/**")), + compaction = CompactionPatchDto(auto = false, threshold_percent = 75.5, prune = false), + ) + + assertEquals( + "{\"watcher\":{\"ignore\":[\"**/dist/**\",\"tmp/**\"]},\"compaction\":{\"auto\":false,\"threshold_percent\":75.5,\"prune\":false}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - context threshold clear emits null`() { + val patch = ConfigPatchDto(compaction = CompactionPatchDto(clear = listOf("threshold_percent"))) + + assertEquals( + "{\"compaction\":{\"threshold_percent\":null}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + @Test fun `buildConfigPatch - mcp upsert and delete`() { val patch = ConfigPatchDto(mcp = linkedMapOf( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 4050343723..880e6cba4c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertContains @@ -78,6 +80,156 @@ class KiloAgentBehaviorRpcApiImplTest { assertContains(err.message.orEmpty(), "HTTP 400") } + @Test + fun `skills and remove skill call CLI endpoints`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val file = Files.createDirectories(dir.resolve("plan")).resolve("SKILL.md") + val content = """--- + |name: plan + |description: Plan work + |--- + | + |# Fresh Plan + """.trimMargin() + Files.writeString(file, content) + mock.skills = """[ + {"name":"plan","description":"Plan work","location":"$file","content":"# Stale Plan"}, + {"name":"builtin","location":"builtin"} + ]""".trimIndent() + val rpc = rpc() + + val skills = rpc.skills("/test project") + assertEquals(listOf("plan", "builtin"), skills.map { it.name }) + assertEquals("Plan work", skills.single { it.name == "plan" }.description) + assertEquals(content, skills.single { it.name == "plan" }.content) + assertEquals(true, skills.single { it.name == "plan" }.editable) + assertEquals(false, skills.single { it.name == "builtin" }.editable) + + assertTrue(rpc.removeSkill("/test project", file.toString())) + assertEquals("{\"location\":\"$file\"}", mock.lastSkillRemoveBody) + assertEquals(1, mock.requestCount("/kilocode/skill/remove")) + + mock.skillRemoveStatus = 400 + val err = assertFailsWith { + rpc.removeSkill("/test", "/tmp/missing/SKILL.md") + } + assertContains(err.message.orEmpty(), "HTTP 400") + + assertTrue(rpc.reloadSkills("/test project")) + assertEquals(1, mock.requestCount("/instance/reload")) + } + + @Test + fun `url cached skills are read only`() = runBlocking { + val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote") + val file = Files.createDirectories(cache).resolve("SKILL.md") + Files.writeString(file, "# Remote") + mock.skills = """[ + {"name":"remote","description":"Remote","location":"$file","content":"# Remote"} + ]""".trimIndent() + + val skill = rpc().skills("/test project").single() + + assertEquals(false, skill.editable) + assertEquals("# Remote", skill.content) + } + + @Test + fun `custom skills under non cache paths remain editable`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val file = Files.createDirectories(dir.resolve("cache/kilo/skills/custom")).resolve("SKILL.md") + Files.writeString(file, "# Custom") + mock.skills = """[ + {"name":"custom","description":"Custom","location":"$file","content":"# Custom"} + ]""".trimIndent() + + val skill = rpc().skills("/test project").single() + + assertEquals(true, skill.editable) + } + + @Test + fun `save skill supports configured markdown text and html files without reload`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val file = dir.resolve("test.md") + Files.writeString(file, "old") + mock.skills = """[ + {"name":"test","description":"Test","location":"$file","content":"old"} + ]""".trimIndent() + val rpc = rpc() + + assertTrue(rpc.saveSkill("/test project", file.toString(), "new content")) + assertEquals("new content", Files.readString(file)) + assertEquals(0, mock.requestCount("/instance/reload")) + } + + @Test + fun `save skill writes content without reloading instance`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val file = Files.createDirectories(dir.resolve("plan")).resolve("SKILL.md") + Files.writeString(file, "old") + mock.skills = """[ + {"name":"plan","description":"Plan work","location":"$file","content":"old"} + ]""".trimIndent() + val rpc = rpc() + + assertTrue(rpc.saveSkill("/test project", file.toString(), "new content")) + + assertEquals("new content", Files.readString(file)) + assertEquals(0, mock.requestCount("/instance/reload")) + assertFalse(rpc.saveSkill("/test project", "builtin", "nope")) + } + + @Test + fun `save skills validates known paths once for multiple edits`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val plan = Files.createDirectories(dir.resolve("plan")).resolve("SKILL.md") + val review = Files.createDirectories(dir.resolve("review")).resolve("SKILL.md") + Files.writeString(plan, "old plan") + Files.writeString(review, "old review") + mock.skills = """[ + {"name":"plan","description":"Plan work","location":"$plan","content":"old plan"}, + {"name":"review","description":"Review work","location":"$review","content":"old review"} + ]""".trimIndent() + val rpc = rpc() + mock.resetCounts() + + assertTrue(rpc.saveSkills("/test project", mapOf(plan.toString() to "new plan", review.toString() to "new review"))) + + assertEquals("new plan", Files.readString(plan)) + assertEquals("new review", Files.readString(review)) + assertEquals(1, mock.requestCount("/skill")) + } + + @Test + fun `save skill rejects unknown absolute skill files`() = runBlocking { + val dir = Files.createTempDirectory("kilo-skill-test") + val known = Files.createDirectories(dir.resolve("known")).resolve("SKILL.md") + val other = Files.createDirectories(dir.resolve("other")).resolve("SKILL.md") + Files.writeString(known, "known") + Files.writeString(other, "old") + mock.skills = """[ + {"name":"known","description":"Known","location":"$known","content":"known"} + ]""".trimIndent() + + assertFalse(rpc().saveSkill("/test project", other.toString(), "new content")) + + assertEquals("old", Files.readString(other)) + } + + @Test + fun `reload skills is blocked by pending permissions`() = runBlocking { + mock.pendingPermissions = """[ + {"id":"per_test","sessionID":"ses_test","permission":"bash","patterns":["*"],"metadata":{}} + ]""".trimIndent() + val rpc = rpc() + + assertFalse(rpc.reloadSkills("/test project")) + + assertEquals(1, mock.requestCount("/permission")) + assertEquals(0, mock.requestCount("/instance/reload")) + } + @Test fun `mcp config writes global and workspace patches`() = runBlocking { mock.config = """{"mcp":{"global":{"type":"local","command":["node","g.js"]}}}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt new file mode 100644 index 0000000000..c6e4f94fcf --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt @@ -0,0 +1,84 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.WorkspaceFileDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloWorkspaceRpcApiImplTest { + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + mock.close() + } + + @Test + fun `searches files and directories through core`() = runBlocking { + mock.findFiles = """["src/Main.kt",".kilo/worktrees/hidden.kt"]""" + mock.findDirectories = """["src/","docs/"]""" + val dir = Files.createTempDirectory("kilo-search") + try { + val app = app() + + val result = KiloWorkspaceRpcApiImpl(app).searchFiles(dir.toString(), "src", 3) + + assertEquals( + listOf( + WorkspaceFileDto("src", "src", directory = true), + WorkspaceFileDto("docs", "docs", directory = true), + WorkspaceFileDto("src/Main.kt", "Main.kt"), + ), + result.files, + ) + assertEquals(2, mock.requestCount("/find/file")) + assertTrue(mock.findFilePaths.any { it.contains("type=file") && it.contains("query=src") }) + assertTrue(mock.findFilePaths.any { it.contains("type=directory") && it.contains("query=src") }) + } finally { + delete(dir) + } + } + + private suspend fun app(): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}; logs=${log.messages}", + ) + assertIs(state, "App startup failed; logs=${log.messages}") + return app + } + + private fun delete(dir: java.nio.file.Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 888b07e113..5e05d25573 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -11,6 +11,7 @@ import java.net.ServerSocket import java.net.Socket import java.net.SocketException import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -67,9 +68,11 @@ class MockCliServer : AutoCloseable { @Volatile var mcpStatus = 200 @Volatile var mcpActionStatus = 200 @Volatile var agentRemoveStatus = 200 + @Volatile var skillRemoveStatus = 200 @Volatile var agentBuilderStatus = 200 @Volatile var lastMcpActionPath: String? = null @Volatile var lastAgentRemoveBody: String? = null + @Volatile var lastSkillRemoveBody: String? = null @Volatile var lastAgentBuilderPath: String? = null @Volatile var lastAgentBuilderBody: String? = null @Volatile var lastAgentBuilderMethod: String? = null @@ -87,6 +90,12 @@ class MockCliServer : AutoCloseable { @Volatile var commandsStatus = 200 @Volatile var skillsStatus = 200 + // File search responses + @Volatile var findFiles = "[]" + @Volatile var findDirectories = "[]" + @Volatile var findFileStatus = 200 + val findFilePaths = CopyOnWriteArrayList() + // Session REST responses @Volatile var sessions = "[]" @Volatile var recentSessions = "[]" @@ -128,6 +137,8 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionRenamePath: String? = null @Volatile var lastSessionRenameBody: String? = null @Volatile var lastSessionRenameMethod: String? = null + @Volatile var pendingPermissions = "[]" + @Volatile var pendingQuestions = "[]" /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ @Volatile var responseDelay: Long = 0 @@ -368,8 +379,18 @@ class MockCliServer : AutoCloseable { lastAgentRemoveBody = body respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""") } + bare == "/kilocode/skill/remove" && method == "POST" -> { + lastSkillRemoveBody = body + respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""") + } + bare == "/instance/reload" && method == "POST" -> respond(output, 200, "true") bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/find/file" -> { + findFilePaths.add(path) + val body = if (path.contains("type=directory")) findDirectories else findFiles + respond(output, findFileStatus, body) + } bare == "/mcp" -> respond(output, mcpStatus, mcp) bare.matches(Regex("/mcp/[^/]+/(connect|disconnect)")) && method == "POST" -> { lastMcpActionPath = path @@ -393,6 +414,8 @@ class MockCliServer : AutoCloseable { respond(output, cloudSessionImportStatus, cloudSessionImport) } bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) + bare == "/permission" && method == "GET" -> respond(output, 200, pendingPermissions) + bare == "/question" && method == "GET" -> respond(output, 200, pendingQuestions) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt index d2bc692d61..570c73363f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt @@ -21,4 +21,13 @@ object KiloNotifications { ?: Notification(GROUP, title, content ?: "", NotificationType.ERROR) notification.notify(project) } + + fun info(title: String, content: String? = null) { + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP) + ?.createNotification(title, content ?: "", NotificationType.INFORMATION) + ?: Notification(GROUP, title, content ?: "", NotificationType.INFORMATION) + notification.notify(project) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt index 63f55818d3..2171cb1094 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt @@ -33,7 +33,9 @@ class KiloAgentBehaviorService internal constructor( suspend fun agents(directory: String): List = safe(emptyList()) { call { agents(directory) } } - suspend fun skills(directory: String): List = safe(emptyList()) { call { skills(directory) } } + suspend fun loadSkills(directory: String): List = call { skills(directory) } + + suspend fun refreshSkills(directory: String, fallback: List): List = safe(fallback) { call { skills(directory) } } suspend fun commands(directory: String): List = safe(emptyList()) { call { commands(directory) } } @@ -52,6 +54,11 @@ class KiloAgentBehaviorService internal constructor( suspend fun removeSkill(directory: String, location: String): Boolean = safe(false) { call { removeSkill(directory, location) } } + suspend fun reloadSkills(directory: String): Boolean = safe(false) { call { reloadSkills(directory) } } + + suspend fun saveSkills(directory: String, edits: Map): Boolean = + safe(false) { call { saveSkills(directory, edits) } } + suspend fun removeAgent(directory: String, name: String): Boolean = safe(false) { call { removeAgent(directory, name) } } suspend fun createAgent(directory: String, input: AgentCreateDto): Boolean = safe(false) { call { createAgent(directory, input) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 5836ae0cea..b9ffb09054 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -139,6 +139,7 @@ class KiloWorkspaceService internal constructor( } suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto { + LOG.debug { "workspace file search directory=$directory query=$query limit=$limit" } return try { call { searchFiles(directory, query, limit) } } catch (e: CancellationException) { @@ -168,6 +169,15 @@ class KiloWorkspaceService internal constructor( } } + suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean { + return try { + call { openFile(path, line, column) } + } catch (e: Exception) { + LOG.warn("workspace file open failed for path=$path", e) + false + } + } + suspend fun localConfigTarget(directory: String): ConfigTargetDto? { return try { val target = call { this.localConfigTarget(directory) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index 7847bade60..6bc480645e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -10,7 +10,6 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.IdeActions -import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.ex.EditorEx @@ -74,11 +73,11 @@ internal open class SessionEditorTextField( super.uiDataSnapshot(sink) selection?.provideCopy(sink) { text } ctx?.let { sink.set(PromptDataKeys.SEND, it) } - file()?.let { sink.set(PlatformCoreDataKeys.FILE_EDITOR, it) } } private fun install(editor: Editor) { (editor as? EditorEx)?.setEmbeddedIntoDialogWrapper(true) + editor.putUserData(EditorTextField.SUPPLEMENTARY_KEY, true) // EditorImpl lazily creates EditorFloatingToolbar with the same first-show hook. // Settings providers run later, so this callback runs immediately after toolbar creation. UiNotifyConnector.doWhenFirstShown(editor.component) { hide(editor.component) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index 5e40551cd0..a2b6331bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -73,8 +73,8 @@ class KiloPromptCompletionProvider( } fun prewarm() { - if (cache.containsKey("")) return scope.launch { + if (cache.containsKey("")) return@launch val result = service.searchFiles(workspace.directory, "", 50) if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index 954d62a685..e94db1b366 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable +import ai.kilocode.client.settings.context.ContextConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable @@ -73,6 +74,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { behavior.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(behavior) + val context = ActionLink(KiloBundle.message("settings.context.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ContextConfigurable.ID) + } + context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(context) + return panel } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt index 800f9e26a8..e127e610da 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt @@ -25,6 +25,7 @@ class AgentBehaviorConfigurable : SearchableConfigurable { listOf( KiloBundle.message("settings.agentBehavior.agents.displayName") to AgentsConfigurable.ID, KiloBundle.message("settings.agentBehavior.mcp.displayName") to McpConfigurable.ID, + KiloBundle.message("settings.agentBehavior.skills.displayName") to SkillsConfigurable.ID, ).forEach { (label, id) -> panel.next(ActionLink(label) { e -> val src = e.source as? JComponent ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt new file mode 100644 index 0000000000..03ce991d73 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt @@ -0,0 +1,586 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.settings.base.SettingsBadge +import ai.kilocode.client.settings.base.SettingsDraftPage +import ai.kilocode.client.settings.base.SettingsDraftState +import ai.kilocode.client.settings.base.SettingsListCell +import ai.kilocode.client.settings.base.SettingsListConfig +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.SettingsListPanel +import ai.kilocode.client.settings.base.SettingsListSelection +import ai.kilocode.client.settings.base.SettingsListView +import ai.kilocode.client.settings.base.SettingsMessageException +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.SkillsConfigDto +import ai.kilocode.rpc.dto.SkillsPatchDto +import ai.kilocode.rpc.dto.SkillDto +import com.intellij.CommonBundle +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorTextField +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.awt.BorderLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.ListSelectionModel + +private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +class SkillsConfigurable : AgentBehaviorConfigurableBase() { + override fun getId(): String = ID + override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.skills.displayName") + override fun create(cs: CoroutineScope, dir: String): JComponent = SkillsSettingsUi(cs, dir) + override fun update(ui: JComponent, dir: String) { + (ui as? SkillsSettingsUi)?.setDirectory(dir) + } + override fun scrollReadyShell() = false + + companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.skills" } +} + +internal class SkillsSettingsUi( + scope: CoroutineScope, + dir: String, + private val choose: (JComponent) -> String? = ::chooseSkillPath, + private val input: (String, String) -> String? = ::inputSkillUrl, + private val edit: (SkillDto, Boolean) -> SkillEditDialogHandle = ::SkillEditDialog, +) : SettingsListPanel(scope, SettingsListConfig.Equal.copy(tooltip = false)), SettingsDraftPage { + private val cs = scope + private var dir = dir + private var skills = emptyMap() + private val app get() = service() + private val state = SettingsDraftState(skillsDraft(app.state.value.config?.skills ?: SkillsConfigDto()), ::saved) + private var draft: SkillsDraft + get() = state.draft + set(value) { + state.draft = value + } + internal val sources = SkillSourcesView(this, choose, input) + + init { + start() + setCenter(skillScroll()) + content.add(sources, BorderLayout.SOUTH) + } + + fun setDirectory(value: String) { + if (value == dir) return + dir = value + reload() + } + + override suspend fun fetch(): List { + val items = withTimeoutOrNull(SKILL_LOAD_TIMEOUT_MS) { + service().loadSkills(dir) + } ?: throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.skills.load.timeout")) + withContext(edt) { + val dirty = state.modified() + val edit = draft + state.accept(skillsDraft(config())) + if (dirty) draft = state.draft.copy(edited = edit.edited, deleted = edit.deleted) + skills = items.associateBy { key(it) } + sources.refresh(draft.sources) + } + LOG.info("skills settings fetch dir=$dir total=${items.size}") + return rows(items) + } + + override fun afterApply() { + sources.refresh(draft.sources) + } + + override fun onCell(key: String, cellId: String) { + val skill = skills[key] ?: return + when (cellId) { + OPEN_CELL -> open(skill) + EDIT_CELL -> edit(skill) + DELETE_CELL -> remove(skill) + } + } + + override fun searchPlaceholder() = KiloBundle.message("settings.agentBehavior.skills.search") + + override fun emptyText() = KiloBundle.message("settings.agentBehavior.skills.empty") + + internal fun updateSources(paths: List, urls: List) { + state.update { copy(sources = SkillsConfigDto(paths = paths, urls = urls)) } + sources.refresh(draft.sources) + } + + override fun modified(): Boolean = state.modified() + + override fun resetDraft() { + state.reset() + sources.refresh(draft.sources) + view.update(rows()) + clearProgress() + } + + override fun applyDraft() { + val token = state.start() ?: return + val fallback = skillFallback(token.target) + if (!launch("apply") { id -> + val target = token.target + var failed: String? = null + val behavior = service() + LOG.info("skills settings apply start dir=$dir edited=${target.edited.size} deleted=${target.deleted.size} paths=${target.sources.paths.size} urls=${target.sources.urls.size}") + if (target.edited.isNotEmpty() && !behavior.saveSkills(dir, target.edited)) { + failed = KiloBundle.message("settings.agentBehavior.save.failed") + } + if (failed == null) { + for (location in target.deleted) { + if (!behavior.removeSkill(dir, location)) { + failed = KiloBundle.message("settings.agentBehavior.skills.delete.failed") + break + } + } + } + if (failed == null && target.sources != token.previous.sources) { + val patch = ConfigPatchDto(skills = SkillsPatchDto(paths = target.sources.paths, urls = target.sources.urls)) + if (app.updateConfig(patch) == null) failed = KiloBundle.message("settings.agentBehavior.save.failed") + } + val reloaded = if (failed == null) behavior.reloadSkills(dir) else true + val items = behavior.refreshSkills(dir, fallback) + withContext(edt) { + if (!active(id)) { + if (failed == null) KiloNotifications.info(KiloBundle.message("settings.agentBehavior.skills.saved.notification")) + else KiloNotifications.error(failed) + return@withContext + } + if (failed == null) { + skills = items.associateBy { key(it) } + val next = skillsDraft(config()) + state.complete(token, next) + sources.refresh(draft.sources) + view.update(rows(items)) + if (reloaded) clearProgress() else showProgress(KiloBundle.message("settings.agentBehavior.skills.reload.blocked")) + LOG.info("skills settings apply succeeded dir=$dir") + } else { + state.fail(token, failed) + sources.refresh(draft.sources) + view.update(rows(items)) + showError(failed) + LOG.warn("skills settings apply failed dir=$dir message=$failed") + } + setBusy(false) + } + }) return + showProgress(KiloBundle.message("settings.agentBehavior.saving")) + } + + private fun skillScroll() = JBScrollPane(view).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + + private fun rows(items: List = skills.values.toList()): List = items.mapNotNull { skill -> + if (skill.location in draft.deleted) return@mapNotNull null + item(skill) + } + + private fun skillFallback(target: SkillsDraft): List = skills.values.mapNotNull { skill -> + if (skill.location in target.deleted) return@mapNotNull null + target.edited[skill.location]?.let { skill.copy(content = it) } ?: skill + } + + private fun item(skill: SkillDto) = object : SettingsListItem { + override val key = key(skill) + override val title = skill.name + override val note = skill.location.takeUnless { builtin(it) } + override val description = skill.description + override val doubleClick = EDIT_CELL + override val badges = listOf( + SettingsBadge(KiloBundle.message("settings.agentBehavior.badge.builtin"), UiStyle.Badge.Secondary), + ).takeIf { builtin(skill.location) } ?: emptyList() + override val cells = listOfNotNull( + SettingsListCell( + OPEN_CELL, + KiloBundle.message("settings.agentBehavior.skills.openInEditor"), + primary = true, + ).takeIf { skill.editable }, + SettingsListCell( + EDIT_CELL, + KiloBundle.message(if (skill.editable) "settings.agentBehavior.edit" else "common.open"), + primary = !skill.editable, + ), + SettingsListCell( + DELETE_CELL, + KiloBundle.message("common.delete"), + icon = AllIcons.Actions.GC, + iconOnly = true, + ).takeIf { skill.editable }, + ) + } + + private fun edit(skill: SkillDto) { + val current = skill.copy(content = content(skill)) + val dialog = edit(current, skill.editable) + if (!skill.editable) { + dialog.showAndGet() + return + } + if (!dialog.showAndGet()) return + state.update { copy(edited = edited + (skill.location to dialog.content())) } + view.update(rows(), SettingsListSelection.Key(key(skill))) + } + + private fun open(skill: SkillDto) { + if (!skill.editable) return + showProgress(KiloBundle.message("settings.agentBehavior.skills.openInEditor.pending")) + cs.launch { + val opened = service().openFile(skill.location) + if (opened) return@launch + withContext(edt) { KiloNotifications.error(KiloBundle.message("settings.agentBehavior.skills.openInEditor.failed")) } + } + } + + private fun remove(skill: SkillDto) { + val result = Messages.showYesNoDialog( + KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name), + KiloBundle.message("settings.agentBehavior.skills.delete.title"), + KiloBundle.message("common.delete"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (result != Messages.YES) return + state.update { copy(deleted = deleted + skill.location, edited = edited - skill.location) } + view.update(rows(), selectionIndex()) + } + + private fun content(skill: SkillDto) = draft.edited[skill.location] ?: skill.content + + private fun config() = app.state.value.config?.skills ?: SkillsConfigDto() + + private companion object { + const val EDIT_CELL = "edit" + const val OPEN_CELL = "open" + const val DELETE_CELL = "delete" + const val BUILTIN = "builtin" + const val LEGACY_BUILTIN = "" + val LOG = KiloLog.create(SkillsSettingsUi::class.java) + + fun key(skill: SkillDto) = skill.location.ifBlank { skill.name } + fun builtin(location: String) = location == BUILTIN || location == LEGACY_BUILTIN + } +} + +internal interface SkillEditDialogHandle { + fun showAndGet(): Boolean + fun content(): String +} + +private data class SkillsDraft( + val sources: SkillsConfigDto, + val edited: Map = emptyMap(), + val deleted: Set = emptySet(), +) + +private fun skillsDraft(sources: SkillsConfigDto) = SkillsDraft(sources) + +private fun saved(base: SkillsDraft, draft: SkillsDraft): Boolean = base == draft + +internal class SkillEditDialog(private val skill: SkillDto, private val savable: Boolean) : DialogWrapper(true), SkillEditDialogHandle { + private val base = initial() + private val editor = SkillEditor(base, skill.location, savable) + + init { + title = skill.name + setOKButtonText(CommonBundle.getOkButtonText()) + setCancelButtonText(CommonBundle.getCloseButtonText()) + init() + isOKActionEnabled = false + editor.document.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + isOKActionEnabled = savable && editor.text != base + } + }) + } + + override fun createCenterPanel(): JComponent = JBScrollPane(editor).apply { + viewportBorder = editorPad() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + preferredSize = JBUI.size(720, 520) + } + + override fun createActions() = if (savable) arrayOf(okAction, cancelAction) else arrayOf(cancelAction) + + override fun content() = editor.text + + private fun initial() = skill.content?.takeIf { it.isNotBlank() } + ?: skill.description?.takeIf { it.isNotBlank() } + ?: KiloBundle.message("settings.agentBehavior.skills.content.empty") + + private class SkillEditor(value: String, location: String, editable: Boolean) : EditorTextField( + EditorFactory.getInstance().createDocument(value), + ProjectManager.getInstance().defaultProject, + skillFileType(location, value), + false, + !editable, + ) { + init { + border = JBUI.Borders.empty() + setOneLineMode(false) + addSettingsProvider { ed -> + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.settings.isUseSoftWraps = true + ed.settings.isPaintSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } + } +} + +internal class SkillSourcesView( + private val parent: SkillsSettingsUi, + private val choose: (JComponent) -> String?, + private val input: (String, String) -> String?, +) : Stack(ai.kilocode.client.ui.layout.StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val view = SettingsListView( + KiloBundle.message("settings.agentBehavior.skills.sources.empty"), + SettingsListConfig.Preferred.copy(description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION), + ) { key, id -> + if (id == EDIT_CELL) edit(key) + } + private var cfg = SkillsConfigDto() + + internal fun sourceList() = view.list + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, 0, 0) + next(TitledSeparator(KiloBundle.message("settings.agentBehavior.skills.sources.title"))) + next(toolbar()) + next(JBScrollPane(view).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + preferredSize = JBUI.size(0, JBUI.scale(160)) + maximumSize = JBUI.size(Int.MAX_VALUE, JBUI.scale(160)) + }) + } + + fun refresh(config: SkillsConfigDto) { + cfg = config + view.update(rows(config)) + } + + private fun toolbar(): JComponent { + val add = DefaultActionGroup(KiloBundle.message("settings.agentBehavior.skills.sources.add"), true).apply { + templatePresentation.icon = AllIcons.General.Add + add(AddPathAction()) + add(AddUrlAction()) + } + val group = DefaultActionGroup(add, RemoveAction()) + val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, group, true) + toolbar.targetComponent = this + toolbar.updateActionsImmediately() + return toolbar.component + } + + internal fun addPath() { + val path = choose(parent)?.trim()?.takeIf { it.isNotBlank() } ?: return + if (path in cfg.paths) return + parent.updateSources(cfg.paths + path, cfg.urls) + } + + internal fun addUrl() { + val url = input( + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.title"), + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.prompt"), + )?.trim()?.takeIf { it.isNotBlank() } ?: return + if (url in cfg.urls) return + parent.updateSources(cfg.paths, cfg.urls + url) + } + + private fun rows(config: SkillsConfigDto): List { + val paths = config.paths.map { source(PATH_PREFIX, it) } + val urls = config.urls.map { source(URL_PREFIX, it) } + return paths + urls + } + + private fun source(prefix: String, value: String) = object : SettingsListItem { + override val key = prefix + value + override val title = value + override val doubleClick = EDIT_CELL + } + + internal fun removeSelected() { + val keys = view.selectedItems().map { it.key }.toSet() + if (keys.isEmpty()) return + val paths = cfg.paths.filterNot { PATH_PREFIX + it in keys } + val urls = cfg.urls.filterNot { URL_PREFIX + it in keys } + parent.updateSources(paths, urls) + } + + private fun edit(key: String) { + val path = key.startsWith(PATH_PREFIX) + val old = key.removePrefix(if (path) PATH_PREFIX else URL_PREFIX) + val dialog = SkillSourceDialog(old, path, choose) + if (!dialog.showAndGet()) return + val next = dialog.value().trim().takeIf { it.isNotBlank() } ?: return + if (path) { + parent.updateSources(cfg.paths.map { if (it == old) next else it }.distinct(), cfg.urls) + return + } + parent.updateSources(cfg.paths, cfg.urls.map { if (it == old) next else it }.distinct()) + } + + private inner class AddPathAction : DumbAwareAction( + KiloBundle.message("settings.agentBehavior.skills.sources.addPath"), + null, + null, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun actionPerformed(e: AnActionEvent) = addPath() + } + + private inner class AddUrlAction : DumbAwareAction( + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl"), + null, + null, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun actionPerformed(e: AnActionEvent) = addUrl() + } + + private inner class RemoveAction : DumbAwareAction( + KiloBundle.message("common.delete"), + null, + AllIcons.General.Remove, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = view.selectedItems().isNotEmpty() + } + override fun actionPerformed(e: AnActionEvent) = removeSelected() + } + + private companion object { + const val EDIT_CELL = "edit" + const val PATH_PREFIX = "path:" + const val URL_PREFIX = "url:" + } +} + +private class SkillSourceDialog( + value: String, + private val path: Boolean, + private val choose: (JComponent) -> String?, +) : DialogWrapper(true) { + private val field = JBTextField(value) + + init { + title = if (path) KiloBundle.message("settings.agentBehavior.skills.sources.editPath.title") + else KiloBundle.message("settings.agentBehavior.skills.sources.editUrl.title") + setOKButtonText(KiloBundle.message("common.save")) + init() + } + + override fun createCenterPanel(): JComponent { + if (!path) return field.apply { columns = SOURCE_COLUMNS } + return JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { + add(field.apply { columns = SOURCE_COLUMNS }, BorderLayout.CENTER) + add(JButton("...").apply { + addActionListener { + choose(this)?.let { field.text = it } + } + }, BorderLayout.EAST) + } + } + + fun value() = field.text +} + +private fun chooseSkillPath(parent: JComponent): String? { + return FileChooser.chooseFile(skillPathDescriptor(), parent, null, null as VirtualFile?)?.path +} + +internal fun skillPathDescriptor() = FileChooserDescriptor(false, true, false, false, false, false).apply { + title = KiloBundle.message("settings.agentBehavior.skills.sources.addPath.title") + description = KiloBundle.message("settings.agentBehavior.skills.sources.addPath.prompt") +} + +internal fun skillFileType(location: String, content: String? = null): FileType { + val syntax = content?.syntaxName() + val name = syntax ?: location.substringAfterLast('/').substringAfterLast('\\').ifBlank { SKILL_FILE } + val type = FileTypeManager.getInstance().getFileTypeByFileName(name) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type +} + +private fun String.syntaxName(): String? { + val text = trimStart() + if (text.isBlank()) return null + if (text.looksHtml()) return "index.html" + if (text.looksMarkdown()) return SKILL_FILE + return null +} + +private fun String.looksHtml() = contains(Regex("^\\s*( + line.matches(Regex("\\s{0,3}(#{1,6}\\s+.+|[-*+]\\s+.+|\\d+\\.\\s+.+|```.*|>\\s+.+)")) || + line.contains(Regex("(`[^`]+`|\\[[^]]+][(][^)]+[)])")) +} + +private fun inputSkillUrl(title: String, prompt: String): String? = Messages.showInputDialog( + prompt, + title, + Messages.getQuestionIcon(), +) + +private fun editorPad() = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), +) + +private const val SOURCE_COLUMNS = 60 +private const val SKILL_FILE = "SKILL.md" +private const val SKILL_LOAD_TIMEOUT_MS = 10_000L diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt index ca8424f65b..335df8d50f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt @@ -11,6 +11,8 @@ internal class SettingsDraftState( private var base = initial private var pending: D? = null + private var stale: List = emptyList() + private var applied: D? = null private var save = false private var err: String? = null @@ -29,6 +31,10 @@ internal class SettingsDraftState( fun accept(next: D) { val target = pending if (target == null) { + val done = applied + if (done != null && saved(base, done) && stale.any { saved(next, it) }) return + stale = emptyList() + applied = null val prev = base val edit = draft base = next @@ -51,12 +57,15 @@ internal class SettingsDraftState( fun complete(token: SettingsDraftSave, returned: D) { val edit = draft - val next = if (saved(returned, token.target)) returned else token.target + val fresh = saved(returned, token.target) + val next = if (fresh) returned else token.target base = next draft = if (saved(edit, token.target)) next else edit pending = null save = false err = null + stale += token.previous + applied = token.target } fun fail(token: SettingsDraftSave, message: String) { @@ -66,6 +75,8 @@ internal class SettingsDraftState( pending = null save = false err = message + stale = emptyList() + applied = null } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 81cac63adc..0246af99dd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -8,6 +8,7 @@ import java.awt.Point import java.awt.Rectangle import javax.swing.Icon import javax.swing.JList +import javax.swing.ListSelectionModel import javax.swing.ListCellRenderer import javax.swing.SwingUtilities @@ -21,6 +22,8 @@ internal data class SettingsListConfig( val height: SettingsListRowHeight, val description: Boolean = true, val descriptionIndent: Boolean = true, + val tooltip: Boolean = true, + val selection: Int = ListSelectionModel.SINGLE_SELECTION, ) { companion object { val Equal = SettingsListConfig(SettingsListRowHeight.EQUAL) @@ -41,7 +44,9 @@ internal data class SettingsListCell( internal interface SettingsListItem { val key: String val title: String + val note: String? get() = null val description: String? get() = null + val doubleClick: String? get() = null val icon: Icon? get() = null val section: String? get() = null val badges: List get() = emptyList() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt index 314e2c64b7..074ea866be 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt @@ -83,6 +83,9 @@ internal class SettingsListRenderer( title.clear() title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + value.note?.takeIf { it.isNotBlank() }?.let { + title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } syncBadges(value) icon.icon = value.icon mark.isVisible = value.icon != null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt index 661ac90103..183a86a9df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.base import ai.kilocode.client.session.ui.model.ModelSearch +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.application.ApplicationManager import com.intellij.ui.CollectionListModel import com.intellij.ui.ScrollingUtil @@ -8,23 +9,27 @@ import com.intellij.ui.components.JBList import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.xml.util.XmlStringUtil import com.intellij.util.ui.UIUtil +import java.awt.Dimension +import java.awt.Rectangle import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.KeyStroke import javax.swing.ListSelectionModel +import javax.swing.Scrollable +import javax.swing.SwingConstants import javax.swing.event.ListSelectionEvent internal class SettingsListView( empty: String, private val cfg: SettingsListConfig = SettingsListConfig.Equal, private val onCell: (String, String) -> Unit, -) : BaseContentPanel() { +) : BaseContentPanel(), Scrollable { private val model = CollectionListModel() internal val list = object : JBList(model) { override fun getToolTipText(event: MouseEvent): String? { - if (!cfg.description) return null + if (!cfg.description || !cfg.tooltip) return null val idx = locationToIndex(event.point) if (idx < 0) return null val bounds = getCellBounds(idx, idx) ?: return null @@ -34,7 +39,7 @@ internal class SettingsListView( return XmlStringUtil.wrapInHtml(text) } }.apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION + selectionMode = cfg.selection setExpandableItemsEnabled(false) emptyText.text = empty } @@ -67,6 +72,11 @@ internal class SettingsListView( val hit = hit(e, enabled = false) ?: return if (hit.id != null) return val item = hit.item + item.doubleClick?.let { id -> + onCell(item.key, id) + e.consume() + return + } primary(item) e.consume() } @@ -94,6 +104,12 @@ internal class SettingsListView( return list.selectedValue } + @RequiresEdt + fun selectedItems(): List { + checkEdt() + return list.selectedValuesList + } + @RequiresEdt fun selectedIndex(): Int { checkEdt() @@ -121,6 +137,7 @@ internal class SettingsListView( @RequiresEdt fun setBusy(value: Boolean) { checkEdt() + list.setPaintBusy(value) if (list.isEnabled == !value) return list.isEnabled = !value list.repaint() @@ -191,9 +208,15 @@ internal class SettingsListView( private fun primary(item: SettingsListItem) { val cells = settingsListVisibleCells(item, true) val cell = cells.firstOrNull { it.enabled && it.primary } - ?: cells.firstOrNull { it.enabled } - ?: return - onCell(item.key, cell.id) + if (cell != null) { + onCell(item.key, cell.id) + return + } + item.doubleClick?.let { id -> + onCell(item.key, id) + return + } + cells.firstOrNull { it.enabled }?.let { onCell(item.key, it.id) } } private fun hit(e: MouseEvent, enabled: Boolean = true): Hit? { @@ -201,7 +224,7 @@ internal class SettingsListView( val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null if (!bounds.contains(e.point)) return null val item = model.getElementAt(idx) - val selected = idx == list.selectedIndex + val selected = list.isSelectedIndex(idx) val id = if (enabled) { settingsListCellAt(list, idx, e.point, selected) } else { @@ -217,6 +240,27 @@ internal class SettingsListView( check(ApplicationManager.getApplication().isDispatchThread) { "Settings list updates must run on EDT" } } + override fun getScrollableTracksViewportWidth() = true + + override fun getScrollableTracksViewportHeight() = false + + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + + override fun getScrollableUnitIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ): Int { + if (orientation != SwingConstants.VERTICAL) return UiStyle.Gap.pad() + return list.fixedCellHeight.takeIf { it > 0 } ?: UiStyle.Gap.xl() + } + + override fun getScrollableBlockIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = if (orientation == SwingConstants.VERTICAL) visibleRect.height else visibleRect.width + private data class Hit(val item: SettingsListItem, val id: String?) private data class Press(val key: String, val id: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt index cf87dd58ff..240c2c0212 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt @@ -33,6 +33,14 @@ internal open class SettingsPanel : SettingsOverlayPanel() { repaint() } + protected fun setCenter(component: JComponent) { + val layout = content.layout as? BorderLayout + layout?.getLayoutComponent(BorderLayout.CENTER)?.let { content.remove(it) } + content.add(component, BorderLayout.CENTER) + revalidate() + repaint() + } + } private class SettingsBody : Stack(StackAxis.VERTICAL), Scrollable { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt new file mode 100644 index 0000000000..2a5c92916e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt @@ -0,0 +1,18 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.DraftReadyConfigurable +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class ContextConfigurable : DraftReadyConfigurable() { + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.context.displayName") + + override fun create(cs: CoroutineScope): JComponent = ContextSettingsUi(cs) + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.context" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt new file mode 100644 index 0000000000..3a5989a332 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.rpc.dto.CompactionPatchDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto + +internal data class ContextDraft( + val auto: Boolean = false, + val threshold: String = "", + val prune: Boolean = false, + val ignore: List = emptyList(), +) + +internal enum class ThresholdStatus { + VALID, + INVALID, +} + +internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft( + auto = config?.compaction?.auto ?: false, + threshold = config?.compaction?.threshold_percent?.let(::formatThreshold).orEmpty(), + prune = config?.compaction?.prune ?: false, + ignore = config?.watcher?.ignore ?: emptyList(), +) + +internal fun patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto? { + if (thresholdStatus(to.threshold) == ThresholdStatus.INVALID) return null + + val compaction = compactionPatch(from, to) + val watcher = if (from.ignore != to.ignore) WatcherPatchDto(ignore = to.ignore) else null + return ConfigPatchDto(watcher = watcher, compaction = compaction) +} + +internal fun changed(patch: ConfigPatchDto): Boolean = patch.watcher != null || patch.compaction != null + +internal fun savedMatches(base: ContextDraft, draft: ContextDraft): Boolean = + base.auto == draft.auto && + normalizeThreshold(base.threshold) == normalizeThreshold(draft.threshold) && + base.prune == draft.prune && + base.ignore == draft.ignore + +internal fun thresholdStatus(value: String): ThresholdStatus { + val text = value.trim() + if (text.isBlank()) return ThresholdStatus.VALID + val num = text.toDoubleOrNull() + if (num == null || !num.isFinite() || num < 0.0 || num > 100.0) return ThresholdStatus.INVALID + return ThresholdStatus.VALID +} + +private fun compactionPatch(from: ContextDraft, to: ContextDraft): CompactionPatchDto? { + val threshold = parseThreshold(to.threshold) + val fromThreshold = parseThreshold(from.threshold) + val clear = if (fromThreshold != threshold && threshold == null) listOf("threshold_percent") else emptyList() + val patch = CompactionPatchDto( + clear = clear, + auto = to.auto.takeIf { from.auto != to.auto }, + threshold_percent = threshold.takeIf { fromThreshold != threshold && threshold != null }, + prune = to.prune.takeIf { from.prune != to.prune }, + ) + if (patch.clear.isEmpty() && patch.auto == null && patch.threshold_percent == null && patch.prune == null) return null + return patch +} + +private fun parseThreshold(value: String): Double? { + val text = value.trim() + if (text.isBlank()) return null + return text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 && it <= 100.0 } +} + +private fun normalizeThreshold(value: String): String = parseThreshold(value)?.let(::formatThreshold).orEmpty() + +private fun formatThreshold(value: Double): String { + val whole = value.toLong() + if (value == whole.toDouble()) return whole.toString() + return value.toString() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt new file mode 100644 index 0000000000..7a66f2ac59 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -0,0 +1,388 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.client.settings.base.SettingsBannerKind +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.Messages +import com.intellij.ui.CollectionListModel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.DefaultListCellRenderer +import javax.swing.JList +import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants +import javax.swing.event.DocumentEvent +import javax.swing.text.AbstractDocument +import javax.swing.text.AttributeSet +import javax.swing.text.DocumentFilter + +internal class ContextSettingsUi( + cs: CoroutineScope, + private val app: KiloAppService = service(), + workspaces: KiloWorkspaceService = service(), +) : BaseSettingsUi( + cs, + ContextDraft(), + app, + workspaces, + loginBanner = false, +) { + init { + startSettings(ContextSettingsContent { updateDraft(it) }) + } + + override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to)?.takeIf(::changed) + + override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { + app.updateConfigAsync(change, done) + } + + override fun base(result: KiloAppStateDto): ContextDraft = contextDraft(result.config) + + override fun draft(state: KiloAppStateDto): ContextDraft = contextDraft(state.config) + + override fun saved(base: ContextDraft, draft: ContextDraft): Boolean = savedMatches(base, draft) + + override fun pendingText(): String = KiloBundle.message("settings.context.save.pending") + + override fun failedText(): String = KiloBundle.message("settings.context.save.failed") + + override suspend fun loadWorkspace(root: String) = Unit + + override fun applyWorkspace(result: Unit) = Unit + + override fun models(state: ModelStateDto) = Unit + + override fun logSaveStarted(change: ConfigPatchDto) = LOG.info("context settings save: started ${summary(change)}") + + override fun logSaveCompleted(change: ConfigPatchDto) = LOG.info("context settings save: completed ${summary(change)}") + + override fun logSaveFailed(change: ConfigPatchDto) = LOG.warn("context settings save: failed ${summary(change)}") + + override fun logSaveFailedAfterDispose(change: ConfigPatchDto) = LOG.warn("context settings save: failed after dispose ${summary(change)}") + + override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("context settings save: completed after dispose ${summary(change)}") + + @RequiresEdt + override fun syncContent() { + val ready = appState.status == KiloAppStatusDto.READY + val editable = ready && !saving + form.sync(draft, editable) + top.hideBanner() + val err = saveError + if (saving) { + showProgress(KiloBundle.message("settings.context.save.pending")) + return + } + if (err != null) { + showError(err) + return + } + if (!ready) { + showProgress(KiloBundle.message("settings.cli.unavailable.message")) + return + } + if (thresholdStatus(draft.threshold) == ThresholdStatus.INVALID) { + top.showBanner( + KiloBundle.message("settings.context.compaction.threshold.invalid"), + emptyList(), + SettingsBannerKind.ERROR, + ) + clearProgress() + return + } + clearProgress() + } + + private companion object { + val LOG = KiloLog.create(ContextSettingsUi::class.java) + } +} + +internal class ContextSettingsContent( + private val update: (ContextDraft.() -> ContextDraft) -> Unit, +) : BaseContentPanel() { + private val auto = SettingsToggle { value -> update { copy(auto = value) } } + private val prune = SettingsToggle { value -> update { copy(prune = value) } } + private val threshold = ThresholdField( + KiloBundle.message("settings.context.compaction.threshold.placeholder"), + ) { value -> update { copy(threshold = value) } } + private val patterns = PatternList { value -> update { copy(ignore = value) } } + + init { + section( + KiloBundle.message("settings.context.compaction.title"), + ).apply { + row(SettingsRow( + KiloBundle.message("settings.context.compaction.auto.title"), + KiloBundle.message("settings.context.compaction.auto.description"), + auto, + )) + row(SettingsRow( + KiloBundle.message("settings.context.compaction.threshold.title"), + KiloBundle.message("settings.context.compaction.threshold.description"), + Stack.horizontal(UiStyle.Gap.xs()) + .next(threshold) + .next(JBLabel(KiloBundle.message("settings.context.compaction.threshold.suffix"))) + .align(HAlign.RIGHT, VAlign.CENTER), + )) + row(SettingsRow( + KiloBundle.message("settings.context.compaction.prune.title"), + KiloBundle.message("settings.context.compaction.prune.description"), + prune, + )) + } + section( + KiloBundle.message("settings.context.watcher.title"), + KiloBundle.message("settings.context.watcher.description"), + ).row(patterns) + } + + @RequiresEdt + fun sync(draft: ContextDraft, enabled: Boolean) { + auto.isSelected = draft.auto + prune.isSelected = draft.prune + threshold.sync(draft.threshold) + patterns.sync(draft.ignore) + listOf(auto, prune, threshold, patterns).forEach { it.isEnabled = enabled } + } +} + +private class ThresholdField( + placeholder: String, + private val change: (String) -> Unit, +) : JBTextField() { + private var syncing = false + + init { + columns = THRESHOLD_COLUMNS + emptyText.text = placeholder + (document as AbstractDocument).documentFilter = NumberFilter() + document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!syncing) change(text) + } + }) + } + + fun sync(value: String) { + if (text == value) return + syncing = true + text = value + syncing = false + } +} + +private class NumberFilter : DocumentFilter() { + override fun insertString(fb: FilterBypass, offset: Int, string: String?, attr: AttributeSet?) { + replace(fb, offset, 0, string, attr) + } + + override fun replace(fb: FilterBypass, offset: Int, length: Int, text: String?, attrs: AttributeSet?) { + val value = text ?: "" + val next = StringBuilder(fb.document.getText(0, fb.document.length)) + .replace(offset, offset + length, value) + .toString() + if (next.isEmpty() || valid(next)) super.replace(fb, offset, length, value, attrs) + } + + private fun valid(value: String): Boolean { + if (value.count { it == '.' } > 1) return false + if (!value.all { it.isDigit() || it == '.' }) return false + val num = value.toDoubleOrNull() ?: return false + return num >= 0.0 && num <= 100.0 + } +} + +internal class PatternList( + private val change: (List) -> Unit, +) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val model = CollectionListModel() + internal var input: () -> String? = { + Messages.showInputDialog( + this, + KiloBundle.message("settings.context.watcher.input.prompt"), + KiloBundle.message("settings.context.watcher.input.title"), + null, + ) + } + internal var editor: (String) -> String? = { value -> + Messages.showInputDialog( + this, + KiloBundle.message("settings.context.watcher.input.prompt"), + KiloBundle.message("settings.context.watcher.title"), + null, + value, + null, + ) + } + private val add = HoverIcon().apply { + icon = AllIcons.General.Add + toolTipText = KiloBundle.message("settings.context.watcher.add") + addActionListener { add() } + } + private val remove = HoverIcon().apply { + icon = AllIcons.General.Remove + toolTipText = KiloBundle.message("settings.context.watcher.remove") + addActionListener { remove() } + } + private val list = JBList(model).apply { + selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION + isFocusable = true + emptyText.text = KiloBundle.message("settings.context.watcher.empty") + cellRenderer = PatternRenderer() + } + private val toolbar = Stack.horizontal().next(add).next(remove) + private val scroll = JBScrollPane(list).apply { + border = null + viewportBorder = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) + list.addListSelectionListener { if (!it.valueIsAdjusting) syncActions() } + list.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount != 2 || !UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) return + val idx = list.locationToIndex(e.point) + if (idx < 0 || list.getCellBounds(idx, idx)?.contains(e.point) != true) return + edit(idx) + } + }) + list.registerKeyboardAction( + { remove() }, + javax.swing.KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), + JComponent.WHEN_FOCUSED, + ) + ScrollingUtil.installActions(list) + next(toolbar.align(HAlign.LEFT, VAlign.CENTER)) + gap(UiStyle.Gap.sm()) + next(scroll) + syncActions() + } + + @RequiresEdt + fun sync(values: List) { + if (model.items != values) model.replaceAll(values) + syncActions() + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + add.isEnabled = enabled + remove.isEnabled = enabled && list.selectedIndices.isNotEmpty() + list.isEnabled = enabled + scroll.isEnabled = enabled + toolbar.isEnabled = enabled + syncActions() + } + + private fun add() { + if (!isEnabled) return + val value = input()?.trim().orEmpty() + if (value.isBlank()) return + val values = model.items.toMutableList() + val idx = values.indexOf(value).takeIf { it >= 0 } ?: run { + values += value + model.replaceAll(values) + change(values) + values.lastIndex + } + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + syncActions() + } + + private fun edit(idx: Int) { + if (!isEnabled || idx < 0 || idx >= model.size) return + val value = editor(model.getElementAt(idx))?.trim().orEmpty() + if (value.isBlank()) return + val values = model.items.toMutableList() + val found = values.indexOf(value) + val next = if (found >= 0 && found != idx) { + values.removeAt(idx) + if (found > idx) found - 1 else found + } else { + values[idx] = value + idx + } + model.replaceAll(values) + change(values) + list.selectedIndex = next + ScrollingUtil.ensureIndexIsVisible(list, next, 0) + syncActions() + } + + private fun remove() { + val indices = list.selectedIndices.filter { it >= 0 && it < model.size } + if (!isEnabled || indices.isEmpty()) return + val values = model.items.toMutableList() + indices.sortedDescending().forEach(values::removeAt) + model.replaceAll(values) + val next = indices.minOrNull()?.coerceAtMost(values.lastIndex) ?: -1 + if (next >= 0) list.selectedIndex = next else list.clearSelection() + change(values) + syncActions() + } + + private fun syncActions() { + add.isEnabled = isEnabled + remove.isEnabled = isEnabled && list.selectedIndices.isNotEmpty() + } + + private class PatternRenderer : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + selected: Boolean, + focus: Boolean, + ): java.awt.Component { + val comp = super.getListCellRendererComponent(list, value, index, selected, focus) as JComponent + comp.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + return comp + } + } +} + +private fun summary(patch: ConfigPatchDto): String { + val parts = listOfNotNull( + "watcher".takeIf { patch.watcher != null }, + "compaction".takeIf { patch.compaction != null }, + ) + return parts.joinToString(",").ifEmpty { "none" } +} + +private const val THRESHOLD_COLUMNS = 8 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index d08df1f9f9..a9a427aef4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -33,7 +33,7 @@ @@ -41,7 +41,7 @@ @@ -62,6 +62,14 @@ bundle="messages.KiloBundle" key="settings.agentBehavior.displayName"/> + + + + { + withContext(Dispatchers.Default) { service.loadSkills("/test") } + } + } + + fun `test refreshSkills returns previous rows on rpc failure`() = runBlocking { + val fallback = listOf(SkillDto("plan", location = "/test/SKILL.md")) + rpc.skillsError = RuntimeException("boom") + + val items = withContext(Dispatchers.Default) { service.refreshSkills("/test", fallback) } + + assertEquals(fallback, items) + } + + fun `test saveSkills forwards all edits`() = runBlocking { + rpc.skills = listOf(SkillDto("plan", location = "/test/plan/SKILL.md")) + + val ok = withContext(Dispatchers.Default) { + service.saveSkills("/test", mapOf("/test/plan/SKILL.md" to "# Saved")) + } + + assertTrue(ok) + assertEquals(listOf(Triple("/test", "/test/plan/SKILL.md", "# Saved")), rpc.skillSaves) + assertEquals("# Saved", rpc.skills.single().content) + } + fun `test mcpStatus forwards directory`() = runBlocking { rpc.mcps = listOf(McpStatusDto("filesystem", "connected")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 607fe2493d..2691c32705 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -97,4 +97,12 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(err.message, seen?.message) assertEquals(listOf("dep"), rpc.searchQueries) } + + fun `test searchFiles sends query to RPC`() = runBlocking { + withContext(Dispatchers.Default) { + service.searchFiles("/test", "src") + } + + assertEquals(listOf("src"), rpc.searchQueries) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 5e51f8f46a..70a6dc52bb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -44,7 +44,6 @@ import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.command.WriteCommandAction -import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory @@ -56,7 +55,6 @@ import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.markup.TextAttributes -import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.PlainTextLanguage @@ -455,28 +453,20 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(spans.contains("@unknown" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES)) } - fun `test prompt editor exposes file editor for undo redo`() { + fun `test prompt editor does not expose file editor for platform undo`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) val field = panel.defaultFocusedComponent as EditorTextField realize(panel, 260, 400) val editor = field.getEditor(false)!! - WriteCommandAction.runWriteCommandAction(project) { - editor.document.insertString(0, "hello") - } val sink = TestSink() (field as UiDataProvider).uiDataSnapshot(sink) - val file = sink.file as? TextEditor ?: error("missing file editor") - assertNotNull(file) - assertSame(editor.document, file.editor.document) - UndoManager.getInstance(project).undo(file) - assertEquals("", editor.document.text) - UndoManager.getInstance(project).redo(file) - assertEquals("hello", editor.document.text) + assertNull(sink.file) + assertSame(true, editor.getUserData(EditorTextField.SUPPLEMENTARY_KEY)) } - fun `test prompt editor platform undo redo actions target prompt editor`() { + fun `test prompt editor platform undo redo actions do not throw`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) val field = panel.defaultFocusedComponent as EditorTextField @@ -488,13 +478,10 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(true, editor.contentComponent.getClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER)) val sink = TestSink() (field as UiDataProvider).uiDataSnapshot(sink) - val file = sink.file as? TextEditor ?: error("missing file editor") - assertSame(editor.document, file.editor.document) - assertTrue("prompt file editor should have undo", UndoManager.getInstance(project).isUndoAvailable(file)) + assertNull(sink.file) - invokeAction(IdeActions.ACTION_UNDO, editor.contentComponent, file) - assertEquals("", editor.document.text) - invokeAction(IdeActions.ACTION_REDO, editor.contentComponent, file) + updatePlatformAction(IdeActions.ACTION_UNDO, editor) + updatePlatformAction(IdeActions.ACTION_REDO, editor) assertEquals("hello", editor.document.text) } @@ -1364,21 +1351,18 @@ class PromptPanelTest : BasePlatformTestCase() { UIUtil.dispatchAllInvocationEvents() } - private fun invokeAction(id: String, component: java.awt.Component, file: TextEditor) { + private fun updatePlatformAction(id: String, editor: Editor) { val action = ActionManager.getInstance().getAction(id) ?: error("missing action $id") val ctx = DataContext { data -> when (data) { CommonDataKeys.PROJECT.name -> project - PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> component - PlatformCoreDataKeys.FILE_EDITOR.name -> file + CommonDataKeys.EDITOR.name -> editor + PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> editor.contentComponent else -> null } } val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null) ActionUtil.updateAction(action, event) - assertTrue("action $id should be enabled", event.presentation.isEnabled) - ActionUtil.performAction(action, event) - UIUtil.dispatchAllInvocationEvents() } private fun waitForLookupItems(editor: Editor): List { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt index 8be8ad98da..2015edebba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.settings.context.ContextConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable @@ -29,6 +30,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { assertEquals("ai.kilocode.jetbrains.settings.models", ModelsConfigurable.ID) } + fun `test child context id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.context", ContextConfigurable.ID) + } + fun `test child provider and behavior ids match xml registration`() { assertEquals("ai.kilocode.jetbrains.settings.providers", ProvidersConfigurable.ID) assertEquals("ai.kilocode.jetbrains.settings.agentBehavior", AgentBehaviorConfigurable.ID) @@ -79,12 +84,21 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { } } + fun `test createComponent contains Context link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val links = links(panel as Container) + assertTrue("expected a link labeled 'Context'", links.any { it.text == "Context" }) + } + } + fun `test createComponent contains settings links in order`() { val cfg = KiloSettingsConfigurable() edt { val panel = cfg.createComponent() val labels = links(panel as Container).map { it.text } - assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior"), labels) + assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior", "Context"), labels) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurableTest.kt index 5f79482b75..eee23f2772 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurableTest.kt @@ -18,6 +18,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() { fun `test child ids match xml registration`() { assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.agents", AgentsConfigurable.ID) assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.mcp", McpConfigurable.ID) + assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.skills", SkillsConfigurable.ID) } fun `test createComponent contains child links in order`() { @@ -26,7 +27,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() { edt { val panel = cfg.createComponent() val labels = links(panel as Container).map { it.text } - assertEquals(listOf("Agents", "MCP Servers"), labels) + assertEquals(listOf("Agents", "MCP Servers", "Skills"), labels) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt new file mode 100644 index 0000000000..b335631ea5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt @@ -0,0 +1,615 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.settingsListCellBounds +import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.fire +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.SkillDto +import ai.kilocode.rpc.dto.SkillsConfigDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.TestDialog +import com.intellij.openapi.ui.TestDialogManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import com.intellij.ui.TitledSeparator +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +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.BorderLayout +import java.awt.Container +import java.awt.Dimension +import java.awt.Point +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable +import javax.swing.JTextField + +class SkillsSettingsUiTest : BasePlatformTestCase() { + private var scope: CoroutineScope? = null + private var ui: SkillsSettingsUi? = null + private lateinit var app: KiloAppService + private lateinit var appRpc: FakeAppRpcApi + private lateinit var agentRpc: FakeAgentBehaviorRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private var shown = 0 + + override fun tearDown() { + try { + TestDialogManager.setTestDialog(TestDialog.DEFAULT) + ui?.let { panel -> edt { panel.dispose(); true } } + ui = null + scope?.cancel() + scope = null + } finally { + super.tearDown() + } + } + + fun `test loads skills with location note and builtins have no actions`() { + val panel = panel() + + flushUntil { rows(panel).size == 3 } + + edt { + val rows = rows(panel) + val custom = rows.single { it.key == CUSTOM } + assertEquals("plan", custom.title) + assertEquals(CUSTOM, custom.note) + assertEquals("Plan work", custom.description) + assertEquals("edit", custom.doubleClick) + assertEquals(listOf("open", "edit", "delete"), custom.cells.map { it.id }) + assertTrue(custom.cells.single { it.id == "open" }.primary) + assertFalse(custom.cells.single { it.id == "edit" }.primary) + assertEquals("Edit", custom.cells.single { it.id == "edit" }.label) + assertTrue(custom.cells.single { it.id == "delete" }.iconOnly) + val builtin = rows.single { it.key == "builtin" } + assertEquals("thinking", builtin.title) + assertNull(builtin.note) + assertEquals("edit", builtin.doubleClick) + assertEquals(listOf("built-in"), builtin.badges.map { it.text }) + assertEquals(listOf("edit"), builtin.cells.map { it.id }) + assertEquals("Open", builtin.cells.single().label) + val remote = rows.single { it.key == REMOTE } + assertEquals(listOf("edit"), remote.cells.map { it.id }) + assertEquals("Open", remote.cells.single().label) + assertEquals(listOf(DIR), agentRpc.skillCalls) + true + } + } + + fun `test skills list is vertically scrolled without horizontal scrollbar`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val pane = scrollFor(panel, skillsList(panel)) + val view = pane.viewport.view + val layout = panel.content.layout as BorderLayout + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, pane.horizontalScrollBarPolicy) + assertTrue((view as Scrollable).getScrollableTracksViewportWidth()) + assertFalse(view.getScrollableTracksViewportHeight()) + assertSame(pane, layout.getLayoutComponent(BorderLayout.CENTER)) + assertSame(panel.sources, layout.getLayoutComponent(BorderLayout.SOUTH)) + true + } + } + + fun `test sources section has additional sources title`() { + val panel = panel() + flushUntil { sourceRows(panel).size == 2 } + + assertTrue(edt { + components(panel).filterIsInstance().any { it.text == "Additional Skill Sources" } + }) + } + + fun `test skills list does not show description tooltips`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val list = skillsList(panel) + list.size = Dimension(520, 320) + list.doLayout() + val bounds = list.getCellBounds(0, 0) + + assertNull(list.getToolTipText(mouse(list, MouseEvent.MOUSE_MOVED, Point(bounds.x + 8, bounds.y + 8)))) + true + } + } + + fun `test renderer puts location on first line and description on preview line`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val list = skillsList(panel) + val row = rows(panel).single { it.key == CUSTOM } + val idx = rows(panel).indexOf(row) + val comp = list.cellRenderer.getListCellRendererComponent(list, row, idx, true, true) + comp.setSize(520, list.fixedCellHeight) + layout(comp) + val title = components(comp).filterIsInstance().single() + val labels = components(comp).filterIsInstance().filter { it.isVisible }.map { it.text } + + assertEquals("plan $CUSTOM", title.toString()) + assertTrue(labels.contains("Plan work")) + true + } + } + + fun `test double click stages skill content until apply`() { + val panel = panel(edit = { _, _ -> FakeSkillDialog("# Saved") }) + flushUntil { rows(panel).size == 3 } + + doubleClick(skillsList(panel), panel, CUSTOM) + + assertTrue(edt { panel.modified() }) + assertTrue(agentRpc.skillSaves.isEmpty()) + edt { panel.applyDraft(); true } + flushUntil { agentRpc.skillSaves.size == 1 } + assertEquals(Triple(DIR, CUSTOM, "# Saved"), agentRpc.skillSaves.single()) + } + + fun `test edited skill row keeps normal actions`() { + val panel = panel(edit = { _, _ -> FakeSkillDialog("# Draft") }) + flushUntil { rows(panel).size == 3 } + + doubleClick(skillsList(panel), panel, CUSTOM) + + assertEquals(listOf("open", "edit", "delete"), edt { rows(panel).single { it.key == CUSTOM }.cells.map { it.id } }) + assertTrue(edt { panel.modified() }) + } + + fun `test reopening staged skill edit shows draft content before apply`() { + val seen = mutableListOf() + val panel = panel(edit = { skill, _ -> + seen += skill.content + FakeSkillDialog(if (seen.size == 1) "# Draft" else "# Draft 2") + }) + flushUntil { rows(panel).size == 3 } + + doubleClick(skillsList(panel), panel, CUSTOM) + doubleClick(skillsList(panel), panel, CUSTOM) + + assertEquals(listOf("# Plan\nUse steps", "# Draft"), seen) + assertTrue(agentRpc.skillSaves.isEmpty()) + } + + fun `test open in editor action opens skill file`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + click(skillsList(panel), panel, CUSTOM, "open") + + assertEquals("The skill file will open after you close Settings.", edt { progressText(panel) }) + flushUntil { workspaceRpc.openedFiles.size == 1 } + assertEquals(FakeWorkspaceRpcApi.Opened(CUSTOM, null, null), workspaceRpc.openedFiles.single()) + } + + fun `test read only skills open without staging edits or editor file open`() { + shown = 0 + val panel = panel(edit = { _, savable -> + assertFalse(savable) + FakeSkillDialog("# Ignored") { shown += 1 } + }) + flushUntil { rows(panel).size == 3 } + + click(skillsList(panel), panel, REMOTE, "edit") + + assertEquals(1, shown) + assertFalse(edt { panel.modified() }) + assertTrue(agentRpc.skillSaves.isEmpty()) + assertTrue(workspaceRpc.openedFiles.isEmpty()) + } + + fun `test skill edit dialog shows content with fallback`() { + edt { + val content = SkillEditDialog(SkillDto("plan", "desc", CUSTOM, "# Plan\nUse steps"), true) + val fallback = SkillEditDialog(SkillDto("plan", "desc", CUSTOM), true) + val readonly = SkillEditDialog(SkillDto("kilo-config", "desc", "builtin", "

Kilo Config

"), false) + try { + assertEquals("# Plan\nUse steps", content.content()) + assertEquals("desc", fallback.content()) + assertEquals("

Kilo Config

", readonly.content()) + assertEquals("OK", content.okText()) + } finally { + content.close(DialogWrapper.CANCEL_EXIT_CODE) + fallback.close(DialogWrapper.CANCEL_EXIT_CODE) + readonly.close(DialogWrapper.CANCEL_EXIT_CODE) + } + true + } + } + + fun `test skill editor file type follows content syntax before location`() { + assertEquals( + FileTypeManager.getInstance().getFileTypeByFileName("index.html"), + skillFileType("builtin", "

Kilo CLI Configuration Reference

All config lives in kilo.json.

"), + ) + assertEquals( + skillFileType("SKILL.md"), + skillFileType("builtin", "# Kilo CLI Configuration Reference\n\nAll config lives in `kilo.json`."), + ) + assertEquals(PlainTextFileType.INSTANCE, skillFileType("builtin", "Plain fallback text")) + } + + + fun `test delete action stages skill removal until apply`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(skillsList(panel), panel, CUSTOM, "delete") + + assertTrue(edt { rows(panel).none { it.key == CUSTOM } }) + assertTrue(agentRpc.skillRemovals.isEmpty()) + edt { panel.applyDraft(); true } + flushUntil { agentRpc.skillRemovals.size == 1 } + assertEquals(listOf(DIR to CUSTOM), agentRpc.skillRemovals) + } + + fun `test delete action requires confirmation`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + TestDialogManager.setTestDialog { Messages.NO } + + click(skillsList(panel), panel, CUSTOM, "delete") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(agentRpc.skillRemovals.isEmpty()) + assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) + } + + fun `test add path and url write skills config patch on apply`() { + var path = "/extra/skills" + var url = "https://skills.test/index.json" + val panel = panel(choose = { path }, input = { _, _ -> url }) + flushUntil { rows(panel).size == 3 } + + edt { panel.sources.addPath(); true } + edt { panel.sources.addUrl(); true } + flushUntil { sourceRows(panel).any { it.key == "url:$url" } } + assertTrue(appRpc.configPatches.isEmpty()) + + edt { panel.applyDraft(); true } + flushUntil { appRpc.configPatches.size == 1 && !edt { panel.modified() } } + + val paths = appRpc.configPatches.single().skills!!.paths + val urls = appRpc.configPatches.single().skills!!.urls + assertEquals(listOf("/global/skills", path), paths) + assertEquals(listOf("https://skills.test/base.json", url), urls) + assertEquals( + listOf("path:/global/skills", "path:$path", "url:https://skills.test/base.json", "url:$url"), + edt { sourceRows(panel).map { it.key } }, + ) + assertEquals(listOf(DIR), agentRpc.skillReloads) + } + + fun `test stale config update result keeps added skill sources visible`() { + val path = "/extra/skills" + val url = "https://skills.test/index.json" + val extra = "$path/extra/SKILL.md" + val panel = panel(choose = { path }, input = { _, _ -> url }) + appRpc.configUpdateReturnStale = true + appRpc.afterConfig = { agentRpc.skills = agentRpc.skills + SkillDto("extra", "Extra skill", extra) } + flushUntil { rows(panel).size == 3 } + + edt { + panel.sources.addPath() + panel.sources.addUrl() + panel.applyDraft() + true + } + + flushUntil { appRpc.configPatches.size == 1 && !edt { panel.modified() } } + assertTrue(edt { rows(panel).any { it.key == extra } }) + assertEquals( + listOf("path:/global/skills", "path:$path", "url:https://skills.test/base.json", "url:$url"), + edt { sourceRows(panel).map { it.key } }, + ) + } + + fun `test blocked reload completes apply with warning`() { + val path = "/extra/skills" + val panel = panel(choose = { path }) + agentRpc.reloadSkillResult = false + flushUntil { rows(panel).size == 3 } + + edt { + panel.sources.addPath() + panel.applyDraft() + true + } + + flushUntil { appRpc.configPatches.size == 1 && !edt { panel.modified() } } + assertEquals(listOf(DIR), agentRpc.skillReloads) + assertEquals("Skills settings saved, but active sessions are present. Reload the core after those sessions finish to apply the new skills.", edt { progressText(panel) }) + } + + fun `test post apply skills refresh failure keeps saved rows`() { + val panel = panel(edit = { _, _ -> FakeSkillDialog("# Saved") }) + flushUntil { rows(panel).size == 3 } + + doubleClick(skillsList(panel), panel, CUSTOM) + agentRpc.skillsError = RuntimeException("timeout") + edt { panel.applyDraft(); true } + + flushUntil { agentRpc.skillSaves.size == 1 && !edt { panel.modified() } } + assertEquals(listOf(CUSTOM, "builtin", REMOTE), edt { rows(panel).map { it.key } }) + assertEquals("# Saved", agentRpc.skills.single { it.location == CUSTOM }.content) + } + + fun `test source reset discards staged changes`() { + val path = "/extra/skills" + val panel = panel(choose = { path }) + flushUntil { rows(panel).size == 3 } + + edt { panel.sources.addPath(); true } + + assertTrue(edt { sourceRows(panel).any { it.key == "path:$path" } }) + assertTrue(edt { panel.modified() }) + edt { panel.resetDraft(); true } + + assertTrue(appRpc.configPatches.isEmpty()) + assertEquals(listOf(CUSTOM, "builtin", REMOTE), edt { rows(panel).map { it.key } }) + assertFalse(edt { sourceRows(panel).any { it.key == "path:$path" } }) + assertTrue(agentRpc.skillReloads.isEmpty()) + } + + fun `test delete source writes skills config patch`() { + val panel = panel() + flushUntil { rows(panel).size == 3 && sourceRows(panel).size == 2 } + + edt { + sourceList(panel).selectedIndices = intArrayOf(0) + panel.sources.removeSelected() + true + } + + assertTrue(appRpc.configPatches.isEmpty()) + edt { panel.applyDraft(); true } + flushUntil { appRpc.configPatches.size == 1 && !edt { panel.modified() } } + val patch = appRpc.configPatches.single().skills!! + assertEquals(emptyList(), patch.paths) + assertEquals(listOf("https://skills.test/base.json"), patch.urls) + assertEquals(listOf("url:https://skills.test/base.json"), edt { sourceRows(panel).map { it.key } }) + assertEquals(listOf(DIR), agentRpc.skillReloads) + } + + fun `test stale config update result keeps removed skill sources hidden`() { + val panel = panel() + appRpc.configUpdateReturnStale = true + appRpc.afterConfig = { agentRpc.skills = agentRpc.skills.filterNot { it.location == CUSTOM } } + flushUntil { rows(panel).size == 3 && sourceRows(panel).size == 2 } + + edt { + sourceList(panel).selectedIndices = intArrayOf(0) + panel.sources.removeSelected() + panel.applyDraft() + true + } + + flushUntil { appRpc.configPatches.size == 1 && !edt { panel.modified() } } + assertEquals(listOf("builtin", REMOTE), edt { rows(panel).map { it.key } }) + assertEquals(listOf("url:https://skills.test/base.json"), edt { sourceRows(panel).map { it.key } }) + } + + fun `test search filters skills by name`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + components(panel).filterIsInstance().single().text = "think" + UIUtil.dispatchAllInvocationEvents() + true + } + + flushUntil { rows(panel).map { it.key } == listOf("builtin") } + } + + fun `test skills reload failure keeps existing rows`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.skillsError = RuntimeException("timeout") + + edt { panel.reload(); true } + flushUntil { edt { skillsList(panel).isEnabled } } + + assertEquals(listOf(CUSTOM, "builtin", REMOTE), edt { rows(panel).map { it.key } }) + } + + fun `test skill editor file type follows location extension`() { + assertNotSame(UnknownFileType.INSTANCE, skillFileType("/tmp/skills/plan/SKILL.md")) + assertEquals( + FileTypeManager.getInstance().getFileTypeByFileName("index.html"), + skillFileType("/tmp/skills/index.html"), + ) + assertEquals(PlainTextFileType.INSTANCE, skillFileType("/tmp/skills/index.unknown")) + } + + fun `test skill path chooser accepts directories only`() { + val descriptor = skillPathDescriptor() + + assertTrue(descriptor.isChooseFolders) + assertFalse(descriptor.isChooseFiles) + } + + private fun panel( + choose: (JComponent) -> String? = { null }, + input: (String, String) -> String? = { _, _ -> null }, + edit: (SkillDto, Boolean) -> SkillEditDialogHandle = { _, _ -> FakeSkillDialog("# Plan\nUse steps") }, + ): SkillsSettingsUi { + install() + val panel = edt { SkillsSettingsUi(scope!!, DIR, choose, input, edit) } + ui = panel + edt { panel.reload(); true } + return panel + } + + private fun install() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + appRpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + agentRpc = FakeAgentBehaviorRpcApi().apply { + skills = listOf( + SkillDto("plan", "Plan work", CUSTOM, "# Plan\nUse steps", editable = true), + SkillDto("thinking", "Built in", "builtin", "Built in content"), + SkillDto("remote", "Remote skill", REMOTE, "# Remote skill"), + ) + } + app = KiloAppService(cs, appRpc) + val ready = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(skills = SkillsConfigDto( + paths = listOf("/global/skills"), + urls = listOf("https://skills.test/base.json"), + )), + ) + app._state.value = ready + appRpc.state.value = ready + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable) + } + + private fun click(list: JBList, panel: SkillsSettingsUi, key: String, id: String) { + edt { + list.size = Dimension(520, 320) + list.doLayout() + val rows = if (list === skillsList(panel)) rows(panel) else sourceRows(panel) + val idx = rows.indexOfFirst { it.key == key } + list.selectedIndex = idx + val area = settingsListCellBounds(list, idx, selected = true).getValue(id) + click(list, center(area)) + true + } + } + + private fun doubleClick(list: JBList, panel: SkillsSettingsUi, key: String) { + edt { + list.size = Dimension(520, 320) + list.doLayout() + val idx = rows(panel).indexOfFirst { it.key == key } + list.selectedIndex = idx + val area = list.getCellBounds(idx, idx) + fire(list, mouse(list, MouseEvent.MOUSE_CLICKED, center(area), count = 2)) + true + } + } + + private fun rows(panel: SkillsSettingsUi): List = items(skillsList(panel)) + + private fun sourceRows(panel: SkillsSettingsUi): List = items(sourceList(panel)) + + private fun items(list: JBList): List { + val model = list.model + return (0 until model.size).map { model.getElementAt(it) } + } + + private fun skillsList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().first() + + private fun sourceList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().last() + + private fun scrollFor(panel: SkillsSettingsUi, list: JBList) = components(panel) + .filterIsInstance() + .single { pane -> pane.viewport.view === list.parent } + + private fun progressText(panel: SkillsSettingsUi) = components(panel.progress).filterIsInstance().single().text + + private fun SkillEditDialog.okText(): String { + val method = DialogWrapper::class.java.getDeclaredMethod("getOKAction") + method.isAccessible = true + return (method.invoke(this) as javax.swing.Action).getValue(javax.swing.Action.NAME) as String + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(item: java.awt.Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + + private fun layout(root: java.awt.Component) { + root.doLayout() + if (root is Container) root.components.filterIsInstance().forEach { layout(it) } + UIUtil.dispatchAllInvocationEvents() + } + + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) + + private fun click(list: JBList, point: Point) { + fire(list, mouse(list, MouseEvent.MOUSE_PRESSED, point)) + fire(list, mouse(list, MouseEvent.MOUSE_RELEASED, point)) + } + + private fun mouse(list: JBList, id: Int, point: Point, count: Int = 1) = MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + count, + false, + MouseEvent.BUTTON1, + ) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(300) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents(); true } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(done()) + } + + private companion object { + const val DIR = "/test" + const val CUSTOM = "/home/test/.config/kilo/skill/plan/SKILL.md" + const val REMOTE = "/home/test/.cache/kilo/skills/remote/SKILL.md" + } +} + +private class FakeSkillDialog(private val text: String, private val show: () -> Unit = {}) : SkillEditDialogHandle { + override fun showAndGet(): Boolean { + show() + return true + } + override fun content() = text +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt index 89efbeb3de..f9cb8f4b8c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt @@ -88,6 +88,66 @@ class SettingsDraftStateTest { assertFalse(state.modified()) } + @Test + fun `stale external base after fallback completion does not revert applied target`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "old") + + state.accept("old") + + assertEquals("new", state.baseline) + assertEquals("new", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `stale external base after fresh completion does not revert applied target`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "new") + + state.accept("old") + + assertEquals("new", state.baseline) + assertEquals("new", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `fresh external base after ignored stale update is accepted`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "old") + state.accept("old") + + state.accept("other") + + assertEquals("other", state.baseline) + assertEquals("other", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `older stale external base after multiple saves is ignored`() { + val state = SettingsDraftState("old") + state.update { "new" } + val first = state.start()!! + state.complete(first, "new") + state.update { "other" } + val second = state.start()!! + state.complete(second, "other") + + state.accept("old") + + assertEquals("other", state.baseline) + assertEquals("other", state.draft) + assertFalse(state.modified()) + } + @Test fun `failed save keeps draft dirty and restores previous base`() { val state = SettingsDraftState("old") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index ca7f5cd7c8..d7f79d4ddf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -14,6 +14,9 @@ import java.awt.Dimension import java.awt.Point import java.awt.event.InputEvent import java.awt.event.MouseEvent +import javax.swing.ListSelectionModel +import javax.swing.Scrollable +import javax.swing.SwingConstants import javax.swing.SwingUtilities class SettingsListViewTest : BasePlatformTestCase() { @@ -236,6 +239,25 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test action click invokes on second selected row in multi selection list`() { + edt { + val calls = mutableListOf() + val cfg = SettingsListConfig.Equal.copy(selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION) + val view = SettingsListView("Empty", cfg) { key, id -> calls += "$key:$id" } + view.update(listOf( + item("a", "Alpha", null, SettingsListCell("edit", "Edit", alwaysVisible = false)), + item("b", "Beta", null, SettingsListCell("edit", "Edit", alwaysVisible = false)), + )) + layout(view) + view.list.selectedIndices = intArrayOf(0, 1) + + val area = settingsListCellBounds(view.list, 1, selected = true).getValue("edit") + click(view, center(area)) + + assertEquals(listOf("b:edit"), calls) + } + } + fun `test update selects preferred key`() { edt { val view = SettingsListView("Empty") { _, _ -> } @@ -260,6 +282,17 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test list view tracks viewport width`() { + edt { + val view = SettingsListView("Empty") { _, _ -> } + view.update(listOf(item("long", "Alpha", "A very long description that should wrap instead of scrolling"))) + + assertTrue((view as Scrollable).getScrollableTracksViewportWidth()) + assertFalse(view.getScrollableTracksViewportHeight()) + assertEquals(160, view.getScrollableBlockIncrement(java.awt.Rectangle(0, 0, 320, 160), SwingConstants.VERTICAL, 1)) + } + } + private fun item(id: String, name: String, note: String?, vararg cells: SettingsListCell) = object : SettingsListItem { override val key = id override val title = name diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt new file mode 100644 index 0000000000..8fc6dd6179 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt @@ -0,0 +1,76 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.rpc.dto.CompactionConfigDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.WatcherConfigDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContextSettingsStateTest { + @Test + fun `draft reads context config`() { + val draft = contextDraft(ConfigDto( + watcher = WatcherConfigDto(ignore = listOf("**/dist/**")), + compaction = CompactionConfigDto(auto = true, threshold_percent = 75.0, prune = true), + )) + + assertEquals(true, draft.auto) + assertEquals("75", draft.threshold) + assertEquals(true, draft.prune) + assertEquals(listOf("**/dist/**"), draft.ignore) + } + + @Test + fun `unchanged draft emits no patch`() { + val draft = ContextDraft(auto = true, threshold = "75", prune = false, ignore = listOf("tmp/**")) + + assertEquals(false, patch(draft, draft)?.let(::changed)) + } + + @Test + fun `boolean false values are emitted`() { + val from = ContextDraft(auto = true, prune = true) + val to = ContextDraft(auto = false, prune = false) + val patch = patch(from, to) + + assertEquals(false, patch?.compaction?.auto) + assertEquals(false, patch?.compaction?.prune) + } + + @Test + fun `threshold set and clear use explicit semantics`() { + val from = ContextDraft(threshold = "") + val set = ContextDraft(threshold = "80") + val clear = ContextDraft(threshold = "") + + assertEquals(80.0, patch(from, set)?.compaction?.threshold_percent) + assertEquals(listOf("threshold_percent"), patch(set, clear)?.compaction?.clear) + assertNull(patch(set, clear)?.compaction?.threshold_percent) + } + + @Test + fun `watcher empty list is emitted`() { + val from = ContextDraft(ignore = listOf("**/dist/**")) + val to = ContextDraft(ignore = emptyList()) + + assertEquals(emptyList(), patch(from, to)?.watcher?.ignore) + } + + @Test + fun `invalid threshold prevents patch without looking like no changes`() { + val from = ContextDraft(threshold = "50") + val to = ContextDraft(auto = true, threshold = "101", prune = true, ignore = listOf("tmp/**")) + + assertEquals(ThresholdStatus.INVALID, thresholdStatus(to.threshold)) + assertNull(patch(from, to)) + } + + @Test + fun `saved match normalizes threshold formatting`() { + assertTrue(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "75.0"))) + assertFalse(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "76"))) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt new file mode 100644 index 0000000000..80c5a2e1c4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt @@ -0,0 +1,324 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CompactionConfigDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.WatcherConfigDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Container +import java.awt.event.MouseEvent +import javax.swing.AbstractButton +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.ListSelectionModel +import javax.swing.JTextField +import javax.swing.text.AbstractDocument +import javax.swing.text.JTextComponent + +class ContextSettingsUiTest : BasePlatformTestCase() { + private lateinit var appScope: CoroutineScope + private lateinit var uiScope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private var ui: ContextSettingsUi? = null + + override fun setUp() { + super.setUp() + appScope = CoroutineScope(SupervisorJob()) + uiScope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + app = KiloAppService(appScope, rpc) + workspaces = KiloWorkspaceService(appScope, workspaceRpc) + val state = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto( + watcher = WatcherConfigDto(ignore = listOf("tmp/**")), + compaction = CompactionConfigDto(auto = true, threshold_percent = 75.0, prune = true), + ), + ) + rpc.state.value = state + app._state.value = state + edt { ui = ContextSettingsUi(uiScope, app, workspaces) } + flushUntil { text(requireUi()).contains("Auto Compaction") } + } + + override fun tearDown() { + try { + val panel = ui + if (panel != null) edt { panel.dispose() } + ui = null + uiScope.cancel() + appScope.cancel() + } finally { + super.tearDown() + } + } + + fun `test toggling compaction sends boolean false values`() { + val panel = requireUi() + + edt { + val toggles = components(panel).filterIsInstance() + toggles[0].doClick() + toggles[1].doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + val patch = rpc.configPatches.single() + assertEquals(false, patch.compaction?.auto) + assertEquals(false, patch.compaction?.prune) + } + + fun `test editing threshold sends number`() { + val panel = requireUi() + + edt { + threshold(panel).text = "80" + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(80.0, rpc.configPatches.single().compaction?.threshold_percent) + } + + fun `test threshold row shows percent label and rejects out of range values`() { + val panel = requireUi() + + edt { + val field = threshold(panel) + assertTrue(text(panel).contains("%")) + assertTrue(text(panel).contains("Auto Compaction Limit")) + assertTrue(text(panel).contains("Prune Old Outputs")) + assertEquals("Default", field.emptyText.text) + field.text = "" + field.text = "101" + assertEquals("", field.text) + field.text = "100" + assertEquals("100", field.text) + (field.document as AbstractDocument).replace(0, field.document.length, "-1", null) + assertEquals("100", field.text) + } + } + + fun `test clearing threshold sends clear patch`() { + val panel = requireUi() + + edt { + threshold(panel).text = "" + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("threshold_percent"), rpc.configPatches.single().compaction?.clear) + } + + fun `test adding watcher pattern sends full list`() { + val panel = requireUi() + + edt { + val patterns = components(panel).filterIsInstance().single() + patterns.input = { "**/dist/**" } + icon(panel, "Add pattern").doClick() + assertEquals(listOf("**/dist/**"), patternList(panel).selectedValuesList) + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("tmp/**", "**/dist/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test stale config update result keeps watcher pattern visible`() { + val panel = requireUi() + rpc.configUpdateReturnStale = true + + edt { + val patterns = components(panel).filterIsInstance().single() + patterns.input = { "**/dist/**" } + icon(panel, "Add pattern").doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() && !edt { panel.modified() } } + edt { + val list = patternList(panel) + assertEquals(listOf("**/dist/**"), list.selectedValuesList) + assertEquals(listOf("tmp/**", "**/dist/**"), (0 until list.model.size).map { list.model.getElementAt(it) }) + } + } + + fun `test removing selected watcher patterns supports multi selection`() { + val panel = requireUi() + + edt { + val patterns = components(panel).filterIsInstance().single() + val inputs = ArrayDeque(listOf("**/dist/**", "**/build/**")) + patterns.input = { inputs.removeFirst() } + icon(panel, "Add pattern").doClick() + icon(panel, "Add pattern").doClick() + val list = patternList(panel) + assertEquals(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, list.selectionMode) + list.setSelectionInterval(0, 1) + icon(panel, "Remove selected patterns").doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("**/build/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test double clicking watcher pattern edits it`() { + val panel = requireUi() + + edt { + val patterns = components(panel).filterIsInstance().single() + patterns.editor = { "**/edited/**" } + val list = patternList(panel) + list.setSize(400, 100) + list.doLayout() + val bounds = list.getCellBounds(0, 0) + val event = MouseEvent( + list, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + bounds.x + 1, + bounds.y + 1, + 2, + false, + MouseEvent.BUTTON1, + ) + list.mouseListeners.forEach { it.mouseClicked(event) } + assertEquals(listOf("**/edited/**"), list.selectedValuesList) + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("**/edited/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test watcher pattern renderer has left inset`() { + val panel = requireUi() + + edt { + val list = patternList(panel) + val comp = list.cellRenderer.getListCellRendererComponent(list, "tmp/**", 0, false, false) as JComponent + assertTrue(comp.insets.left > 0) + } + } + + fun `test watcher section does not repeat ignored patterns row title`() { + val panel = requireUi() + + edt { + assertFalse(text(panel).contains("Ignored patterns")) + assertTrue(text(panel).contains("File Watcher Ignore Patterns")) + assertEquals(1, components(panel).filterIsInstance().size) + } + } + + fun `test failed apply stays visible while panel open`() { + val panel = requireUi() + rpc.configUpdateError = RuntimeException("save failed") + + edt { + threshold(panel).text = "80" + panel.applyDraft() + } + + flushUntil { text(panel).contains("Failed to save context settings") } + edt { + assertTrue(text(panel.progress).contains("Failed to save context settings")) + assertTrue(panel.modified()) + } + } + + fun `test controls are disabled during pending save`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + threshold(panel).text = "80" + panel.applyDraft() + assertTrue(components(panel).filterIsInstance().all { !it.isEnabled }) + assertFalse(threshold(panel).isEnabled) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + private fun requireUi(): ContextSettingsUi = requireNotNull(ui) + + private fun threshold(panel: ContextSettingsUi): JBTextField = components(panel) + .filterIsInstance() + .single { it.columns == 8 } + + private fun patternList(panel: ContextSettingsUi): JBList { + val list = components(panel).filterIsInstance>().single() + @Suppress("UNCHECKED_CAST") + return list as JBList + } + + private fun icon(panel: ContextSettingsUi, tip: String): HoverIcon = components(panel) + .filterIsInstance() + .single { it.toolTipText == tip } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(200) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index bbcc487317..1d5d5c5afb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -11,9 +11,14 @@ import ai.kilocode.rpc.dto.SkillDto class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var agents = emptyList() + var skills = emptyList() var mcps = emptyList() var mcpConfigs = emptyMap() val agentCalls = mutableListOf() + val skillCalls = mutableListOf() + val skillRemovals = mutableListOf>() + val skillReloads = mutableListOf() + val skillSaves = mutableListOf>() val mcpCalls = mutableListOf() val mcpConfigCalls = mutableListOf() val mcpSaves = mutableListOf>() @@ -27,10 +32,16 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var afterRemove: (suspend (String, String) -> Unit)? = null var afterMcpConnect: (suspend (String, String) -> Unit)? = null var createError: Exception? = null + var skillsError: Exception? = null var removeError: Exception? = null + var removeSkillError: Exception? = null + var saveSkillError: Exception? = null var mcpStatusError: Exception? = null var mcpConnectError: Exception? = null var removeResult = true + var removeSkillResult = true + var reloadSkillResult = true + var saveSkillResult = true var mcpConnectResult = true var mcpDisconnectResult = true var mcpAuthenticateResult = true @@ -43,12 +54,41 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { override suspend fun skills(directory: String): List { assertNotEdt("agentBehavior.skills") - return emptyList() + skillsError?.let { throw it } + skillCalls.add(directory) + return skills } override suspend fun removeSkill(directory: String, location: String): Boolean { assertNotEdt("agentBehavior.removeSkill") - return false + removeSkillError?.let { throw it } + skillRemovals.add(directory to location) + if (removeSkillResult) skills = skills.filterNot { it.location == location } + return removeSkillResult + } + + override suspend fun reloadSkills(directory: String): Boolean { + assertNotEdt("agentBehavior.reloadSkills") + skillReloads.add(directory) + return reloadSkillResult + } + + override suspend fun saveSkill(directory: String, location: String, content: String): Boolean { + assertNotEdt("agentBehavior.saveSkill") + saveSkillError?.let { throw it } + skillSaves.add(Triple(directory, location, content)) + if (saveSkillResult) skills = skills.map { if (it.location == location) it.copy(content = content) else it } + return saveSkillResult + } + + override suspend fun saveSkills(directory: String, edits: Map): Boolean { + assertNotEdt("agentBehavior.saveSkills") + saveSkillError?.let { throw it } + for ((location, content) in edits) skillSaves.add(Triple(directory, location, content)) + if (saveSkillResult) skills = skills.map { skill -> + edits[skill.location]?.let { skill.copy(content = it) } ?: skill + } + return saveSkillResult } override suspend fun removeAgent(directory: String, name: String): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index ed6e3e928f..57868aadf0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto @@ -16,6 +17,7 @@ import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.TelemetryCaptureDto +import ai.kilocode.rpc.dto.WatcherConfigDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -196,12 +198,34 @@ class FakeAppRpcApi : KiloAppRpcApi { val mcp = patch.mcp?.entries?.fold(config.mcp) { acc, (name, item) -> if (item == null) acc - name else acc + (name to item) } ?: config.mcp + val watcher = patch.watcher?.let { item -> + val cfg = config.watcher + cfg?.copy(ignore = item.ignore ?: cfg.ignore) + ?: WatcherConfigDto(ignore = item.ignore ?: emptyList()) + } ?: config.watcher + val compaction = patch.compaction?.let { item -> + val cfg = item.clear.fold(config.compaction ?: CompactionConfigDto()) { next, field -> + when (field) { + "threshold_percent" -> next.copy(threshold_percent = null) + "auto" -> next.copy(auto = null) + "prune" -> next.copy(prune = null) + else -> next + } + } + cfg.copy( + auto = item.auto ?: cfg.auto, + threshold_percent = item.threshold_percent ?: cfg.threshold_percent, + prune = item.prune ?: cfg.prune, + ) + } ?: config.compaction return config.copy( defaultAgent = if (values.containsKey("default_agent")) values["default_agent"] else config.defaultAgent, model = if (values.containsKey("model")) values["model"] else config.model, smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel, subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel, subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant, + watcher = watcher, + compaction = compaction, instructions = patch.instructions ?: config.instructions, skills = patch.skills?.let { SkillsConfigDto(paths = it.paths.orEmpty(), urls = it.urls.orEmpty()) } ?: config.skills, mcp = mcp, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt index 345ab0657c..b13c03d526 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt @@ -26,6 +26,12 @@ interface KiloAgentBehaviorRpcApi : RemoteApi { suspend fun removeSkill(directory: String, location: String): Boolean + suspend fun reloadSkills(directory: String): Boolean + + suspend fun saveSkill(directory: String, location: String, content: String): Boolean + + suspend fun saveSkills(directory: String, edits: Map): Boolean + suspend fun removeAgent(directory: String, name: String): Boolean suspend fun createAgent(directory: String, input: AgentCreateDto): Boolean diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 0356b6c0e6..1e267a0729 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -48,7 +48,7 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Resolve [path] to matching files, scoped primarily to [directory]. */ suspend fun files(directory: String, path: String): List - /** Fuzzy file/folder search via the backend IDE index. */ + /** Fuzzy file/folder search via Kilo Core. */ suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 4667d8f7b1..1ab2f302bd 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -64,12 +64,26 @@ data class ConfigDto( val subagentModel: String? = null, val subagentVariant: String? = null, val defaultAgent: String? = null, + val watcher: WatcherConfigDto? = null, + val compaction: CompactionConfigDto? = null, val instructions: List = emptyList(), val skills: SkillsConfigDto? = null, val mcp: Map = emptyMap(), val agent: Map = emptyMap(), ) +@Serializable +data class WatcherConfigDto( + val ignore: List = emptyList(), +) + +@Serializable +data class CompactionConfigDto( + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) + @Serializable data class SkillsConfigDto( val paths: List = emptyList(), @@ -109,12 +123,27 @@ sealed class PermissionRuleDto { @Serializable data class ConfigPatchDto( val values: Map = emptyMap(), + val watcher: WatcherPatchDto? = null, + val compaction: CompactionPatchDto? = null, val instructions: List? = null, val skills: SkillsPatchDto? = null, val mcp: Map? = null, val agents: Map = emptyMap(), ) +@Serializable +data class WatcherPatchDto( + val ignore: List? = null, +) + +@Serializable +data class CompactionPatchDto( + val clear: List = emptyList(), + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) + @Serializable data class AgentConfigPatchDto( val clear: List = emptyList(), diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt index 6bdd4f9805..50dfee7adf 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt @@ -7,4 +7,6 @@ data class SkillDto( val name: String, val description: String? = null, val location: String, + val content: String? = null, + val editable: Boolean = false, ) diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index 741fdcf8de..5b6a4aab73 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-memory/src/commands.ts b/packages/kilo-memory/src/commands.ts index 8fcdbb8d4c..0b7d48d453 100644 --- a/packages/kilo-memory/src/commands.ts +++ b/packages/kilo-memory/src/commands.ts @@ -2,13 +2,12 @@ export const MEMORY_COMMAND_CATALOG = [ { usage: "on", description: "Enable project memory" }, { usage: "off", description: "Disable project memory" }, { usage: "status", description: "Storage location and stored memory overview" }, - { usage: "show", description: "Full audit view (sources, index, changes, decisions)" }, + { usage: "show", description: "Stored project memory overview" }, { usage: "remember ", description: "Save a project memory note" }, { usage: "correct ", description: "Save a correction to project memory" }, { usage: "forget ", description: "Remove matching project memory" }, { usage: "auto on|off", description: "Turn automatic memory saves on or off" }, - { usage: "verbose on|off", description: "Turn verbose memory details on or off" }, - { usage: "edit", description: "Open project.md in $VISUAL/$EDITOR, then rebuild" }, + { usage: "inspect", description: "Reveal the project memory folder" }, { usage: "rebuild", description: "Rebuild the memory index from source files" }, { usage: "purge confirm", description: "Delete all project memory files" }, ] as const @@ -18,7 +17,7 @@ export const MEMORY_USAGE = `/memory [project] ${MEMORY_COMMAND_CATALOG.map((ite export const MEMORY_OPERATIONS = [ "enable", "status", - "edit", + "inspect", "disable", "rebuild", "remember", @@ -26,21 +25,13 @@ export const MEMORY_OPERATIONS = [ "forget", "purge", "auto", - "verbose", ] as const -export const MEMORY_PROMPT_OPERATIONS = ["remember", "forget"] as const - export type MemoryOperation = (typeof MEMORY_OPERATIONS)[number] -export type MemoryPromptOperation = (typeof MEMORY_PROMPT_OPERATIONS)[number] export function isMemoryOperation(input: unknown): input is MemoryOperation { return typeof input === "string" && (MEMORY_OPERATIONS as readonly string[]).includes(input) } -export function isMemoryPromptOperation(input: unknown): input is MemoryPromptOperation { - return typeof input === "string" && (MEMORY_PROMPT_OPERATIONS as readonly string[]).includes(input) -} - type Help = { kind: "help" } @@ -62,7 +53,7 @@ type Operation = } | { kind: "operation" - operation: "auto" | "verbose" + operation: "auto" mode: "on" | "off" } | { @@ -72,7 +63,7 @@ type Operation = } | { kind: "operation" - operation: Exclude + operation: Exclude } type Usage = { @@ -104,7 +95,7 @@ function usage(reason: string): ParsedMemoryCommand { function operation(verb: string, text: string): ParsedMemoryCommand | undefined { if (verb === "on" || verb === "enable") return { kind: "operation", operation: "enable" } if (verb === "off" || verb === "disable") return { kind: "operation", operation: "disable" } - if (verb === "status" || verb === "edit" || verb === "rebuild") { + if (verb === "status" || verb === "inspect" || verb === "rebuild") { return { kind: "operation", operation: verb } } if (verb === "purge") { @@ -116,11 +107,6 @@ function operation(verb: string, text: string): ParsedMemoryCommand | undefined if (mode === "on" || mode === "off") return { kind: "operation", operation: "auto", mode } return usage("Missing auto mode. Run /memory auto on or /memory auto off.") } - if (verb === "verbose") { - const mode = text.toLowerCase() - if (mode === "on" || mode === "off") return { kind: "operation", operation: "verbose", mode } - return usage("Missing verbose mode. Run /memory verbose on or /memory verbose off.") - } if (verb === "remember") { if (text) return { kind: "operation", operation: "remember", text } return usage("Missing text.") diff --git a/packages/kilo-memory/src/effect/index.ts b/packages/kilo-memory/src/effect/index.ts index ca94a98aa6..4a58e71bf3 100644 --- a/packages/kilo-memory/src/effect/index.ts +++ b/packages/kilo-memory/src/effect/index.ts @@ -46,7 +46,9 @@ export namespace KiloMemory { } export async function prepare(input: Input) { - return root(input) + const dir = root(input) + await MemoryFiles.cleanup(dir) + return dir } export async function status(input: Input) { diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts index 1369ea1c67..70c7c42bf3 100644 --- a/packages/kilo-memory/src/storage/audit.ts +++ b/packages/kilo-memory/src/storage/audit.ts @@ -1,13 +1,7 @@ -import { appendFile, chmod } from "fs/promises" -import path from "path" import z from "zod" import { MemoryFs } from "./fs" -import { MemoryPaths } from "./paths" -import { MemoryRedact } from "../capture/redact" export namespace MemoryAudit { - const MAX_LOG = 128_000 - const LOG_MARGIN = 16_000 const Log = z .object({ kind: z.literal("log"), @@ -49,45 +43,10 @@ export namespace MemoryAudit { }[] } - function cap(input: string) { - if (Buffer.byteLength(input) <= MAX_LOG) return input - const lines = input.split("\n").reverse() - const kept: string[] = [] - lines.reduce((sum, line) => { - if (sum >= MAX_LOG) return sum - kept.push(line) - return sum + Buffer.byteLength(`${line}\n`) - }, 0) - return kept.reverse().join("\n") - } - - async function line(file: string, text: string) { - await MemoryFs.dir(path.dirname(file)) - const info = await MemoryFs.guard(file) - if (info && !info.isFile()) throw new Error(`memory path is not a file: ${file}`) - await appendFile(file, text, { mode: MemoryFs.FILE }) - await chmod(file, MemoryFs.FILE).catch((error: unknown) => { - if (process.platform === "win32") return - throw error - }) - const next = await MemoryFs.guard(file) - if (!next?.isFile()) throw new Error(`memory path is not a file: ${file}`) - if (next.size <= MAX_LOG + LOG_MARGIN) return - await MemoryFs.write(file, cap((await MemoryFs.read(file)) ?? "")) - } - - async function audit(root: string, input: Decision) { - const data = MemoryRedact.value(input) as Decision - await MemoryFs.queue(root, () => - line( - MemoryPaths.files(root).decisions, - `${JSON.stringify({ - v: 1, - time: new Date().toISOString(), - ...data, - })}\n`, - ), - ) + function audit(root: string, input: Decision) { + void root + void input + return Promise.resolve() } export async function append(root: string, text: string) { @@ -99,12 +58,8 @@ export namespace MemoryAudit { } export async function readDecisions(root: string) { - return MemoryFs.read(MemoryPaths.files(root).decisions) - .then((text) => text ?? "") - .catch((error: unknown) => { - if (MemoryFs.miss(error)) return "" - throw error - }) + void root + return "" } function record(input: string) { diff --git a/packages/kilo-memory/src/storage/fs.ts b/packages/kilo-memory/src/storage/fs.ts index 3b0130ff18..e9df6dcf17 100644 --- a/packages/kilo-memory/src/storage/fs.ts +++ b/packages/kilo-memory/src/storage/fs.ts @@ -109,6 +109,15 @@ export namespace MemoryFs { return readFile(file, "utf8") } + export async function remove(file: string) { + await parents(path.dirname(file)) + const info = await guard(file) + if (!info) return false + if (!info.isFile()) throw new Error(`memory path is not a file: ${file}`) + await rm(file, { force: true }) + return true + } + export async function json(file: string) { const text = await read(file) return text === undefined ? undefined : JSON.parse(text) diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts index e3e1728ec3..bb373e2461 100644 --- a/packages/kilo-memory/src/storage/state.ts +++ b/packages/kilo-memory/src/storage/state.ts @@ -10,6 +10,9 @@ import { MemoryText } from "../text" import { MemoryTopics } from "../recall/topics" export namespace MemoryState { + const CLEAN_LIMIT = 128 + const CLEAN_RETRY_MS = 60_000 + const cleaned = new Map() const seed: Record = { "project.md": "# Project Memory\n\n## Facts\n\n## Decisions\n\n## Constraints\n\n## Open Questions\n", "environment.md": "# Environment Memory\n\n## Commands\n\n## Paths\n\n## Tooling\n", @@ -92,6 +95,38 @@ export namespace MemoryState { ) } + export async function cleanup(root: string) { + const retry = cleaned.get(root) + if (retry !== undefined && retry > Date.now()) return false + cleaned.delete(root) + const owns = await owned(root).catch((error: unknown) => { + MemoryFs.warn("failed to inspect legacy memory audit", { error, root }) + return undefined + }) + if (owns !== true) { + if (owns === undefined) cache(root, Date.now() + CLEAN_RETRY_MS) + return false + } + const removed = await MemoryFs.remove(MemoryPaths.files(root).decisions).catch((error: unknown) => { + MemoryFs.warn("failed to remove legacy memory audit", { error, root }) + return undefined + }) + if (removed === undefined) { + cache(root, Date.now() + CLEAN_RETRY_MS) + return false + } + cache(root, Number.POSITIVE_INFINITY) + return removed + } + + function cache(root: string, retry: number) { + cleaned.delete(root) + cleaned.set(root, retry) + if (cleaned.size <= CLEAN_LIMIT) return + const key = cleaned.keys().next().value + if (typeof key === "string") cleaned.delete(key) + } + export async function readIndex(root: string) { const file = MemoryPaths.files(root).index return MemoryFs.read(file) @@ -218,8 +253,9 @@ export namespace MemoryState { index: await readIndex(root), inventory, items: await inspect(root, inventory), - changes: await MemoryAudit.readChanges(root), - decisions: await MemoryAudit.readDecisions(root), + // Retain empty fields for wire compatibility while the legacy audit file is removed. + changes: "", + decisions: "", } } diff --git a/packages/kilo-memory/src/storage/store.ts b/packages/kilo-memory/src/storage/store.ts index c7ca4be45d..8d79568f6c 100644 --- a/packages/kilo-memory/src/storage/store.ts +++ b/packages/kilo-memory/src/storage/store.ts @@ -27,6 +27,7 @@ export namespace MemoryFiles { export const indexExpired = MemoryState.indexExpired export const scaffold = MemoryState.scaffold export const owned = MemoryState.owned + export const cleanup = MemoryState.cleanup export const writeSession = MemorySessions.writeSession export const readSession = MemorySessions.readSession diff --git a/packages/kilo-memory/test/command-cases.json b/packages/kilo-memory/test/command-cases.json index 1992f2dc48..dea1a43042 100644 --- a/packages/kilo-memory/test/command-cases.json +++ b/packages/kilo-memory/test/command-cases.json @@ -26,10 +26,10 @@ "operation": "status" }, { - "name": "edit operation", - "input": "/memory edit", + "name": "inspect operation", + "input": "/memory inspect", "result": "operation", - "operation": "edit" + "operation": "inspect" }, { "name": "on operation", @@ -94,26 +94,6 @@ "operation": "auto", "mode": "off" }, - { - "name": "verbose mode usage", - "input": "/memory verbose", - "result": "usage", - "reason": "Missing verbose mode" - }, - { - "name": "verbose on operation", - "input": "/memory verbose on", - "result": "operation", - "operation": "verbose", - "mode": "on" - }, - { - "name": "verbose off operation", - "input": "/memory verbose off", - "result": "operation", - "operation": "verbose", - "mode": "off" - }, { "name": "remember operation keeps text", "input": "/memory remember use bun test from packages/opencode", @@ -148,12 +128,6 @@ "operation": "auto", "mode": "off" }, - { - "name": "inspect action is unknown", - "input": "/memory inspect", - "result": "usage", - "reason": "Unknown memory action" - }, { "name": "unknown action", "input": "/memory wat", diff --git a/packages/kilo-memory/test/commands.test.ts b/packages/kilo-memory/test/commands.test.ts index b91d4baad8..c5661b137a 100644 --- a/packages/kilo-memory/test/commands.test.ts +++ b/packages/kilo-memory/test/commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { parseMemoryCommand, type MemoryOperation, type ParsedMemoryCommand } from "../src/commands" +import { MEMORY_USAGE, parseMemoryCommand, type MemoryOperation, type ParsedMemoryCommand } from "../src/commands" type Case = { name: string @@ -29,7 +29,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined { if (!item.query) throw new Error(`Missing query for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, query: item.query } } - if (item.operation === "auto" || item.operation === "verbose") { + if (item.operation === "auto") { if (!item.mode) throw new Error(`Missing mode for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, mode: item.mode } } @@ -41,6 +41,23 @@ function expected(item: Case): ParsedMemoryCommand | undefined { } describe("memory commands", () => { + test("does not expose verbose mode", () => { + expect(MEMORY_USAGE).not.toContain("verbose") + expect(parseMemoryCommand("/memory verbose on")).toEqual({ + kind: "usage", + reason: "Unknown memory action: verbose.", + }) + }) + + test("replaces edit with inspect", () => { + expect(MEMORY_USAGE).toContain("inspect") + expect(MEMORY_USAGE).not.toContain("edit") + expect(parseMemoryCommand("/memory edit")).toEqual({ + kind: "usage", + reason: "Unknown memory action: edit.", + }) + }) + test("parse shared fixtures", () => { for (const item of cases) { const parsed = parseMemoryCommand(item.input) diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts index 388dcb6c1b..04b52a0362 100644 --- a/packages/kilo-memory/test/core.test.ts +++ b/packages/kilo-memory/test/core.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { mkdir, mkdtemp, readdir, rm, symlink, utimes, writeFile } from "fs/promises" import os from "os" import path from "path" @@ -10,6 +10,7 @@ import { MemoryOperations } from "../src/capture/operations" import { MemoryPaths } from "../src/storage/paths" import { MemoryRecall } from "../src/recall/recall" import { MemorySchema } from "../src/schema" +import { KiloMemory } from "../src/effect/index" async function tmp() { const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-")) @@ -43,13 +44,119 @@ describe("memory core package", () => { expect(shown.sources.corrections).toContain("## Corrections") expect(await Bun.file(path.join(t.root, ".gitignore")).text()).toBe("*\n!.gitignore\n") expect(shown.index).toBe("") + expect(shown.changes).toBe("") + expect(shown.decisions).toBe("") + expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false) + }) + }) + + test("prepare removes legacy decisions once from owned memory roots", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const legacy = path.join(t.root, "decisions.jsonl") + await writeFile(legacy, '{"kind":"log"}\n') + + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(legacy).exists()).toBe(false) + + await writeFile(legacy, '{"kind":"log"}\n') + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(legacy).exists()).toBe(true) + + const other = path.join(t.dir, "unowned") + await mkdir(other) + const file = path.join(other, "decisions.jsonl") + await writeFile(file, '{"kind":"log"}\n') + + await KiloMemory.status({ root: other }) + expect(await Bun.file(file).exists()).toBe(true) + }) + }) + + test("prepare ignores legacy decisions cleanup failures", async () => { + const clock = spyOn(Date, "now") + const now = Date.now() + clock.mockReturnValue(now) + try { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const legacy = path.join(t.root, "decisions.jsonl") + await mkdir(legacy) + + const status = await KiloMemory.status({ root: t.root }) + expect(status.state.enabled).toBe(true) + + await rm(legacy, { recursive: true }) + await writeFile(legacy, '{"kind":"log"}\n') + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(legacy).exists()).toBe(true) + + clock.mockReturnValue(now + 60_001) + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(legacy).exists()).toBe(false) + }) + } finally { + clock.mockRestore() + } + }) + + test("prepare ignores corrupt manifests during legacy cleanup", async () => { + const clock = spyOn(Date, "now") + const now = Date.now() + clock.mockReturnValue(now) + try { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const paths = MemoryPaths.files(t.root) + await writeFile(paths.manifest, "{") + + const status = await KiloMemory.status({ root: t.root }) + expect(status.state.enabled).toBe(true) + + await writeFile(paths.manifest, '{"kind":"kilo-memory","version":1}\n') + await writeFile(paths.decisions, '{"kind":"log"}\n') + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(paths.decisions).exists()).toBe(true) + + clock.mockReturnValue(now + 60_001) + await KiloMemory.status({ root: t.root }) + expect(await Bun.file(paths.decisions).exists()).toBe(false) + }) + } finally { + clock.mockRestore() + } + }) + + test("legacy cleanup cache evicts older roots", async () => { + await use(async (t) => { + const first = path.join(t.dir, "cache-0") + await mkdir(first) + await MemoryFiles.writeManifest(first) + await MemoryFiles.cleanup(first) + const legacy = MemoryPaths.files(first).decisions + await writeFile(legacy, '{"kind":"log"}\n') + + for (let i = 1; i <= 128; i++) { + const root = path.join(t.dir, `cache-${i}`) + await mkdir(root) + await MemoryFiles.writeManifest(root) + await MemoryFiles.cleanup(root) + } + + await MemoryFiles.cleanup(first) + expect(await Bun.file(legacy).exists()).toBe(false) }) }) test("enable preserves existing memory settings", async () => { await use(async (t) => { const enabled = await Memory.enable({ root: t.root }) - await MemoryFiles.writeState(t.root, { ...enabled.state, autoInject: false, autoConsolidate: false, verbose: true }) + await MemoryFiles.writeState(t.root, { + ...enabled.state, + autoInject: false, + autoConsolidate: false, + verbose: true, + }) const next = await Memory.enable({ root: t.root }) @@ -111,43 +218,7 @@ describe("memory core package", () => { }) }) - test("decision and change audit records redact secret-like text in one log", async () => { - await use(async (t) => { - const secret = "sk-abcdefghijklmnopqrstuvwxyz123456" - await Memory.enable({ root: t.root }) - await MemoryFiles.decide(t.root, { - kind: "recall", - result: "skipped", - query: `check api_key=${secret}`, - skipped: [{ reason: "secret", text: `password=hunter2 ${secret}` }], - }) - await MemoryFiles.append(t.root, `provider error "api_key": "${secret}"`) - const shown = await Memory.show({ root: t.root }) - - expect(shown.decisions).toContain("[redacted]") - expect(shown.decisions).toContain('"kind":"log"') - expect(shown.decisions).not.toContain(secret) - expect(shown.decisions).not.toContain("hunter2") - expect(shown.changes).toContain("[redacted]") - expect(shown.decisions).toContain("provider error") - }) - }) - - test("targeted recall redacts query before decision truncation", async () => { - await use(async (t) => { - const secret = "sk-" + "a".repeat(40) - await Memory.enable({ root: t.root }) - - await Memory.recall({ root: t.root, query: "x".repeat(220) + secret }) - const shown = await Memory.show({ root: t.root }) - - expect(shown.decisions).toContain("[redacted]") - expect(shown.decisions).not.toContain(secret) - expect(shown.decisions).not.toContain(secret.slice(0, 20)) - }) - }) - - test("stale locks are stolen before appending audit records", async () => { + test("stale locks are stolen before applying memory", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) const lock = path.join(t.root, ".lock") @@ -155,10 +226,10 @@ describe("memory core package", () => { await mkdir(lock) await utimes(lock, old, old) - await MemoryFiles.append(t.root, "after stale lock") + await Memory.apply({ root: t.root, ops: [{ action: "add", key: "after_lock", text: "Stale locks recover." }] }) const shown = await Memory.show({ root: t.root }) - expect(shown.changes).toContain("after stale lock") + expect(shown.sources.project).toContain("after_lock") }) }) @@ -174,7 +245,6 @@ describe("memory core package", () => { expect(state.enabled).toBe(false) expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true) expect(shown.inventory.items).toEqual({}) - expect(shown.changes).toContain("recover state.json") }) }) @@ -280,7 +350,6 @@ describe("memory core package", () => { const shown = await Memory.show({ root: t.root }) expect(mixed.result.added).toBe(1) - // The skip record is redacted: it flows into the persistent decisions audit. expect(mixed.result.skipped).toContainEqual({ reason: "secret", text: "[redacted]" }) expect(JSON.stringify(mixed.result.skipped)).not.toContain("sk-abcdefghijklmnopqrstuvwxyz") expect(shown.sources.project).toContain("safe_fact") @@ -335,7 +404,6 @@ describe("memory core package", () => { ]) expect(shown.sources.project).not.toContain("memory_echo") expect(shown.index).not.toContain("memory_echo") - expect(shown.decisions).toContain('"reason":"self_referential"') }) }) @@ -372,17 +440,10 @@ describe("memory core package", () => { expect(shown.sources.project).not.toContain("Vim keybindings") expect(shown.index).toContain("repo_style") expect(shown.index).not.toContain("reply_style") - expect(shown.decisions).toContain('"reason":"out_of_scope"') - expect(shown.decisions).not.toContain("reply_style") - expect(shown.decisions).not.toContain("theme") - expect(shown.decisions).not.toContain("editor") - expect(shown.decisions).not.toContain("I prefer terse summaries") - expect(shown.decisions).not.toContain("dark mode") - expect(shown.decisions).not.toContain("Vim keybindings") }) }) - test("out-of-scope secret ops stay out of the operations audit", async () => { + test("out-of-scope secret ops stay out of memory", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) @@ -393,9 +454,8 @@ describe("memory core package", () => { const shown = await Memory.show({ root: t.root }) expect(result.result.skipped).toEqual([{ reason: "out_of_scope", text: "My preference is [redacted]" }]) - expect(shown.decisions).toContain('"reason":"out_of_scope"') - expect(shown.decisions).not.toContain("private_pref") - expect(shown.decisions).not.toContain("password=hunter2") + expect(shown.sources.project).not.toContain("private_pref") + expect(shown.sources.project).not.toContain("password=hunter2") }) }) @@ -478,7 +538,7 @@ describe("memory core package", () => { }) }) - test("targeted recall returns typed memory and audits matched files", async () => { + test("targeted recall returns typed memory and matched files", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) await Memory.remember({ @@ -490,12 +550,8 @@ describe("memory core package", () => { }) const result = await Memory.recall({ root: t.root, query: "what command runs cli tests?" }) - const shown = await Memory.show({ root: t.root }) - expect(result.result?.block).toContain("cli_tests") expect(result.files).toEqual(["environment.md"]) - expect(shown.decisions).toContain('"kind":"recall"') - expect(shown.decisions).toContain('"result":"recalled"') }) }) diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index ae1eaea096..91e0ec84e0 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -80,7 +80,7 @@ function run(input: { } describe("MemoryCapture (fake ports)", () => { - test("turn-close typed LLM saves environment memory and audit records", async () => { + test("turn-close typed LLM saves environment memory", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -102,9 +102,6 @@ describe("MemoryCapture (fake ports)", () => { const shown = await KiloMemory.show({ root: t.root }) expect(shown.sources.environment).toContain("cli_memory_tests") - expect(shown.decisions).toContain('"kind":"digest"') - expect(shown.decisions).toContain('"kind":"typed"') - expect(shown.decisions).toContain('"result":"saved"') } finally { await t.done() } @@ -138,9 +135,6 @@ describe("MemoryCapture (fake ports)", () => { const shown = await KiloMemory.show({ root: t.root }) expect(shown.sources.environment).toContain("cli_tests") expect(shown.sources.environment).not.toContain(secret) - expect(shown.decisions).toContain('"reason":"secret"') - // The audit record itself must not carry the raw secret (decisions are exposed via /memory/show). - expect(shown.decisions).not.toContain(secret) const detail = events.find((item) => item.detail?.type === "saved")?.detail expect(detail?.message).toContain("environment.md:cli_tests") expect(detail?.message).not.toContain(secret) @@ -173,18 +167,15 @@ describe("MemoryCapture (fake ports)", () => { sessionID: "ses_effect", max: MemorySchema.maxStoredDigestSummary, }) - const shown = await KiloMemory.show({ root: t.root }) expect(saved?.summary).toContain("[redacted]") expect(saved?.summary).not.toContain(secret) expect(saved?.summary).not.toContain(secret.slice(0, 20)) - expect(shown.decisions).not.toContain(secret) - expect(shown.decisions).not.toContain(secret.slice(0, 20)) } finally { await t.done() } }) - test("turn-close surfaces content-gate rejections in the audit with redacted text", async () => { + test("turn-close rejects self-referential content while applying safe operations", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -206,10 +197,6 @@ describe("MemoryCapture (fake ports)", () => { expect(result).toMatchObject({ skipped: false, operationCount: 1 }) const shown = await KiloMemory.show({ root: t.root }) expect(shown.sources.project).not.toContain("gate_check") - // The apply-time content gate is visible in the audit, and its recorded text is redacted. - expect(shown.decisions).toContain('"reason":"self_referential"') - expect(shown.decisions).toContain("[redacted]") - expect(shown.decisions).not.toContain("password=hunter2") } finally { await t.done() } @@ -346,7 +333,7 @@ describe("MemoryCapture (fake ports)", () => { } }) - test("interrupted close records a non-LLM fallback digest tagged with the reason", async () => { + test("interrupted close records a non-LLM fallback digest", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -367,9 +354,6 @@ describe("MemoryCapture (fake ports)", () => { const raw = await Bun.file(path.join(MemoryPaths.files(t.root).sessions, file)).text() expect(saved?.fallback).toBe(true) expect(raw).toContain("Fallback: true") - const shown = await KiloMemory.show({ root: t.root }) - expect(shown.decisions).toContain("session digest fallback on interrupted") - expect(shown.decisions).toContain('"fallback":true') } finally { await t.done() } @@ -457,7 +441,7 @@ describe("MemoryCapture (fake ports)", () => { } }) - test("template echo digest output falls back and records template_echo", async () => { + test("template echo digest output falls back", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -473,16 +457,13 @@ describe("MemoryCapture (fake ports)", () => { }) const saved = await MemoryFiles.readSession(t.root, { sessionID: "ses_effect", max: 480 }) - const shown = await KiloMemory.show({ root: t.root }) expect(saved?.fallback).toBe(true) - expect(shown.decisions).toContain('"reason":"template_echo"') - expect(shown.decisions).toContain('"fallback":true') } finally { await t.done() } }) - test("empty digest output falls back and records empty_digest", async () => { + test("empty digest output falls back", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -498,11 +479,8 @@ describe("MemoryCapture (fake ports)", () => { }) const saved = await MemoryFiles.readSession(t.root, { sessionID: "ses_effect", max: 480 }) - const shown = await KiloMemory.show({ root: t.root }) expect(saved?.fallback).toBe(true) expect(saved?.summary).toContain("User:") - expect(shown.decisions).toContain('"reason":"empty_digest"') - expect(shown.decisions).toContain('"fallback":true') } finally { await t.done() } @@ -603,7 +581,7 @@ describe("MemoryCapture (fake ports)", () => { } }) - test("records audit when configured memory model is unavailable", async () => { + test("configured memory model fallback still captures memory", async () => { const t = await tmp() try { await KiloMemory.enable({ root: t.root }) @@ -620,8 +598,8 @@ describe("MemoryCapture (fake ports)", () => { }), }) - const shown = await KiloMemory.show({ root: t.root }) - expect(shown.changes).toContain("memory_model_config reason=model unavailable fallback=1") + const saved = await MemoryFiles.readSession(t.root, { sessionID: "ses_effect", max: 480 }) + expect(saved?.summary).toContain("Explored repo setup") } finally { await t.done() } diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index c9533a3b77..94fd594b9a 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 1ee34a5090..fb6f7203a9 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index f465e7880a..f79ab1a4ec 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.11", + "version": "7.4.13", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 49ed771af5..c379a67d7b 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -7,11 +7,15 @@ } [data-slot="basic-tool-tool-info"] { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; font-size: var(--kilo-font-size-12); text-align: start; } [data-slot="basic-tool-tool-info-structured"] { + width: 100%; min-width: 0; overflow: hidden; } @@ -25,6 +29,7 @@ } [data-slot="basic-tool-tool-info-main"] { + width: 100%; align-items: baseline; overflow: hidden; } @@ -34,6 +39,7 @@ } [data-slot="basic-tool-tool-subtitle"] { + flex: 1 1 auto; font-size: var(--kilo-font-size-12); color: var(--text-weak); } @@ -51,6 +57,8 @@ } [data-slot="basic-tool-tool-arg"] { + flex: 0 1 auto; + max-width: 24ch; font-size: var(--kilo-font-size-12); color: var(--text-weak); } @@ -286,6 +294,31 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } } + /* Expanded edit/write/apply_patch triggers stick to the top while their diff scrolls + underneath, so they must stay opaque in every state; otherwise diff rows show + through the header. !important because the trigger hover rule below uses a + translucent hover background and would re-open the leak on hover. */ + :is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"]) + > [data-component="collapsible"] + > [data-slot="collapsible-trigger"][aria-expanded="true"] { + background-color: var(--background-stronger) !important; + } + + /* The sticky headers pin to the visible top edge of the transcript. The message + list's 12px top padding otherwise leaves a strip above the stuck trigger where + the scrolling diff stays visible. Both the trigger and the inner file header + consume --sticky-accordion-top, so they shift up together and stay flush. */ + :is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"]) { + --sticky-accordion-top: -12px; + } + + /* The inner file accordion sticks below the compact 28px tool trigger. The inline + default offset (37px) matches the taller upstream trigger and leaves a gap where + diff rows peek through between the two stuck headers. */ + [data-component="accordion"][data-scope="apply-patch"] { + --sticky-accordion-offset: 28px !important; + } + /* Reposition copy button tooltip to appear below (not above) to avoid clipping */ [data-component="tool-output"] [data-slot="markdown-copy-button"]::after { bottom: auto; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 131874b564..ae7c0a24c9 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1099,7 +1099,7 @@ function McpTool(props: ToolProps) { if (typeof value === "boolean") return [`${key}=${value}`] return [] }) - .slice(0, 3) + .slice(0, 1) } const formatted = createMemo(() => { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 0bedfd066f..9131ae9949 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,46 @@ # kilo-code +## 7.4.13 + +### Minor Changes + +- [#12271](https://github.com/Kilo-Org/kilocode/pull/12271) [`38013f7`](https://github.com/Kilo-Org/kilocode/commit/38013f70fad82f55b9ebe02d8d6883a26d791934) - Allow agents to stop and remove a targeted Agent Manager session. + +- [#12401](https://github.com/Kilo-Org/kilocode/pull/12401) [`210a6bb`](https://github.com/Kilo-Org/kilocode/commit/210a6bbf9b89b30b408d744cf0f253482370eed6) - Support choosing a reasoning effort per model in the Agent Manager Compare Models picker, so compared worktrees can run the same prompt at different effort levels. The selected effort is shown next to the model name in the collapsed selector. + +- [#12306](https://github.com/Kilo-Org/kilocode/pull/12306) [`c081f58`](https://github.com/Kilo-Org/kilocode/commit/c081f582abecaba98a303069140d014a9ee90ca9) - Configure a custom file extension allowlist for codebase indexing to limit scans to relevant project files and support additional text formats. + +- [#12292](https://github.com/Kilo-Org/kilocode/pull/12292) [`6dcaeb3`](https://github.com/Kilo-Org/kilocode/commit/6dcaeb3e97f256e9e98d5093610a0a02aedf84de) - Add JetBrains Context settings for compaction and file watcher ignore patterns. + +- [#12403](https://github.com/Kilo-Org/kilocode/pull/12403) [`5819c68`](https://github.com/Kilo-Org/kilocode/commit/5819c689961ccf61f510896c5477a96e9d2a3459) - Cycle reasoning effort variants with Shift+Tab in prompt inputs. Works in the sidebar chat, Agent Manager, and the New Worktree dialog. The variant selector tooltip shows the shortcut on hover, and the behavior can be turned off with the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under Settings > Display) to restore Shift+Tab focus navigation. + +### Patch Changes + +- [#12301](https://github.com/Kilo-Org/kilocode/pull/12301) [`d854c10`](https://github.com/Kilo-Org/kilocode/commit/d854c1077da0b55afe454c86b43c3240cdac053c) - Respect the Terminal Command Blocks display setting for background process tool cards. + +- [#12304](https://github.com/Kilo-Org/kilocode/pull/12304) [`79fe757`](https://github.com/Kilo-Org/kilocode/commit/79fe75745fc6abd7bd3aad0c079e8f5150751a2c) - Display here-document content as plain text in terminal approval prompts. + +- [#11944](https://github.com/Kilo-Org/kilocode/pull/11944) [`f857248`](https://github.com/Kilo-Org/kilocode/commit/f85724830c6483d22368d3ad75d12639e1488733) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Support bidirectional text in chat messages so right-to-left and mixed-language conversations render in the correct direction. + +- [#12302](https://github.com/Kilo-Org/kilocode/pull/12302) [`ad66c90`](https://github.com/Kilo-Org/kilocode/commit/ad66c908a4bec5f29e6d83fa9efbd1f6cc2cd416) - Clarify when reverting a conversation does not restore workspace changes and link disabled snapshots to the Checkpoints setting. + +- [#12279](https://github.com/Kilo-Org/kilocode/pull/12279) [`6ce16e7`](https://github.com/Kilo-Org/kilocode/commit/6ce16e7cae1c58b545d5eaf00f1c6c44830e22e6) - Open the Agent Manager new-worktree dialog from primary actions and keyboard shortcuts, with an explicit quick-create action in its dropdown. + +- [#12415](https://github.com/Kilo-Org/kilocode/pull/12415) [`a695930`](https://github.com/Kilo-Org/kilocode/commit/a695930d792ce3882cf3d7231ebc9be0e2adfa13) - Fix diff content painting through and above the sticky headers of expanded Edit, Write, and Apply Patch tool cards when scrolling large diffs. + +- [#12213](https://github.com/Kilo-Org/kilocode/pull/12213) [`55fe160`](https://github.com/Kilo-Org/kilocode/commit/55fe1608fb24705258232665f1adfff0b4227e24) - Keep sessions usable after deprecated local review commands complete without a session status transition. + +- [#12378](https://github.com/Kilo-Org/kilocode/pull/12378) [`b402cc2`](https://github.com/Kilo-Org/kilocode/commit/b402cc2635c1dae836a684f9d7a981c05491a930) - Include image-output models in the Kilo Gateway chat model list. + +- [#12401](https://github.com/Kilo-Org/kilocode/pull/12401) [`210a6bb`](https://github.com/Kilo-Org/kilocode/commit/210a6bbf9b89b30b408d744cf0f253482370eed6) - Fix double scrollbars in the Agent Manager new-worktree prompt field and widen the dialog so longer prompts stay readable. The prompt box now grows with its content like the sidebar chat input, the textarea is the only element that scrolls, and manual resize of the prompt area keeps working. + +- Updated dependencies [[`084bcea`](https://github.com/Kilo-Org/kilocode/commit/084bceadaedf193568ccf71256bd299c0d11e90c), [`c081f58`](https://github.com/Kilo-Org/kilocode/commit/c081f582abecaba98a303069140d014a9ee90ca9), [`ff703fb`](https://github.com/Kilo-Org/kilocode/commit/ff703fba621e35a4a5d8e4801620502228cca5bf)]: + - @kilocode/kilo-gateway@7.4.12 + - @kilocode/kilo-indexing@7.5.0 + - @opencode-ai/core@7.4.12 + - @kilocode/kilo-ui@7.4.12 + - @opencode-ai/ui@7.4.12 + ## 7.4.11 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 4066e4f570..425654b2c4 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.11", + "version": "7.4.13", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -1129,6 +1129,11 @@ "default": true, "description": "Show the task timeline graph in the chat header" }, + "kilo-code.new.chat.shiftTabCyclesVariant": { + "type": "boolean", + "default": true, + "description": "Cycle through reasoning effort variants with Shift+Tab in prompt inputs. Disable to keep Shift+Tab for keyboard focus navigation." + }, "kilo-code.new.agentWorkStyle": { "type": "string", "scope": "application", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 7c6aeddea2..abea4fe311 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -171,6 +171,7 @@ import { validIndexingSetting, watchIndexingConfig, } from "./kilo-provider/indexing-settings" +import { buildChatSettingsMessage, validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings" let maxCost = 0 @@ -391,6 +392,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null private indexingConfigDisposable: vscode.Disposable | null = null + private chatConfigDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null @@ -913,6 +915,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) this.indexingConfigDisposable?.dispose() this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg)) + this.chatConfigDisposable?.dispose() + this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg)) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -1250,6 +1254,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestIndexingSettings": this.postMessage(buildIndexingSettingsMessage()) break + case "requestChatSettings": + this.postMessage(buildChatSettingsMessage()) + break case "requestKiloEmbeddingModels": this.fetchAndSendKiloEmbeddingModels().catch((e) => console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e), @@ -3665,6 +3672,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const { section, leaf } = buildSettingPath(key) if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return if (section === "indexing" && !validIndexingSetting(leaf, value)) return + if (section === "chat" && !validChatSetting(leaf, value)) return const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) // Normalize a webview-side clear to `undefined` so VS Code removes the // key from settings.json rather than persisting a literal `null`. This @@ -3954,7 +3962,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper detail, }) } - void this.memory.fetch(sessionID, false) + void this.memory.fetch(sessionID) } return } @@ -4508,6 +4516,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.indexingConfigDisposable?.dispose() + this.chatConfigDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.visibleTaskStreams.clear() diff --git a/packages/kilo-vscode/src/agent-manager/multi-version.ts b/packages/kilo-vscode/src/agent-manager/multi-version.ts index 2ab92ad10e..5d0704f552 100644 --- a/packages/kilo-vscode/src/agent-manager/multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/multi-version.ts @@ -4,11 +4,13 @@ export interface ModelAllocation { providerID: string modelID: string count: number + variant?: string } interface ModelRef { providerID: string modelID: string + variant?: string } /** @@ -32,7 +34,7 @@ export function resolveVersionModels( for (const alloc of allocations) { const clamped = Math.min(Math.max(Math.floor(alloc.count) || 0, 0), MAX_MULTI_VERSIONS) for (let c = 0; c < clamped; c++) { - models.push({ providerID: alloc.providerID, modelID: alloc.modelID }) + models.push({ providerID: alloc.providerID, modelID: alloc.modelID, variant: alloc.variant }) } if (models.length >= MAX_MULTI_VERSIONS) break } @@ -98,7 +100,8 @@ export function buildInitialMessages( if (prompt) { msg.text = prompt msg.agent = agent - msg.variant = variant + // A per-allocation effort pick wins over the dialog-level variant. + msg.variant = model?.variant ?? variant msg.files = files } return msg diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 13d1e28e5b..63ade03fc9 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -445,7 +445,7 @@ interface CreateMultiVersionIn { files?: Array<{ mime: string; url: string }> baseBranch?: string branchName?: string - modelAllocations?: Array<{ providerID: string; modelID: string; count: number }> + modelAllocations?: Array<{ providerID: string; modelID: string; count: number; variant?: string }> /** When set, reconcile each created session's sandbox override to this state. */ sandbox?: boolean } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 59570704a7..e5dbd40e44 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -194,7 +194,7 @@ export async function runWithMessageConfirmation( } } -export function sessionToWebview(session: Session) { +export function sessionToWebview(session: Pick) { return { id: session.id, parentID: session.parentID ?? null, diff --git a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts new file mode 100644 index 0000000000..a1a7c9427b --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode" + +type Post = (msg: unknown) => void + +export function buildChatSettingsMessage() { + const config = vscode.workspace.getConfiguration("kilo-code.new.chat") + return { + type: "chatSettingsLoaded" as const, + settings: { + shiftTabCyclesVariant: config.get("shiftTabCyclesVariant", true), + }, + } +} + +export function watchChatConfig(post: Post): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("kilo-code.new.chat")) { + post(buildChatSettingsMessage()) + } + }) +} + +export function validChatSetting(key: string, value: unknown) { + return key === "shiftTabCyclesVariant" && typeof value === "boolean" +} diff --git a/packages/kilo-vscode/src/kilo-provider/memory.ts b/packages/kilo-vscode/src/kilo-provider/memory.ts index 136d977da5..c234022224 100644 --- a/packages/kilo-vscode/src/kilo-provider/memory.ts +++ b/packages/kilo-vscode/src/kilo-provider/memory.ts @@ -1,11 +1,5 @@ import * as vscode from "vscode" -import * as path from "node:path" -import { - isMemoryOperation, - isMemoryPromptOperation, - type MemoryOperation, - type MemoryPromptOperation, -} from "@kilocode/kilo-memory/commands" +import { isMemoryOperation, type MemoryOperation } from "@kilocode/kilo-memory/commands" import { MemorySchema } from "@kilocode/kilo-memory/schema" import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import { retry } from "../services/cli-backend/retry" @@ -14,6 +8,7 @@ import { getErrorMessage } from "../kilo-provider-utils" type MemorySourceFile = MemorySchema.Source type MemoryApi = KiloClient["memory"] const CACHE_LIMIT = 8 +const STORED_LIMIT = 16 const NO_PROJECT = "No active project for memory. Open a file in the target folder to manage its memory." export type KiloProviderMemoryMessage = { @@ -53,6 +48,20 @@ function memory(client: KiloClient | undefined): MemoryApi | undefined { return (client as { memory?: MemoryApi } | undefined)?.memory } +function count(text: string) { + return text.split("\n").filter((line) => line.trim().startsWith("- ")).length +} + +function stored(text: string) { + return text + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const marker = line.indexOf(":: ") + return marker === -1 ? line : line.slice(marker + 3) + }) +} + function request(input: Record): { value: KiloProviderMemoryMessage } | { error: string } { const op = operation(input.operation) if (!op) return { error: "Unknown memory operation" } @@ -102,14 +111,16 @@ export class KiloProviderMemory { async handle(message: Record): Promise { if (message.type === "requestMemory") { - this.fetch( - typeof message.sessionID === "string" ? message.sessionID : undefined, - message.includeSources === true, - ).catch((err: unknown) => console.error("[Kilo New] fetchAndSendMemory failed:", err)) + this.fetch(typeof message.sessionID === "string" ? message.sessionID : undefined).catch((err: unknown) => + console.error("[Kilo New] fetchAndSendMemory failed:", err), + ) return true } if (message.type === "memoryShow") { - await this.show(typeof message.sessionID === "string" ? message.sessionID : undefined) + await this.show( + typeof message.sessionID === "string" ? message.sessionID : undefined, + message.mode === "status" ? "status" : "show", + ) return true } if (message.type === "memoryOperation") { @@ -127,17 +138,11 @@ export class KiloProviderMemory { await this.run(parsed.value) return true } - if (message.type === "memoryPrompt") { - const op = isMemoryPromptOperation(message.operation) ? message.operation : undefined - if (!op) return true - await this.prompt(op, typeof message.sessionID === "string" ? message.sessionID : undefined) - return true - } return false } - fetch(sessionID?: string, includeSources = false): Promise { - return this.serial(() => this.load(sessionID, includeSources)) + fetch(sessionID?: string): Promise { + return this.serial(() => this.load(sessionID)) } /** Resolves once the serialized operation queue has drained. */ @@ -145,7 +150,7 @@ export class KiloProviderMemory { return this.tail } - private async load(sessionID?: string, includeSources = false): Promise { + private async load(sessionID?: string): Promise { try { const directory = this.input.dir(sessionID ?? this.input.session()?.id) const client = this.input.client() @@ -168,14 +173,10 @@ export class KiloProviderMemory { } const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) - const show = includeSources - ? (await retry(() => api.show({ directory }, { throwOnError: true }))).data - : undefined const msg = { type: "memoryLoaded", sessionID, status, - ...(show ? { show } : {}), } this.cache(directory, msg) this.input.post(msg) @@ -189,27 +190,11 @@ export class KiloProviderMemory { } } - async prompt(value: MemoryPromptOperation, sessionID?: string): Promise { - const title = value === "remember" ? "Remember in project memory" : "Forget project memory" - const placeHolder = value === "remember" ? "Project fact, command, or correction" : "Text to remove" - const text = await vscode.window.showInputBox({ title, placeHolder, ignoreFocusOut: true }) - if (!text?.trim()) { - // Clear the webview's pending state for this action when the input is dismissed. - this.input.post({ type: "memoryOperationResult", operation: value, sessionID, ok: true }) - return - } - await this.run({ - operation: value, - sessionID, - ...(value === "remember" ? { text: text.trim() } : { query: text.trim() }), - }) + show(sessionID?: string, mode: "status" | "show" = "show"): Promise { + return this.serial(() => this.doShow(sessionID, mode)) } - show(sessionID?: string): Promise { - return this.serial(() => this.doShow(sessionID)) - } - - private async doShow(sessionID?: string): Promise { + private async doShow(sessionID: string | undefined, mode: "status" | "show"): Promise { const client = this.input.client() if (!client) { this.input.post({ @@ -237,55 +222,56 @@ export class KiloProviderMemory { this.input.post({ type: "memoryLoaded", sessionID, error: NO_PROJECT }) return } - const { data: show } = await retry(() => api.show({ directory }, { throwOnError: true })) - const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) - const current = sessionID ?? this.input.session()?.id - const startup = - current && status.state.stats.lastInjectedSessionID === current ? status.state.stats.lastInjectedTokens : 0 - const content = [ - "# Kilo Memory", - "", - `Root: ${show.root}`, - `Enabled: ${show.state.enabled ? "yes" : "no"}`, - `Auto-save: ${show.state.autoConsolidate ? "on" : "off"}`, - `Startup context: ${show.state.autoInject ? "on" : "off"}`, - `Stored index tokens: ${status.index.estimatedTokens}`, - `Startup context tokens for this session: ${startup}`, - `Last auto-save model usage: ${status.state.stats.lastConsolidationTokens} tokens`, - "", - "## project.md", - show.sources.project.trim(), - "", - "## environment.md", - show.sources.environment.trim(), - "", - "## corrections.md", - show.sources.corrections.trim(), - "", - "## index.kmem", - show.index.trim(), - "", - "## items", - show.items.trim(), - "", - "## changes", - show.changes.trim(), - "", - "## decisions.jsonl", - show.decisions.trim(), - "", - ].join("\n") - await vscode.workspace - .openTextDocument({ content, language: "markdown" }) - .then((doc) => vscode.window.showTextDocument(doc, { preview: true })) + const [{ data: show }, { data: status }] = await Promise.all([ + retry(() => api.show({ directory }, { throwOnError: true })), + retry(() => api.status({ directory }, { throwOnError: true })), + ]) const msg = { type: "memoryLoaded", sessionID, status, - show, } this.cache(directory, msg) this.input.post(msg) + const items = stored(show.items) + if (mode === "show" && items.length === 0) { + void vscode.window.showInformationMessage( + "This project doesn't have any memory yet. It will start showing after you use Kilo.", + ) + return + } + const entries: vscode.QuickPickItem[] = [ + { + label: `${status.state.enabled ? "Enabled" : "Disabled"} · ${status.state.scope}`, + description: status.state.autoConsolidate ? "Auto-save on" : "Auto-save off", + }, + { label: "Storage", detail: status.root }, + { + label: "Sources", + description: `project.md ${count(show.sources.project)} · environment.md ${count(show.sources.environment)} · corrections.md ${count(show.sources.corrections)}`, + }, + { + label: "Index", + description: `${status.index.estimatedTokens.toLocaleString()} estimated tokens`, + }, + ] + if (mode === "show") { + const shown = items.slice(0, STORED_LIMIT) + entries.push( + { + label: "Stored memory", + description: + shown.length < items.length ? `${shown.length} of ${items.length} shown` : `${shown.length} shown`, + }, + ...shown.map((label) => ({ label })), + ) + } + void vscode.window.showQuickPick(entries, { + title: mode === "show" ? "Memory" : "Memory status", + placeHolder: mode === "show" ? "Stored project memory" : "Project memory status", + matchOnDescription: true, + matchOnDetail: true, + }) } catch (err) { console.error("[Kilo New] KiloProvider: Failed to show memory:", err) this.input.post({ @@ -358,31 +344,28 @@ export class KiloProviderMemory { return false } const data = await this.action(api, directory, message) - const refreshed = await Promise.all([ - retry(() => api.status({ directory }, { throwOnError: true })), - retry(() => api.show({ directory }, { throwOnError: true })), - ]).catch((err: unknown) => { - console.warn("[Kilo New] Memory changed but refresh failed:", err) - return undefined - }) - const status = refreshed?.[0].data - const show = refreshed?.[1].data + const refreshed = + message.operation === "status" + ? { data } + : await retry(() => api.status({ directory }, { throwOnError: true })).catch((err: unknown) => { + console.warn("[Kilo New] Memory changed but refresh failed:", err) + return undefined + }) + const status = refreshed?.data const result = { type: "memoryOperationResult", operation: message.operation, sessionID: message.sessionID, ok: true, ...(status ? { status } : {}), - ...(show ? { show } : {}), result: data, } this.input.post(result) - if (status && show) { + if (status) { const loaded = { type: "memoryLoaded", sessionID: message.sessionID, status, - show, } this.cache(directory, loaded) this.input.post(loaded) @@ -409,12 +392,11 @@ export class KiloProviderMemory { const op = message.operation if (op === "enable") return (await api.enable({ directory }, { throwOnError: true })).data if (op === "status") return (await api.status({ directory }, { throwOnError: true })).data - if (op === "edit") return this.edit(api, directory) + if (op === "inspect") return this.inspect(api, directory) if (op === "disable") return (await api.disable({ directory }, { throwOnError: true })).data if (op === "rebuild") return (await api.rebuild({ directory }, { throwOnError: true })).data if (op === "purge") return this.purge(api, directory, message) if (op === "auto") return this.auto(api, directory, message) - if (op === "verbose") return this.verbose(api, directory, message) if (op === "remember") return this.remember(api, directory, message) if (op === "correct") return this.correct(api, directory, message) return this.forget(api, directory, message) @@ -460,12 +442,10 @@ export class KiloProviderMemory { return (await api.forget({ directory, query, sessionID: message.sessionID }, { throwOnError: true })).data } - private async edit(api: MemoryApi, directory: string) { + private async inspect(api: MemoryApi, directory: string) { const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) if (!status.state.enabled) throw new Error("Memory is disabled. Run /memory on first.") - const uri = vscode.Uri.file(path.join(status.root, "project.md")) - const doc = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(doc, { preview: false }) + await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(status.root)) return status } @@ -481,11 +461,4 @@ export class KiloProviderMemory { } throw new Error("Auto-save mode is required") } - - private async verbose(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { - if (message.mode === "on" || message.mode === "off") { - return (await api.configure({ directory, verbose: message.mode === "on" }, { throwOnError: true })).data - } - throw new Error("Verbose mode is required") - } } diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 91ad20403b..755953d04d 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.11", + "version": "7.4.13", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index a5914f7bff..9d01986cc8 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -88,6 +88,7 @@ const mockVscode = { tabGroups: { all: [] }, showTextDocument: async () => {}, showInformationMessage: async () => undefined, + showQuickPick: async () => undefined, showErrorMessage: async () => undefined, showWarningMessage: async () => undefined, createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }), diff --git a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts new file mode 100644 index 0000000000..0671910d3f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { buildChatSettingsMessage, validChatSetting } from "../../src/kilo-provider/chat-settings" + +type Stub = { + getConfiguration: (section?: string) => { + get: (key: string, fallback?: T) => T | undefined + } +} + +const original = vscode.workspace.getConfiguration + +function stubConfig(state: Map) { + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new.chat") { + return { get: (_key: string, fallback?: T) => fallback } + } + return { + get: (key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback), + } + } +} + +afterEach(() => { + ;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"] +}) + +describe("buildChatSettingsMessage", () => { + let state: Map + + beforeEach(() => { + state = new Map() + stubConfig(state) + }) + + it("enables Shift+Tab variant cycling by default", () => { + expect(buildChatSettingsMessage().settings.shiftTabCyclesVariant).toBe(true) + }) + + it("returns the persisted cycling preference", () => { + state.set("shiftTabCyclesVariant", false) + + expect(buildChatSettingsMessage().settings.shiftTabCyclesVariant).toBe(false) + }) +}) + +describe("validChatSetting", () => { + it("accepts only boolean cycling updates", () => { + expect(validChatSetting("shiftTabCyclesVariant", true)).toBe(true) + expect(validChatSetting("shiftTabCyclesVariant", false)).toBe(true) + expect(validChatSetting("shiftTabCyclesVariant", "false")).toBe(false) + expect(validChatSetting("unknown", true)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts index f4f417fad0..274cb8cc60 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -28,18 +28,6 @@ function status(root: string) { } } -function show(root: string) { - return { - root: `${root}/.kilo/memory`, - state: status(root).state, - sources: { project: "", environment: "", corrections: "" }, - index: "", - items: "", - changes: "", - decisions: "", - } -} - describe("KiloProvider memory events", () => { it("routes tracked background memory events to their session directory", async () => { const calls: string[] = [] @@ -151,10 +139,6 @@ describe("KiloProvider memory events", () => { calls.push(["disable", input.directory]) return { data: { root: `${input.directory}/.kilo/memory`, state: status(input.directory).state } } }, - show: async (input: { directory: string }) => { - calls.push(["show", input.directory]) - return { data: show(input.directory) } - }, }, } as unknown as KiloClient const posts: unknown[] = [] @@ -176,7 +160,6 @@ describe("KiloProvider memory events", () => { ["status", "/repo/project"], ["disable", "/repo/project"], ["status", "/repo/project"], - ["show", "/repo/project"], ]) expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts index 1d3fb20c11..4769ccf2fe 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, spyOn } from "bun:test" import type { KiloClient } from "@kilocode/sdk/v2/client" +import * as vscode from "vscode" import { KiloProviderMemory } from "../../src/kilo-provider/memory" function subject(client: KiloClient | undefined) { @@ -18,6 +19,7 @@ function status(root: string) { root: `${root}/.kilo/memory`, state: { enabled: true, + scope: "project", autoConsolidate: true, stats: { lastInjectedSessionID: "", @@ -42,6 +44,89 @@ function show(root: string) { } describe("KiloProviderMemory", () => { + it("shows stored memory and explains empty projects", async () => { + const picker = spyOn(vscode.window, "showQuickPick") + const notice = spyOn(vscode.window, "showInformationMessage") + const full = status("/repo") + const view = show("/repo") + view.items = "record id=project.md:Facts:test :: Stored memory fact :: with context" + const stored = subject({ + memory: { + show: async () => ({ data: view }), + status: async () => ({ data: full }), + }, + } as unknown as KiloClient) + const empty = subject({ + memory: { + show: async () => ({ data: show("/empty") }), + status: async () => ({ data: status("/empty") }), + }, + } as unknown as KiloClient) + + try { + await stored.memory.show("ses_stored") + await empty.memory.show("ses_empty") + + expect(picker).toHaveBeenCalledTimes(1) + expect(picker.mock.calls[0]?.[0]).toContainEqual( + expect.objectContaining({ label: "Storage", detail: "/repo/.kilo/memory" }), + ) + expect(picker.mock.calls[0]?.[0]).toContainEqual( + expect.objectContaining({ label: "Stored memory fact :: with context" }), + ) + expect(notice).toHaveBeenCalledWith( + "This project doesn't have any memory yet. It will start showing after you use Kilo.", + ) + } finally { + picker.mockRestore() + notice.mockRestore() + } + }) + + it("shows the stored memory total when the list is truncated", async () => { + const picker = spyOn(vscode.window, "showQuickPick") + const view = show("/repo") + view.items = Array.from({ length: 17 }, (_, i) => `- id=item-${i} :: Fact ${i}`).join("\n") + const item = subject({ + memory: { + show: async () => ({ data: view }), + status: async () => ({ data: status("/repo") }), + }, + } as unknown as KiloClient) + + try { + await item.memory.show("ses_stored") + + expect(picker.mock.calls[0]?.[0]).toContainEqual( + expect.objectContaining({ label: "Stored memory", description: "16 of 17 shown" }), + ) + expect(picker.mock.calls[0]?.[0]).toHaveLength(21) + } finally { + picker.mockRestore() + } + }) + + it("routes inspect operations to the memory folder", async () => { + const reveal = spyOn(vscode.commands, "executeCommand") + const item = subject({ + memory: { + status: async () => ({ data: status("/repo") }), + show: async () => ({ data: show("/repo") }), + }, + } as unknown as KiloClient) + + try { + await item.memory.run({ operation: "inspect", sessionID: "ses_inspect" }) + + expect(reveal).toHaveBeenCalledWith("revealFileInOS", expect.objectContaining({ fsPath: "/repo/.kilo/memory" })) + expect(item.posts).toContainEqual( + expect.objectContaining({ type: "memoryOperationResult", operation: "inspect", ok: true }), + ) + } finally { + reveal.mockRestore() + } + }) + it("handles clients without memory endpoints gracefully", async () => { const item = subject({} as KiloClient) @@ -117,7 +202,7 @@ describe("KiloProviderMemory", () => { expect(posts[1]).toMatchObject({ type: "memoryLoaded", sessionID: "ses_8", - show: { root: "/repo/ses_8/.kilo/memory" }, + status: { root: "/repo/ses_8/.kilo/memory" }, }) }) @@ -158,34 +243,29 @@ describe("KiloProviderMemory", () => { it("routes status operations without mutating memory", async () => { const calls: string[] = [] const state = status("/repo") - const view = show("/repo") const item = subject({ memory: { status: async () => { calls.push("status") return { data: state } }, - show: async () => { - calls.push("show") - return { data: view } - }, }, } as unknown as KiloClient) await item.memory.run({ operation: "status", sessionID: "ses_memory" }) - expect(calls).toEqual(["status", "status", "show"]) + expect(calls).toEqual(["status"]) expect(item.posts).toContainEqual( - expect.objectContaining({ type: "memoryOperationResult", operation: "status", ok: true }), + expect.objectContaining({ type: "memoryOperationResult", operation: "status", ok: true, result: state }), ) + expect(item.posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", status: state })) }) - it("routes auto-save, verbose, and purge operations with explicit payloads", async () => { + it("routes auto-save and purge operations with explicit payloads", async () => { const calls: unknown[] = [] const state = status("/repo") const view = show("/repo") state.state.autoConsolidate = false - state.state.verbose = true const item = subject({ memory: { configure: async (input: unknown) => { @@ -202,14 +282,12 @@ describe("KiloProviderMemory", () => { } as unknown as KiloClient) await item.memory.run({ operation: "auto", mode: "off", sessionID: "ses_memory" }) - await item.memory.run({ operation: "verbose", mode: "on", sessionID: "ses_memory" }) await item.memory.run({ operation: "purge", confirm: true, sessionID: "ses_memory" }) expect(calls).toEqual([ ["configure", { directory: "/repo", autoConsolidate: false }], - ["configure", { directory: "/repo", verbose: true }], ["purge", { directory: "/repo", confirm: true }], ]) - expect(item.posts.filter((post) => (post as { type?: string }).type === "memoryOperationResult")).toHaveLength(3) + expect(item.posts.filter((post) => (post as { type?: string }).type === "memoryOperationResult")).toHaveLength(2) }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index c0576d2fe4..3a9213d887 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -165,6 +165,14 @@ describe("sessionToWebview", () => { expect(result.revert).toBeNull() expect(result.summary).toBeNull() }) + + it("preserves the workspace restoration outcome from a revert response", () => { + const session = { + ...makeSession(), + revert: { messageID: "msg-1", workspace: "snapshots-disabled" as const }, + } + expect(sessionToWebview(session).revert).toMatchObject({ workspace: "snapshots-disabled" }) + }) }) describe("indexProvidersById", () => { diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index fbc8aa4255..7c226ecaba 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -24,12 +24,22 @@ const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") const KILO_MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.tsx") const KILO_MESSAGE_HIGHLIGHT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-highlight.ts") +const KILO_BASIC_TOOL_CSS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/basic-tool.css") const KILO_MESSAGE_PART_CSS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.css") const SHELL_ROLLING_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/shell-rolling-results.tsx") const ASSISTANT_MESSAGE_FILE = path.join( MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx", ) +const TASK_HEADER_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx") +const CONTEXT_TAB_FILE = path.join( + MONOREPO_ROOT, + "packages/kilo-vscode/webview-ui/src/components/settings/ContextTab.tsx", +) +const PROMPT_INPUT_FILE = path.join( + MONOREPO_ROOT, + "packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx", +) const TRANSCRIPT_PARTS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts") const CHAT_LAYOUT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/styles/chat-layout.css") @@ -331,6 +341,55 @@ describe("AssistantMessage visible row contract (source)", () => { it("uses the plan exit card only when plan metadata is renderable", () => { expect(src).toContain("if (!planExitInfo(part)) return") }) + + it("uses the native recall tool without a separate memory badge", () => { + const tools = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + expect(src).not.toContain("assistant-memory-badge") + expect(tools).toContain("ToolRegistry.render(part.tool) ?? McpTool") + }) +}) + +describe("Native tool summary contract (source)", () => { + const tools = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + const css = fs.readFileSync(KILO_BASIC_TOOL_CSS_FILE, "utf-8") + + it("shows one secondary argument while preserving complete expanded input", () => { + const start = tools.indexOf("const inputArgs") + const end = tools.indexOf("const formatted", start) + expect(tools.slice(start, end)).toContain(".slice(0, 1)") + expect(tools).toContain("JSON.stringify(props.input, null, 2)") + }) + + it("gives the primary label remaining width and bounds secondary arguments", () => { + expect(css).toMatch(/\[data-slot="basic-tool-tool-info"\][\s\S]*?flex: 1 1 auto;/) + expect(css).toMatch(/\[data-slot="basic-tool-tool-subtitle"\][\s\S]*?flex: 1 1 auto;/) + expect(css).toMatch(/\[data-slot="basic-tool-tool-arg"\][\s\S]*?max-width: 24ch;/) + }) +}) + +describe("Memory control placement contract (source)", () => { + const header = fs.readFileSync(TASK_HEADER_FILE, "utf-8") + const settings = fs.readFileSync(CONTEXT_TAB_FILE, "utf-8") + const prompt = fs.readFileSync(PROMPT_INPUT_FILE, "utf-8") + + it("keeps memory controls out of the task header", () => { + expect(header).not.toContain("useMemory") + expect(header).not.toContain('name="memory"') + }) + + it("shows storage inspection in settings without a manual rebuild action", () => { + expect(settings).toContain("settings.context.memory.storage.title") + expect(settings).toContain("settings.context.memory.status.enabledTokens") + expect(settings).toContain("memory.inspect()") + expect(settings).not.toContain("memory.rebuild()") + expect(settings).not.toContain("lastOperationCount") + expect(settings).not.toContain("sessionTokens") + }) + + it("expands bare memory commands into inline completion", () => { + expect(prompt).toContain('const value = "/memory "') + expect(prompt).toContain("slash.onInput(value, value.length)") + }) }) describe("Assistant transcript spacing contract (source)", () => { diff --git a/packages/kilo-vscode/tests/unit/memory-activity.test.ts b/packages/kilo-vscode/tests/unit/memory-activity.test.ts deleted file mode 100644 index 316175e256..0000000000 --- a/packages/kilo-vscode/tests/unit/memory-activity.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { MemoryMarkerMeta } from "@kilocode/kilo-memory/marker-meta" -import { addMemoryActivity, markerActivity } from "../../webview-ui/src/utils/memory-activity" - -describe("memory activity", () => { - it("accumulates saved events with their message and source references", () => { - const items = addMemoryActivity( - [], - { - type: "saved", - message: "Saved project memory", - operationCount: 4, - added: 3, - removed: 1, - sources: ["project.md:kilo_colors"], - }, - 10, - ) - - expect(items).toEqual([ - { - type: "saved", - at: 10, - tokens: 0, - count: 3, - items: ["Saved project memory"], - refs: ["project.md:kilo_colors"], - }, - ]) - expect(addMemoryActivity(items, { type: "recalled" }, 20)).toEqual(items) - }) - - it("ignores removal-only events and caps saved activity", () => { - const removed = { type: "saved" as const, message: "Memory updated · 1 removed", added: 0, removed: 1 } - expect(addMemoryActivity([], removed, 10)).toEqual([]) - - const items = Array.from({ length: 60 }).reduce( - (all, _, at) => addMemoryActivity(all, { type: "saved", added: 1 }, at), - [] as ReturnType, - ) - expect(items).toHaveLength(50) - expect(items[0]?.at).toBe(10) - expect(items.at(-1)?.at).toBe(59) - }) - - it("decodes loaded and recalled markers for activity summaries", () => { - const loaded = markerActivity( - [ - { - type: "text", - metadata: MemoryMarkerMeta.metadata( - { - type: "startup", - bytes: 10, - tokens: 42, - count: 1, - files: ["project.md"], - items: ["Use Kilo colors"], - }, - true, - ), - }, - ], - 10, - ) - const recalled = markerActivity( - [ - { - type: "text", - metadata: MemoryMarkerMeta.metadata( - { - type: "recall", - bytes: 10, - tokens: 8, - count: 2, - files: ["project.md"], - items: ["Prefer dark mode"], - }, - true, - ), - }, - ], - 20, - ) - - expect(loaded).toMatchObject({ type: "loaded", tokens: 42, count: 1, items: [] }) - expect(recalled).toMatchObject({ type: "recalled", tokens: 8, count: 2, items: ["Prefer dark mode"] }) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/memory-command.test.ts b/packages/kilo-vscode/tests/unit/memory-command.test.ts index 6b160d62af..d42a67aa86 100644 --- a/packages/kilo-vscode/tests/unit/memory-command.test.ts +++ b/packages/kilo-vscode/tests/unit/memory-command.test.ts @@ -4,7 +4,7 @@ import { parseMemoryCommand, type ParsedMemoryCommand } from "../../webview-ui/s type MemoryOperation = | "enable" | "status" - | "edit" + | "inspect" | "disable" | "rebuild" | "remember" @@ -12,7 +12,6 @@ type MemoryOperation = | "forget" | "purge" | "auto" - | "verbose" type Case = { name: string input: string @@ -43,7 +42,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined { if (!item.query) throw new Error(`Missing query for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, query: item.query } } - if (item.operation === "auto" || item.operation === "verbose") { + if (item.operation === "auto") { if (!item.mode) throw new Error(`Missing mode for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, mode: item.mode } } diff --git a/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts b/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts index 175b74ae63..0c54e44644 100644 --- a/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts @@ -7,6 +7,7 @@ import { remaining, toggleModel, setAllocationCount, + setAllocationVariant, maxAllocationCount, MAX_MULTI_VERSIONS, } from "../../webview-ui/agent-manager/multi-model-utils" @@ -41,6 +42,24 @@ describe("multi-model-utils", () => { expect(arr).toContainEqual({ providerID: "b", modelID: "m2", count: 1 }) }) + test("allocationsToArray includes variant when set", () => { + const alloc = setAllocationVariant(make(["a", "m1", "Model 1", 1]), "a", "m1", "high") + expect(allocationsToArray(alloc)).toContainEqual({ providerID: "a", modelID: "m1", count: 1, variant: "high" }) + }) + + test("setAllocationVariant sets and clears the variant", () => { + const alloc = make(["a", "m1", "Model 1", 1]) + const set = setAllocationVariant(alloc, "a", "m1", "high") + expect(set.get("a/m1")?.variant).toBe("high") + expect(setAllocationVariant(set, "a", "m1", undefined).get("a/m1")?.variant).toBeUndefined() + }) + + test("setAllocationVariant preserves count and does nothing for unknown models", () => { + const alloc = make(["a", "m1", "Model 1", 2]) + expect(setAllocationVariant(alloc, "a", "m1", "high").get("a/m1")?.count).toBe(2) + expect(setAllocationVariant(alloc, "b", "m2", "high")).toBe(alloc) + }) + test("remaining returns slots left", () => { const alloc = make(["a", "m1", "Model 1", 2], ["b", "m2", "Model 2", 1]) expect(remaining(alloc)).toBe(MAX_MULTI_VERSIONS - 3) diff --git a/packages/kilo-vscode/tests/unit/multi-version.test.ts b/packages/kilo-vscode/tests/unit/multi-version.test.ts new file mode 100644 index 0000000000..9d5a1882ef --- /dev/null +++ b/packages/kilo-vscode/tests/unit/multi-version.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "../../src/agent-manager/multi-version" + +const created = (n: number): CreatedVersion[] => + Array.from({ length: n }, (_, i) => ({ + worktreeId: `wt-${i}`, + sessionId: `ses-${i}`, + path: `/tmp/wt-${i}`, + branch: `branch-${i}`, + parentBranch: "main", + versionIndex: i, + })) + +describe("resolveVersionModels", () => { + test("expands allocations with per-model variants", () => { + const { models, versions } = resolveVersionModels( + [ + { providerID: "a", modelID: "m1", count: 2, variant: "high" }, + { providerID: "b", modelID: "m2", count: 1 }, + ], + undefined, + 1, + ) + expect(versions).toBe(3) + expect(models).toEqual([ + { providerID: "a", modelID: "m1", variant: "high" }, + { providerID: "a", modelID: "m1", variant: "high" }, + { providerID: "b", modelID: "m2", variant: undefined }, + ]) + }) + + test("non-compare runs carry no per-version variant", () => { + const { models } = resolveVersionModels(undefined, { providerID: "a", modelID: "m1" }, 2) + expect(models).toEqual([]) + }) +}) + +describe("buildInitialMessages", () => { + test("per-allocation variant wins over the dialog-level variant", () => { + const models = resolveVersionModels( + [ + { providerID: "a", modelID: "m1", count: 1, variant: "high" }, + { providerID: "b", modelID: "m2", count: 1 }, + ], + undefined, + 1, + ).models + const msgs = buildInitialMessages(created(2), models, {}, "do it", undefined, "low") + expect(msgs[0]?.variant).toBe("high") + expect(msgs[1]?.variant).toBe("low") + }) + + test("falls back to the dialog-level variant when no allocation variant is set", () => { + const msgs = buildInitialMessages(created(1), [], { providerID: "a", modelID: "m1" }, "do it", undefined, "medium") + expect(msgs[0]?.variant).toBe("medium") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts index dc00f65473..4b9c07a048 100644 --- a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts +++ b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts @@ -18,7 +18,7 @@ describe("NewWorktreeDialog sandbox toggle", () => { 'vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })', ) expect(src).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined") - expect(src).toContain("const { config, globalConfig, features } = useConfig()") + expect(src).toContain("const { config, globalConfig, features, settings } = useConfig()") expect(src).toContain( "const sandboxVisible = () => features().sandboxControls && globalConfig().sandbox?.enabled === true", ) diff --git a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts index b3524d2f8c..8fc4c36b38 100644 --- a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts +++ b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts @@ -5,9 +5,15 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx") const PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const BANNER_FILE = path.join(ROOT, "webview-ui/src/components/chat/RevertBanner.tsx") +const SESSION_FILE = path.join(ROOT, "webview-ui/src/types/messages/sessions.ts") +const SDK_FILE = path.join(ROOT, "../sdk/js/src/v2/gen/types.gen.ts") const src = fs.readFileSync(TURN_FILE, "utf-8") const provider = fs.readFileSync(PROVIDER_FILE, "utf-8") +const banner = fs.readFileSync(BANNER_FILE, "utf-8") +const session = fs.readFileSync(SESSION_FILE, "utf-8") +const sdk = fs.readFileSync(SDK_FILE, "utf-8") function method(name: string, next: string) { const start = provider.indexOf(` private async ${name}`) @@ -49,3 +55,26 @@ describe("revert session synchronization", () => { expect(provider).toMatch(/this\.setCurrentSession\(event\.properties\.info\)/) }) }) + +describe("revert workspace restoration status", () => { + it("renders explicit conversation-only outcomes", () => { + expect(session).toContain('workspace?: "restored" | "snapshots-disabled" | "unavailable"') + expect(sdk).toMatch( + /export type Session = \{[\s\S]*?workspace\?: "restored" \| "snapshots-disabled" \| "unavailable"/, + ) + expect(sdk).toMatch( + /export type KilocodeSessionImportSessionData = \{[\s\S]*?workspace\?: "restored" \| "snapshots-disabled" \| "unavailable"/, + ) + expect(banner).toContain('"revert.banner.workspace.snapshotsDisabled"') + expect(banner).toContain('"revert.banner.workspace.unavailable"') + }) + + it("opens the checkpoints settings tab when snapshots are disabled", () => { + expect(banner).toContain('{ type: "openSettingsPanel", tab: "checkpoints" }') + expect(banner).toContain('"revert.banner.workspace.enableSnapshots"') + }) + + it("uses a legacy notice for reverts without an explicit outcome", () => { + expect(banner).toContain('"revert.banner.workspace.legacy"') + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts index 11901449db..c6c27294b1 100644 --- a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import { + cycleVariant, getVariant, sessionVariantKeys, sessionVariants, @@ -92,3 +93,23 @@ describe("per-session variant selection", () => { expect(sessionVariantKeys(store, "pending-local-1")).toEqual(["session/pending-local-1/anthropic/claude-sonnet-4"]) }) }) + +describe("cycleVariant", () => { + it("advances to the next variant", () => { + expect(cycleVariant("low", variants)).toBe("medium") + expect(cycleVariant("medium", variants)).toBe("high") + }) + + it("wraps back to the first variant after the last", () => { + expect(cycleVariant("high", variants)).toBe("low") + }) + + it("starts at the first variant when current is missing or unknown", () => { + expect(cycleVariant(undefined, variants)).toBe("low") + expect(cycleVariant("bogus", variants)).toBe("low") + }) + + it("returns undefined when no variants exist", () => { + expect(cycleVariant("low", [])).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts index 351fa3a999..8d0b9abccc 100644 --- a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts @@ -31,6 +31,13 @@ const SCRIPT = ` state: { status: "completed", input: {}, output: "done", title: "Updated todos" }, }, { id: "read-running", type: "tool", tool: "read", state: { status: "running", input: {} } }, + { id: "memory-running", type: "tool", tool: "kilo_memory_recall", state: { status: "running", input: {} } }, + { + id: "memory-completed", + type: "tool", + tool: "kilo_memory_recall", + state: { status: "completed", input: {}, output: "memory", title: "Memory recalled" }, + }, ] const visible = parts.filter((part) => isRenderable(part, message)).map((part) => part.id) @@ -38,7 +45,14 @@ const SCRIPT = ` console.log("${FAIL}" + reason) process.exit(2) } - const expected = ["visible-text", "visible-reasoning", "todo-completed", "read-running"] + const expected = [ + "visible-text", + "visible-reasoning", + "todo-completed", + "read-running", + "memory-running", + "memory-completed", + ] if (visible.length !== expected.length || visible.some((id, index) => id !== expected[index])) { fail("did not exclude transcript-invisible parts") } diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts index b243ab06fc..0718b5ab29 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -27,6 +27,76 @@ function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude? } describe("useSlashCommand sandbox action", () => { + it("opens project memory actions from the top-level command", () => { + const ctx = setup(() => {}) + const state = { text: "/memory" } + const textarea = { + value: state.text, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + ctx.slash.onInput("/mem", 4) + + expect(ctx.slash.results()).toContainEqual( + expect.objectContaining({ name: "memory", description: "Manage project memory", hints: ["mem"] }), + ) + ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text)) + expect(state.text).toBe("/memory ") + expect(ctx.slash.results().map((command) => command.name)).toContain("memory inspect") + ctx.dispose() + }) + + it("offers memory actions after the parent command", () => { + const ctx = setup(() => {}) + + ctx.slash.onInput("/memory ", 8) + + expect(ctx.slash.results().map((command) => command.name)).toEqual([ + "memory status", + "memory show", + "memory on", + "memory off", + "memory inspect", + "memory rebuild", + "memory remember", + "memory correct", + "memory forget", + "memory auto on", + "memory auto off", + "memory purge confirm", + ]) + ctx.dispose() + }) + + it("keeps nested memory actions out of root hint matching", () => { + const ctx = setup(() => {}) + const nested = ctx.slash.commands().filter((command) => command.name.startsWith("memory ")) + + expect(nested.length).toBeGreaterThan(0) + expect(nested.every((command) => command.hints.length === 0)).toBe(true) + ctx.dispose() + }) + + it("completes nested memory actions and closes for free text", () => { + const ctx = setup(() => {}) + const state = { text: "/mem rem" } + const textarea = { + value: state.text, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + ctx.slash.onInput(state.text, state.text.length) + expect(ctx.slash.results().map((command) => command.name)).toEqual(["memory remember"]) + ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text)) + expect(state.text).toBe("/memory remember ") + + ctx.slash.onInput("/memory remember durable fact", 31) + expect(ctx.slash.show()).toBe(false) + ctx.dispose() + }) + it("runs the sandbox toggle as a client command", () => { const state = { toggles: 0, text: "/sandbox", prevented: 0 } const ctx = setup(() => state.toggles++) diff --git a/packages/kilo-vscode/tsconfig.json b/packages/kilo-vscode/tsconfig.json index 868845e9f1..b8012f1644 100644 --- a/packages/kilo-vscode/tsconfig.json +++ b/packages/kilo-vscode/tsconfig.json @@ -3,7 +3,7 @@ "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "sourceMap": true, "rootDir": "src", "strict": true /* enable all strict type-checking options */, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx index 71b39fed3c..e1d00c1f95 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -18,6 +18,7 @@ import { totalAllocations, toggleModel, setAllocationCount, + setAllocationVariant, maxAllocationCount, } from "./multi-model-utils" @@ -96,6 +97,7 @@ export const MultiModelSelector: Component<{ const checked = () => props.allocations.has(key()) const entry = () => props.allocations.get(key()) const disabled = () => !checked() && totalAllocations(props.allocations) >= MAX_MULTI_VERSIONS + const efforts = () => Object.keys(model.variants ?? {}) return (
+ 0}> + +