feat(jetbrains): add context settings page

This commit is contained in:
kirillk
2026-07-16 14:53:45 -04:00
parent bb75928749
commit 6dcaeb3e97
17 changed files with 1400 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add JetBrains Context settings for compaction and file watcher ignore patterns.
@@ -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<String> = 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<String>? = null,
)
@Serializable
data class CompactionPatchDto(
val clear: List<String> = 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<String> = 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<JComponent>`.
- `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<ContextSettingsContent, ContextDraft, ConfigPatchDto, KiloAppStateDto, Unit>`.
- 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
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.context"
groupWeight="3"
instance="ai.kilocode.client.settings.context.ContextConfigurable"
bundle="messages.KiloBundle"
key="settings.context.displayName"/>
```
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.
+87
View File
@@ -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.<name>` | 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.
@@ -20,6 +20,7 @@ import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.CompactionConfigDto
import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomModelDto
import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto 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.TodoViewDto
import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.TokensDto
import ai.kilocode.rpc.dto.ToolRefDto import ai.kilocode.rpc.dto.ToolRefDto
import ai.kilocode.rpc.dto.WatcherConfigDto
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -521,6 +523,8 @@ object KiloCliDataParser {
subagentModel = obj.str("subagent_model"), subagentModel = obj.str("subagent_model"),
subagentVariant = obj.str("subagent_variant"), subagentVariant = obj.str("subagent_variant"),
defaultAgent = obj.str("default_agent"), defaultAgent = obj.str("default_agent"),
watcher = parseWatcherConfig(obj["watcher"].obj()),
compaction = parseCompactionConfig(obj["compaction"].obj()),
instructions = obj["instructions"].arr() instructions = obj["instructions"].arr()
?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } ?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
?: emptyList(), ?: emptyList(),
@@ -530,6 +534,24 @@ object KiloCliDataParser {
) )
}.getOrDefault(ConfigDto()) }.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? { private fun parseSkillsConfig(obj: JsonObject?): SkillsConfigDto? {
if (obj == null) return null if (obj == null) return null
return SkillsConfigDto( return SkillsConfigDto(
@@ -838,6 +860,24 @@ object KiloCliDataParser {
val instructions = patch.instructions val instructions = patch.instructions
if (instructions != null) put("instructions", JsonArray(instructions.map(::JsonPrimitive))) 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 val skills = patch.skills
if (skills != null) { if (skills != null) {
put("skills", buildJsonObject { put("skills", buildJsonObject {
@@ -9,7 +9,9 @@ import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.AgentConfigPatchDto
import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.WatcherPatchDto
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -222,6 +224,28 @@ class KiloBackendAppServiceTest {
assertEquals("fast", svc.config?.agent?.get("code")?.variant) 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 @Test
fun `ready dto maps model config`() = runBlocking { fun `ready dto maps model config`() = runBlocking {
mock.config = """{"model":"openai/gpt","agent":{"plan":{"model":"anthropic/claude","variant":"high"}}}""" mock.config = """{"model":"openai/gpt","agent":{"plan":{"model":"anthropic/claude","variant":"high"}}}"""
@@ -4,6 +4,7 @@ import ai.kilocode.backend.workspace.CommandInfo
import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.backend.workspace.ProviderData
import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.AgentConfigPatchDto
import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto 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.PromptPartDto
import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.SkillsPatchDto import ai.kilocode.rpc.dto.SkillsPatchDto
import ai.kilocode.rpc.dto.WatcherPatchDto
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1173,6 +1175,21 @@ class KiloCliDataParserTest {
assertEquals(listOf("https://example.test/skill.md"), cfg.skills?.urls) 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 @Test
fun `parseConfig - agent overrides and permissions`() { fun `parseConfig - agent overrides and permissions`() {
val cfg = KiloCliDataParser.parseConfig( 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 @Test
fun `buildConfigPatch - mcp upsert and delete`() { fun `buildConfigPatch - mcp upsert and delete`() {
val patch = ConfigPatchDto(mcp = linkedMapOf( val patch = ConfigPatchDto(mcp = linkedMapOf(
@@ -2,6 +2,7 @@ package ai.kilocode.client.settings
import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable 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.models.ModelsConfigurable
import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable
import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable
@@ -57,6 +58,14 @@ class KiloSettingsConfigurable : SearchableConfigurable {
models.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) models.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
panel.next(models) 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 providers = ActionLink(KiloBundle.message("settings.providers.displayName")) { e ->
val src = e.source as? JComponent ?: return@ActionLink val src = e.source as? JComponent ?: return@ActionLink
val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink
@@ -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<JComponent>() {
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"
}
}
@@ -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<String> = 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<String>()
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()
}
@@ -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<ContextSettingsContent, ContextDraft, ConfigPatchDto, KiloAppStateDto, Unit>(
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<String>) -> Unit,
) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) {
private val model = CollectionListModel<String>()
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<String>) {
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
@@ -33,7 +33,7 @@
<applicationConfigurable <applicationConfigurable
parentId="ai.kilocode.jetbrains.settings" parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.profile" id="ai.kilocode.jetbrains.settings.profile"
groupWeight="4" groupWeight="5"
instance="ai.kilocode.client.settings.profile.UserProfileConfigurable" instance="ai.kilocode.client.settings.profile.UserProfileConfigurable"
bundle="messages.KiloBundle" bundle="messages.KiloBundle"
key="settings.profile.displayName"/> key="settings.profile.displayName"/>
@@ -41,11 +41,19 @@
<applicationConfigurable <applicationConfigurable
parentId="ai.kilocode.jetbrains.settings" parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.models" id="ai.kilocode.jetbrains.settings.models"
groupWeight="3" groupWeight="4"
instance="ai.kilocode.client.settings.models.ModelsConfigurable" instance="ai.kilocode.client.settings.models.ModelsConfigurable"
bundle="messages.KiloBundle" bundle="messages.KiloBundle"
key="settings.models.displayName"/> key="settings.models.displayName"/>
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.context"
groupWeight="3"
instance="ai.kilocode.client.settings.context.ContextConfigurable"
bundle="messages.KiloBundle"
key="settings.context.displayName"/>
<applicationConfigurable <applicationConfigurable
parentId="ai.kilocode.jetbrains.settings" parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.providers" id="ai.kilocode.jetbrains.settings.providers"
@@ -303,6 +303,27 @@ settings.kilo.description=Configure Kilo Code AI coding assistant features and a
settings.cli.unavailable.title=Kilo Code is not connected settings.cli.unavailable.title=Kilo Code is not connected
settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again. settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again.
settings.models.displayName=Models settings.models.displayName=Models
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.placeholder=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.patterns.title=Ignored patterns
settings.context.watcher.patterns.description=Add one glob pattern per row.
settings.context.watcher.add=Add pattern
settings.context.watcher.empty=No ignore patterns configured.
settings.context.watcher.placeholder=e.g. **/dist/**
settings.providers.displayName=Providers settings.providers.displayName=Providers
settings.agentBehavior.displayName=Agent Behavior settings.agentBehavior.displayName=Agent Behavior
settings.agentBehavior.description=Configure agents, MCP servers, rules, workflows, and skills. settings.agentBehavior.description=Configure agents, MCP servers, rules, workflows, and skills.
@@ -1,6 +1,7 @@
package ai.kilocode.client.settings package ai.kilocode.client.settings
import ai.kilocode.client.settings.profile.UserProfileConfigurable 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.models.ModelsConfigurable
import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable
import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable
@@ -29,6 +30,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() {
assertEquals("ai.kilocode.jetbrains.settings.models", ModelsConfigurable.ID) 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`() { fun `test child provider and behavior ids match xml registration`() {
assertEquals("ai.kilocode.jetbrains.settings.providers", ProvidersConfigurable.ID) assertEquals("ai.kilocode.jetbrains.settings.providers", ProvidersConfigurable.ID)
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior", AgentBehaviorConfigurable.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`() { fun `test createComponent contains settings links in order`() {
val cfg = KiloSettingsConfigurable() val cfg = KiloSettingsConfigurable()
edt { edt {
val panel = cfg.createComponent() val panel = cfg.createComponent()
val labels = links(panel as Container).map { it.text } val labels = links(panel as Container).map { it.text }
assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior"), labels) assertEquals(listOf("User Profile", "Models", "Context", "Providers", "Agent Behavior"), labels)
} }
} }
@@ -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/**"))
assertFalse(changed(patch(draft, draft)))
}
@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`() {
val from = ContextDraft(threshold = "50")
val to = ContextDraft(threshold = "101")
assertEquals(ThresholdStatus.INVALID, thresholdStatus(to.threshold))
assertFalse(changed(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")))
}
}
@@ -0,0 +1,201 @@
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.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.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 javax.swing.AbstractButton
import javax.swing.JLabel
import javax.swing.JTextField
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<SettingsToggle>()
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<PatternList>().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<SettingsToggle>().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<JTextField>()
.single { it.columns == 8 }
private fun buttons(panel: ContextSettingsUi): List<AbstractButton> = components(panel).filterIsInstance<AbstractButton>()
private fun <T> 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<String>()
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<java.awt.Component> = buildList {
fun visit(comp: java.awt.Component) {
add(comp)
if (comp is Container) comp.components.forEach { visit(it) }
}
visit(root)
}
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.AgentConfigDto
import ai.kilocode.rpc.dto.CompactionConfigDto
import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.DeviceAuthDto 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.ProfileDto
import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.SkillsConfigDto
import ai.kilocode.rpc.dto.TelemetryCaptureDto import ai.kilocode.rpc.dto.TelemetryCaptureDto
import ai.kilocode.rpc.dto.WatcherConfigDto
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -196,12 +198,34 @@ class FakeAppRpcApi : KiloAppRpcApi {
val mcp = patch.mcp?.entries?.fold(config.mcp) { acc, (name, item) -> val mcp = patch.mcp?.entries?.fold(config.mcp) { acc, (name, item) ->
if (item == null) acc - name else acc + (name to item) if (item == null) acc - name else acc + (name to item)
} ?: config.mcp } ?: 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( return config.copy(
defaultAgent = if (values.containsKey("default_agent")) values["default_agent"] else config.defaultAgent, defaultAgent = if (values.containsKey("default_agent")) values["default_agent"] else config.defaultAgent,
model = if (values.containsKey("model")) values["model"] else config.model, model = if (values.containsKey("model")) values["model"] else config.model,
smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel, smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel,
subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel, subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel,
subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant, subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant,
watcher = watcher,
compaction = compaction,
instructions = patch.instructions ?: config.instructions, instructions = patch.instructions ?: config.instructions,
skills = patch.skills?.let { SkillsConfigDto(paths = it.paths.orEmpty(), urls = it.urls.orEmpty()) } ?: config.skills, skills = patch.skills?.let { SkillsConfigDto(paths = it.paths.orEmpty(), urls = it.urls.orEmpty()) } ?: config.skills,
mcp = mcp, mcp = mcp,
@@ -64,12 +64,26 @@ data class ConfigDto(
val subagentModel: String? = null, val subagentModel: String? = null,
val subagentVariant: String? = null, val subagentVariant: String? = null,
val defaultAgent: String? = null, val defaultAgent: String? = null,
val watcher: WatcherConfigDto? = null,
val compaction: CompactionConfigDto? = null,
val instructions: List<String> = emptyList(), val instructions: List<String> = emptyList(),
val skills: SkillsConfigDto? = null, val skills: SkillsConfigDto? = null,
val mcp: Map<String, McpConfigDto> = emptyMap(), val mcp: Map<String, McpConfigDto> = emptyMap(),
val agent: Map<String, AgentConfigDto> = emptyMap(), val agent: Map<String, AgentConfigDto> = emptyMap(),
) )
@Serializable
data class WatcherConfigDto(
val ignore: List<String> = emptyList(),
)
@Serializable
data class CompactionConfigDto(
val auto: Boolean? = null,
val threshold_percent: Double? = null,
val prune: Boolean? = null,
)
@Serializable @Serializable
data class SkillsConfigDto( data class SkillsConfigDto(
val paths: List<String> = emptyList(), val paths: List<String> = emptyList(),
@@ -109,12 +123,27 @@ sealed class PermissionRuleDto {
@Serializable @Serializable
data class ConfigPatchDto( data class ConfigPatchDto(
val values: Map<String, String?> = emptyMap(), val values: Map<String, String?> = emptyMap(),
val watcher: WatcherPatchDto? = null,
val compaction: CompactionPatchDto? = null,
val instructions: List<String>? = null, val instructions: List<String>? = null,
val skills: SkillsPatchDto? = null, val skills: SkillsPatchDto? = null,
val mcp: Map<String, McpConfigDto?>? = null, val mcp: Map<String, McpConfigDto?>? = null,
val agents: Map<String, AgentConfigPatchDto> = emptyMap(), val agents: Map<String, AgentConfigPatchDto> = emptyMap(),
) )
@Serializable
data class WatcherPatchDto(
val ignore: List<String>? = null,
)
@Serializable
data class CompactionPatchDto(
val clear: List<String> = emptyList(),
val auto: Boolean? = null,
val threshold_percent: Double? = null,
val prune: Boolean? = null,
)
@Serializable @Serializable
data class AgentConfigPatchDto( data class AgentConfigPatchDto(
val clear: List<String> = emptyList(), val clear: List<String> = emptyList(),