diff --git a/.changeset/jetbrains-context-settings.md b/.changeset/jetbrains-context-settings.md new file mode 100644 index 0000000000..c90bc7ffd8 --- /dev/null +++ b/.changeset/jetbrains-context-settings.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add JetBrains Context settings for compaction and file watcher ignore patterns. 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/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/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..af023767ae 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 = obj.flagOrNull("auto"), + threshold_percent = obj.num("threshold_percent"), + prune = obj.flagOrNull("prune"), + ) + } + private fun parseSkillsConfig(obj: JsonObject?): SkillsConfigDto? { if (obj == null) return null return SkillsConfigDto( @@ -838,6 +860,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/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/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 74479094e7..de244ad0f8 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,21 @@ 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 - agent overrides and permissions`() { val cfg = KiloCliDataParser.parseConfig( @@ -2123,6 +2140,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/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..1f441f1602 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 @@ -57,6 +58,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { models.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(models) + 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) + val providers = ActionLink(KiloBundle.message("settings.providers.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink 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..d703e75be1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt @@ -0,0 +1,78 @@ +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 ConfigPatchDto() + + 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 clear = mutableListOf() + val threshold = parseThreshold(to.threshold) + val fromThreshold = parseThreshold(from.threshold) + if (fromThreshold != threshold && threshold == null) clear += "threshold_percent" + 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..570b860b6d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -0,0 +1,300 @@ +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.SettingsStackedRow +import ai.kilocode.client.settings.base.SettingsToggle +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.ui.CollectionListModel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.ListSelectionModel +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 { value -> update { copy(threshold = value) } } + private val patterns = PatternList { value -> update { copy(ignore = value) } } + + init { + section( + KiloBundle.message("settings.context.compaction.title"), + KiloBundle.message("settings.context.compaction.description"), + ).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"), + threshold.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(SettingsStackedRow( + KiloBundle.message("settings.context.watcher.patterns.title"), + KiloBundle.message("settings.context.watcher.patterns.description"), + 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( + private val change: (String) -> Unit, +) : JBTextField() { + private var syncing = false + + init { + columns = THRESHOLD_COLUMNS + emptyText.text = KiloBundle.message("settings.context.compaction.threshold.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 + return value.all { it.isDigit() || it == '.' } + } +} + +internal class PatternList( + private val change: (List) -> Unit, +) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val model = CollectionListModel() + internal val entry = JBTextField().apply { + emptyText.text = KiloBundle.message("settings.context.watcher.placeholder") + } + private val add = JButton(KiloBundle.message("settings.context.watcher.add"), AllIcons.General.Add).apply { + addActionListener { add() } + } + private val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + emptyText.text = KiloBundle.message("settings.context.watcher.empty") + } + private val panel = ToolbarDecorator.createDecorator(list) + .disableUpDownActions() + .disableAddAction() + .setRemoveAction { remove() } + .setRemoveActionUpdater { isEnabled && list.selectedIndex >= 0 } + .createPanel() + + init { + entry.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) = syncAdd() + }) + entry.registerKeyboardAction( + { add() }, + javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + next(Stack.horizontal(UiStyle.Gap.sm()).next(entry).next(add)) + next(panel) + syncAdd() + } + + @RequiresEdt + fun sync(values: List) { + if (model.items == values) return + model.replaceAll(values) + syncAdd() + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + entry.isEnabled = enabled + add.isEnabled = enabled && entry.text.trim().isNotBlank() + list.isEnabled = enabled + panel.isEnabled = enabled + syncAdd() + } + + private fun add() { + val value = entry.text.trim() + if (!isEnabled || value.isBlank()) return + val values = model.items.toMutableList() + if (value !in values) values += value + entry.text = "" + model.replaceAll(values) + change(values) + syncAdd() + } + + private fun remove() { + val idx = list.selectedIndex + if (!isEnabled || idx < 0 || idx >= model.size) return + val values = model.items.toMutableList() + values.removeAt(idx) + model.replaceAll(values) + change(values) + syncAdd() + } + + private fun syncAdd() { + add.isEnabled = isEnabled && entry.text.trim().isNotBlank() + } +} + +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..2d65979af4 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,11 +41,19 @@ + + () + 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 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 list = components(panel).filterIsInstance().single() + list.entry.text = "**/dist/**" + buttons(panel).single { it.text == "Add pattern" }.doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("tmp/**", "**/dist/**"), rpc.configPatches.single().watcher?.ignore) + } + + 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): JTextField = components(panel) + .filterIsInstance() + .single { it.columns == 8 } + + private fun buttons(panel: ContextSettingsUi): List = components(panel).filterIsInstance() + + 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) } + is JTextField -> 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/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/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(),