From aae91e4c528a99b335e77091bdc9ca157738b6ca Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 20 Jul 2026 15:22:16 -0400 Subject: [PATCH] feat(jetbrains): add auto-approve settings --- .../kilocode/backend/cli/KiloCliDataParser.kt | 4 + .../backend/cli/KiloCliDataParserTest.kt | 32 ++ .../ai/kilocode/client/app/KiloAppService.kt | 3 +- .../settings/KiloSettingsConfigurable.kt | 9 + .../autoapprove/AutoApproveConfigurable.kt | 18 + .../autoapprove/AutoApproveContent.kt | 90 +++++ .../autoapprove/AutoApproveSettingsState.kt | 187 +++++++++++ .../autoapprove/AutoApproveSettingsUi.kt | 86 +++++ .../autoapprove/GranularToolSection.kt | 84 +++++ .../settings/autoapprove/LevelSelect.kt | 61 ++++ .../autoapprove/SettingsInlineList.kt | 102 ++++++ .../client/settings/base/BaseSettingsUi.kt | 3 +- .../settings/base/DraftReadyConfigurable.kt | 9 +- .../settings/base/KiloReadyConfigurable.kt | 4 +- .../settings/base/SettingsInlineListPanel.kt | 132 ++++++++ .../client/settings/base/SettingsPanel.kt | 14 +- .../resources/kilo.jetbrains.frontend.xml | 10 +- .../resources/messages/KiloBundle.properties | 35 ++ .../settings/KiloSettingsConfigurableTest.kt | 10 +- .../AutoApproveSettingsStateTest.kt | 218 ++++++++++++ .../autoapprove/AutoApproveSettingsUiTest.kt | 310 ++++++++++++++++++ .../autoapprove/SettingsInlineListTest.kt | 169 ++++++++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 24 ++ .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 2 + 24 files changed, 1605 insertions(+), 11 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveConfigurable.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveContent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsUi.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/GranularToolSection.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/LevelSelect.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineList.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsStateTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsUiTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineListTest.kt 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 ecf5c9094e..e0bed48adb 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 @@ -532,6 +532,7 @@ object KiloCliDataParser { skills = parseSkillsConfig(obj["skills"].obj()), mcp = parseMcpConfig(obj["mcp"].obj()), agent = parseAgentConfig(obj["agent"].obj()), + permission = parsePermissionConfig(obj["permission"].obj()), ) }.getOrDefault(ConfigDto()) @@ -915,6 +916,9 @@ object KiloCliDataParser { }) } + val permission = patch.permission + if (permission != null) put("permission", buildPermission(permission)) + if (patch.agents.isNotEmpty()) { put("agent", buildJsonObject { for ((name, agent) in patch.agents) put(name, buildJsonObject { 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 41469467b7..8e71d6ce37 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 @@ -1235,6 +1235,23 @@ class KiloCliDataParserTest { assertNull(webfetch.value) } + @Test + fun `parseConfig - top-level permission map`() { + val cfg = KiloCliDataParser.parseConfig( + """{"permission":{"bash":"ask","read":{"*":"allow","*.env":"deny"},"webfetch":null}}""" + ) + val bash = cfg.permission?.get("bash") + val read = cfg.permission?.get("read") + val webfetch = cfg.permission?.get("webfetch") + + assertIs(bash) + assertEquals("ask", bash.value) + assertIs(read) + assertEquals(mapOf("*" to "allow", "*.env" to "deny"), read.map) + assertIs(webfetch) + assertNull(webfetch.value) + } + @Test fun `parseConfig - empty and missing blocks`() { val cfg = KiloCliDataParser.parseConfig("{}") @@ -2222,6 +2239,21 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildConfigPatch - full top-level permission object with null deletes`() { + val patch = ConfigPatchDto( + permission = linkedMapOf( + "bash" to PermissionRuleDto.Patterns(linkedMapOf("*" to "ask", "npm test" to "allow")), + "read" to PermissionRuleDto.Level(null), + ), + ) + + assertEquals( + "{\"permission\":{\"bash\":{\"*\":\"ask\",\"npm test\":\"allow\"},\"read\":null}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + @Test fun `buildConfigPatch - empty patch`() { assertEquals("{}", KiloCliDataParser.buildConfigPatch(ConfigPatchDto())) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index b24e42f992..79e186b29d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -366,5 +366,6 @@ data class CoreInfo(val version: String, val platform: String) private fun summary(patch: ConfigPatchDto): String { val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } - return "values=$values agents=${patch.agents.size}" + val permission = if (patch.permission != null) " permission" else "" + return "values=$values agents=${patch.agents.size}$permission" } 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 e94db1b366..5197413d31 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.autoapprove.AutoApproveConfigurable import ai.kilocode.client.settings.context.ContextConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable @@ -74,6 +75,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { behavior.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(behavior) + val autoApprove = ActionLink(KiloBundle.message("settings.autoApprove.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, AutoApproveConfigurable.ID) + } + autoApprove.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(autoApprove) + 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveConfigurable.kt new file mode 100644 index 0000000000..6d0a563125 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveConfigurable.kt @@ -0,0 +1,18 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.ScrollableDraftReadyConfigurable +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class AutoApproveConfigurable : ScrollableDraftReadyConfigurable() { + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.autoApprove.displayName") + + override fun create(cs: CoroutineScope): JComponent = AutoApproveSettingsUi(cs) + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.autoApprove" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveContent.kt new file mode 100644 index 0000000000..75270a6f56 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveContent.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsRow +import com.intellij.util.concurrency.annotations.RequiresEdt + +private enum class ExceptionKind { PATH, COMMAND } + +private val GRANULAR_TOOLS = listOf( + "external_directory" to ExceptionKind.PATH, + "bash" to ExceptionKind.COMMAND, + "read" to ExceptionKind.PATH, + "edit" to ExceptionKind.PATH, +) + +private val SIMPLE_TOOLS = listOf("glob", "grep", "list", "task", "skill", "lsp") +private val GROUPED_IDS = listOf("todoread", "todowrite") +private val TRAILING_TOOLS = listOf("websearch", "webfetch", "doom_loop") + +/** + * Full Auto-Approve page layout: four granular tool sections, then a section covering the + * remaining simple/grouped/trailing tools, in the exact order used by VS Code's + * `PermissionEditor.tsx`. + */ +internal class AutoApproveContent( + private val update: (PermissionDraft.() -> PermissionDraft) -> Unit, +) : BaseContentPanel() { + private val granular = GRANULAR_TOOLS.map { (id, kind) -> id to granularSection(id, kind) } + + private val grouped = LevelSelect( + { level -> update { setGrouped(this, GROUPED_IDS, level) } }, + { update { inheritGrouped(this, GROUPED_IDS) } }, + ) + + private val simple = SIMPLE_TOOLS.associateWith { id -> simpleSelect(id) } + private val trailing = TRAILING_TOOLS.associateWith { id -> simpleSelect(id) } + + init { + granular.forEach { (_, section) -> next(section) } + + val rows = section(KiloBundle.message("settings.autoApprove.title")) + for (id in SIMPLE_TOOLS) { + rows.row(SettingsRow(toolTitle(id), KiloBundle.message("settings.autoApprove.tool.$id"), simple.getValue(id))) + } + rows.row(SettingsRow( + "Todoread / Todowrite", + KiloBundle.message("settings.autoApprove.tool.todoreadwrite"), + grouped, + )) + for (id in TRAILING_TOOLS) { + rows.row(SettingsRow(toolTitle(id), KiloBundle.message("settings.autoApprove.tool.$id"), trailing.getValue(id))) + } + } + + @RequiresEdt + fun sync(draft: PermissionDraft, enabled: Boolean) { + for ((id, section) in granular) section.sync(draft.rules[id], enabled) + for ((id, select) in simple) select.sync(effectiveLevel(draft, id), inheritedWildcard(draft.rules[id]), enabled) + grouped.sync( + mostRestrictive(GROUPED_IDS.map { effectiveLevel(draft, it) }), + GROUPED_IDS.all { inheritedWildcard(draft.rules[it]) }, + enabled, + ) + for ((id, select) in trailing) select.sync(effectiveLevel(draft, id), inheritedWildcard(draft.rules[id]), enabled) + } + + private fun granularSection(tool: String, kind: ExceptionKind): GranularToolSection { + val wildcardKey = if (kind == ExceptionKind.COMMAND) "commands" else "paths" + val addKey = if (kind == ExceptionKind.COMMAND) "addCommand" else "addPath" + val placeholderKey = if (kind == ExceptionKind.COMMAND) "placeholder.command" else "placeholder.path" + return GranularToolSection( + tool, + KiloBundle.message("settings.autoApprove.tool.$tool"), + KiloBundle.message("settings.autoApprove.wildcardLabel.$wildcardKey"), + KiloBundle.message("settings.autoApprove.$addKey"), + KiloBundle.message("settings.autoApprove.$placeholderKey"), + { level -> update { setWildcard(this, tool, level) } }, + { update { inheritWildcard(this, tool) } }, + { pattern -> update { addException(this, tool, pattern) } }, + { pattern, level -> update { setException(this, tool, pattern, level) } }, + { patterns -> update { removeExceptions(this, tool, patterns) } }, + ) + } + + private fun simpleSelect(tool: String): LevelSelect = LevelSelect( + { level -> update { setWildcard(this, tool, level) } }, + { update { inheritWildcard(this, tool) } }, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsState.kt new file mode 100644 index 0000000000..406596d7b8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsState.kt @@ -0,0 +1,187 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.PermissionConfigDto +import ai.kilocode.rpc.dto.PermissionRuleDto + +/** + * Draft state for the Auto-Approve settings page: the desired `config.permission` map. + * + * Rule maps never hold explicit `null` values here — a tool/pattern is either present (with a + * real level) or absent (inherited). `null` values only appear in the [PermissionConfigDto] patch + * produced by [permissionPatch], where they signal deletion to the CLI's PATCH merge. + */ +internal data class PermissionDraft(val rules: Map = emptyMap()) + +/** The three permission levels, in restrictiveness order. Shared by [LevelSelect] and [SettingsInlineList]. */ +internal val LEVELS = listOf("allow", "ask", "deny") + +// Keep aligned with the CLI's DEFAULT_RULES (permission-utils.ts:8-13). +private val DEFAULT_LEVEL = mapOf( + "external_directory" to "ask", + "bash" to "ask", + "doom_loop" to "ask", +) + +private val RESTRICTION_ORDER = mapOf("allow" to 0, "ask" to 1, "deny" to 2) + +internal fun defaultLevel(tool: String): String = DEFAULT_LEVEL[tool] ?: "allow" + +internal fun permissionDraft(config: ConfigDto?): PermissionDraft = PermissionDraft(config?.permission ?: emptyMap()) + +internal fun wildcardLevel(rule: PermissionRuleDto?): String? = when (rule) { + null -> null + is PermissionRuleDto.Level -> rule.value + is PermissionRuleDto.Patterns -> rule.map["*"] +} + +internal fun inheritedWildcard(rule: PermissionRuleDto?): Boolean = when (rule) { + null -> true + is PermissionRuleDto.Level -> false + is PermissionRuleDto.Patterns -> rule.map["*"] == null +} + +internal fun effectiveLevel(draft: PermissionDraft, tool: String): String = + wildcardLevel(draft.rules[tool]) ?: defaultLevel(tool) + +internal fun exceptions(rule: PermissionRuleDto?): List> { + if (rule !is PermissionRuleDto.Patterns) return emptyList() + return rule.map.entries + .filter { it.key != "*" && it.value != null } + .map { it.key to it.value!! } +} + +internal fun mostRestrictive(levels: List): String { + val start = levels.firstOrNull() ?: "allow" + return levels.fold(start) { best, level -> + if ((RESTRICTION_ORDER[level] ?: 0) > (RESTRICTION_ORDER[best] ?: 0)) level else best + } +} + +/** Set the wildcard level for [tool], preserving any existing exceptions. */ +internal fun setWildcard(draft: PermissionDraft, tool: String, level: String): PermissionDraft { + val excs = exceptions(draft.rules[tool]) + val rule = if (excs.isEmpty()) { + PermissionRuleDto.Level(level) + } else { + PermissionRuleDto.Patterns(mapOf("*" to level) + excs.toMap()) + } + return draft.copy(rules = draft.rules + (tool to rule)) +} + +/** Revert [tool]'s wildcard to the CLI default, preserving any existing exceptions. */ +internal fun inheritWildcard(draft: PermissionDraft, tool: String): PermissionDraft { + val excs = exceptions(draft.rules[tool]) + return if (excs.isNotEmpty()) { + draft.copy(rules = draft.rules + (tool to PermissionRuleDto.Patterns(excs.toMap()))) + } else { + draft.copy(rules = draft.rules - tool) + } +} + +/** Set (add or change) a single exception pattern's level for [tool]. */ +internal fun setException(draft: PermissionDraft, tool: String, pattern: String, level: String): PermissionDraft { + val rule = draft.rules[tool] + val base = when (rule) { + null -> emptyMap() + is PermissionRuleDto.Level -> rule.value?.let { mapOf("*" to it) } ?: emptyMap() + is PermissionRuleDto.Patterns -> rule.map.mapNotNull { (key, value) -> value?.let { key to it } }.toMap() + } + return draft.copy(rules = draft.rules + (tool to PermissionRuleDto.Patterns(base + (pattern to level)))) +} + +/** Add a new exception pattern for [tool], defaulting its level to allow. */ +internal fun addException(draft: PermissionDraft, tool: String, pattern: String): PermissionDraft = + setException(draft, tool, pattern, "allow") + +/** Remove a single exception pattern for [tool]. */ +internal fun removeException(draft: PermissionDraft, tool: String, pattern: String): PermissionDraft { + val rule = draft.rules[tool] as? PermissionRuleDto.Patterns ?: return draft + val map = rule.map.filterKeys { it != pattern } + return if (map.isEmpty()) { + draft.copy(rules = draft.rules - tool) + } else { + draft.copy(rules = draft.rules + (tool to PermissionRuleDto.Patterns(map))) + } +} + +internal fun removeExceptions(draft: PermissionDraft, tool: String, patterns: List): PermissionDraft { + if (patterns.isEmpty()) return draft + val rule = draft.rules[tool] as? PermissionRuleDto.Patterns ?: return draft + val remove = patterns.toSet() + val map = rule.map.filterKeys { it !in remove } + return if (map.isEmpty()) { + draft.copy(rules = draft.rules - tool) + } else { + draft.copy(rules = draft.rules + (tool to PermissionRuleDto.Patterns(map))) + } +} + +/** Apply the same scalar level to every id in a grouped row (e.g. todoread/todowrite). */ +internal fun setGrouped(draft: PermissionDraft, ids: List, level: String): PermissionDraft = + draft.copy(rules = draft.rules + ids.associateWith { PermissionRuleDto.Level(level) }) + +/** Revert every id in a grouped row to the CLI default. */ +internal fun inheritGrouped(draft: PermissionDraft, ids: List): PermissionDraft = + draft.copy(rules = draft.rules - ids.toSet()) + +/** + * Diff [from] (baseline) against [to] (draft) into a single [PermissionConfigDto] patch, or + * `null` if there is nothing to send. See the plan's diff algorithm for the exact semantics: + * missing-in-`to` tools are deleted (`Level(null)`), new tools are sent in full, and for tools + * present in both, changed `Patterns` rules include `null` deletes for every pattern (including + * `*`) that existed in `from` but is absent from `to`. + */ +internal fun permissionPatch(from: PermissionDraft, to: PermissionDraft): PermissionConfigDto? { + val result = mutableMapOf() + for (tool in from.rules.keys + to.rules.keys) { + val fromRule = from.rules[tool] + val toRule = to.rules[tool] + if (toRule == null) { + if (fromRule != null) result[tool] = PermissionRuleDto.Level(null) + continue + } + if (fromRule == null) { + result[tool] = toRule + continue + } + if (fromRule == toRule) continue + result[tool] = when (toRule) { + is PermissionRuleDto.Level -> toRule + is PermissionRuleDto.Patterns -> { + val map = toRule.map.toMutableMap() + if (fromRule is PermissionRuleDto.Patterns) { + for (key in fromRule.map.keys) { + if (key !in toRule.map) map[key] = null + } + } + PermissionRuleDto.Patterns(map) + } + } + } + return result.takeIf { it.isNotEmpty() } +} + +// Named `patch` (not `change`) to avoid colliding with BaseSettingsUi's `change()` override, which +// would otherwise resolve to itself and recurse infinitely instead of calling this top-level helper. +internal fun patch(from: PermissionDraft, to: PermissionDraft): ConfigPatchDto? = + permissionPatch(from, to)?.let { ConfigPatchDto(permission = it) } + +private fun normalize(rules: Map): Map = + rules.mapNotNull { (tool, rule) -> + when (rule) { + is PermissionRuleDto.Level -> rule.value?.let { tool to rule } + is PermissionRuleDto.Patterns -> { + val map = rule.map.filterValues { it != null } + if (map.isEmpty()) null else tool to PermissionRuleDto.Patterns(map) + } + } + }.toMap() + +internal fun savedMatches(base: PermissionDraft, draft: PermissionDraft): Boolean = + normalize(base.rules) == normalize(draft.rules) + +/** `external_directory` -> `External Directory`. */ +internal fun toolTitle(id: String): String = + id.split("_").joinToString(" ") { word -> word.replaceFirstChar { it.uppercaseChar() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsUi.kt new file mode 100644 index 0000000000..ea32f7d25b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/AutoApproveSettingsUi.kt @@ -0,0 +1,86 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope + +internal class AutoApproveSettingsUi( + cs: CoroutineScope, + private val app: KiloAppService = service(), + workspaces: KiloWorkspaceService = service(), +) : BaseSettingsUi( + cs, + PermissionDraft(), + app, + workspaces, + loginBanner = false, + scroll = false, +) { + init { + startSettings(AutoApproveContent { updateDraft(it) }) + } + + override fun change(from: PermissionDraft, to: PermissionDraft): ConfigPatchDto? = patch(from, to) + + override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { + app.updateConfigAsync(change, done) + } + + override fun base(result: KiloAppStateDto): PermissionDraft = permissionDraft(result.config) + + override fun draft(state: KiloAppStateDto): PermissionDraft = permissionDraft(state.config) + + override fun saved(base: PermissionDraft, draft: PermissionDraft): Boolean = savedMatches(base, draft) + + override fun pendingText(): String = KiloBundle.message("settings.autoApprove.save.pending") + + override fun failedText(): String = KiloBundle.message("settings.autoApprove.save.failed") + + override suspend fun loadWorkspace(root: String) = Unit + + override fun applyWorkspace(result: Unit) = Unit + + override fun logSaveStarted(change: ConfigPatchDto) = LOG.info("auto-approve settings save: started") + + override fun logSaveCompleted(change: ConfigPatchDto) = LOG.info("auto-approve settings save: completed") + + override fun logSaveFailed(change: ConfigPatchDto) = LOG.warn("auto-approve settings save: failed") + + override fun logSaveFailedAfterDispose(change: ConfigPatchDto) = LOG.warn("auto-approve settings save: failed after dispose") + + override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("auto-approve settings save: completed after dispose") + + @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.autoApprove.save.pending")) + return + } + if (err != null) { + showError(err) + return + } + if (!ready) { + showProgress(KiloBundle.message("settings.cli.unavailable.message")) + return + } + clearProgress() + } + + private companion object { + val LOG = KiloLog.create(AutoApproveSettingsUi::class.java) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/GranularToolSection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/GranularToolSection.kt new file mode 100644 index 0000000000..13ef4ccd90 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/GranularToolSection.kt @@ -0,0 +1,84 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.PermissionRuleDto +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +private const val AUTO_EXPAND_THRESHOLD = 5 + +/** + * One granular permission tool (`external_directory`, `bash`, `read`, `edit`): a wildcard + * [LevelSelect] row plus a collapsible list of per-pattern exceptions. Instantiated once per tool + * by [AutoApproveContent]. + */ +internal class GranularToolSection( + private val tool: String, + description: String, + wildcardLabel: String, + addLabel: String, + placeholder: String, + private val onWildcardChange: (String) -> Unit, + private val onWildcardInherit: () -> Unit, + private val onExceptionAdd: (String) -> Unit, + private val onExceptionSetLevel: (String, String) -> Unit, + private val onExceptionRemove: (List) -> Unit, +) : BaseContentPanel() { + private val wildcard = LevelSelect(onWildcardChange) { onWildcardInherit() } + private val wildcardRow = SettingsRow(wildcardLabel, null, wildcard) + private val header = JBLabel().apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = toggle() + }) + } + private val list = SettingsInlineList(addLabel, placeholder, onExceptionAdd, onExceptionSetLevel, onExceptionRemove) + private val body = Stack.vertical(UiStyle.Gap.sm()).next(header) + + private var manualExpand: Boolean? = null + private var exceptionCount = 0 + + init { + section(toolTitle(tool), description) + .row(wildcardRow) + .row(body) + } + + @RequiresEdt + fun sync(rule: PermissionRuleDto?, enabled: Boolean) { + wildcard.sync(wildcardLevel(rule) ?: defaultLevel(tool), inheritedWildcard(rule), enabled) + val excs = exceptions(rule) + exceptionCount = excs.size + header.text = KiloBundle.message("settings.autoApprove.exceptions.count", excs.size) + list.syncItems(excs, enabled) + syncExpanded() + } + + @RequiresEdt + internal fun isExpanded(): Boolean = body.components.contains(list) + + private fun toggle() { + manualExpand = !expanded() + syncExpanded() + } + + private fun expanded(): Boolean = manualExpand ?: (exceptionCount <= AUTO_EXPAND_THRESHOLD) + + private fun syncExpanded() { + val next = expanded() + val icon = if (next) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + if (header.icon !== icon) header.icon = icon + if (next == isExpanded()) return + if (next) body.next(list) else body.remove(list) + body.revalidate() + body.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/LevelSelect.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/LevelSelect.kt new file mode 100644 index 0000000000..d3d0faf001 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/LevelSelect.kt @@ -0,0 +1,61 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.event.ItemEvent +import javax.swing.DefaultComboBoxModel + +internal fun levelLabel(level: String): String = when (level) { + "allow" -> KiloBundle.message("settings.autoApprove.level.allow") + "ask" -> KiloBundle.message("settings.autoApprove.level.ask") + "deny" -> KiloBundle.message("settings.autoApprove.level.deny") + else -> level +} + +/** + * Reusable Allow/Ask/Deny combo, optionally prefixed with a "Default (X)" inherit option. + * Used by every non-list permission row and every granular wildcard row. + */ +internal class LevelSelect( + private val onChange: (String) -> Unit, + private val onInherit: (() -> Unit)? = null, +) : ComboBox(DefaultComboBoxModel()) { + + internal sealed class Item { + data class Default(val resolved: String) : Item() + data class Level(val value: String) : Item() + } + + private var syncing = false + + init { + renderer = SimpleListCellRenderer.create("") { item -> + when (item) { + is Item.Default -> KiloBundle.message("settings.autoApprove.default", levelLabel(item.resolved)) + is Item.Level -> levelLabel(item.value) + } + } + addItemListener { e -> + if (syncing || e.stateChange != ItemEvent.SELECTED) return@addItemListener + when (val item = e.item as? Item) { + is Item.Default -> onInherit?.invoke() + is Item.Level -> onChange(item.value) + null -> Unit + } + } + } + + @RequiresEdt + fun sync(currentLevel: String, inherited: Boolean, enabled: Boolean) { + syncing = true + val next = DefaultComboBoxModel() + if (onInherit != null) next.addElement(Item.Default(currentLevel)) + for (level in LEVELS) next.addElement(Item.Level(level)) + model = next + selectedItem = if (inherited && onInherit != null) Item.Default(currentLevel) else Item.Level(currentLevel) + isEnabled = enabled + syncing = false + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineList.kt new file mode 100644 index 0000000000..14f11b5f22 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineList.kt @@ -0,0 +1,102 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.SettingsInlineListPanel +import ai.kilocode.client.settings.base.SettingsListCell +import ai.kilocode.client.settings.base.SettingsListConfig +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.SettingsToolbarAction +import ai.kilocode.client.settings.base.settingsListCellBounds +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.awt.RelativePoint +import java.awt.Point +import javax.swing.ListSelectionModel + +/** + * Embeddable exception list for granular auto-approve tools. Uses the standard inline settings + * list layout: toolbar, filter field, then list. The list supports multi-selection so toolbar + * delete can remove many exceptions at once. + */ +internal class SettingsInlineList( + private val addLabel: String, + private val placeholder: String, + private val onAdd: (String) -> Unit, + private val onSetLevel: (String, String) -> Unit, + private val onRemove: (List) -> Unit, +) : SettingsInlineListPanel( + KiloBundle.message("settings.autoApprove.exceptions.empty"), + SettingsListConfig.Equal, + ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, +) { + + /** Overridable in tests, mirrors `PatternList.input` in ContextSettingsUi.kt. */ + internal var input: () -> String? = { + Messages.showInputDialog(this, placeholder, addLabel, null) + } + + init { + start() + } + + fun syncItems(exceptions: List>, enabled: Boolean) { + setItems(exceptions.map { (pattern, level) -> ExceptionItem(pattern, level) }, enabled) + } + + override fun onCell(key: String, cellId: String) { + if (!isEnabled) return + if (cellId == "level") showLevelPopup(key) + } + + override fun toolbarActions(): List = listOf( + SettingsToolbarAction( + KiloBundle.message("settings.autoApprove.add"), + addLabel, + AllIcons.General.Add, + { isEnabled }, + ) { promptAdd() }, + SettingsToolbarAction( + KiloBundle.message("settings.autoApprove.delete"), + KiloBundle.message("settings.autoApprove.delete.description"), + AllIcons.General.Remove, + { isEnabled && selectedKeys().isNotEmpty() }, + ) { removeSelected() }, + ) + + private fun promptAdd() { + if (!isEnabled) return + val value = input()?.trim().orEmpty() + if (value.isBlank()) return + onAdd(value) + } + + private fun removeSelected() { + val keys = selectedKeys() + if (keys.isEmpty()) return + onRemove(keys) + } + + private fun showLevelPopup(pattern: String) { + val model = view.list.model + val index = (0 until model.size).firstOrNull { (model.getElementAt(it) as? ExceptionItem)?.pattern == pattern } + ?: return + val bounds = settingsListCellBounds(view.list, index, index == view.list.selectedIndex)["level"] ?: return + JBPopupFactory.getInstance() + .createPopupChooserBuilder(LEVELS) + .setRenderer(SimpleListCellRenderer.create("") { levelLabel(it) }) + .setItemChosenCallback { level -> onSetLevel(pattern, level) } + .createPopup() + .show(RelativePoint(view.list, Point(bounds.x, bounds.y + bounds.height))) + } + + private data class ExceptionItem(val pattern: String, val level: String) : SettingsListItem { + override val key: String get() = pattern + override val title: String get() = pattern + override val cells: List = listOf( + SettingsListCell(id = "level", label = levelLabel(level), alwaysVisible = true), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt index 35f4452f77..0f207efac6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -36,7 +36,8 @@ internal abstract class BaseSettingsUi( private val workspaces: KiloWorkspaceService = service(), private val hint: String? = null, private val loginBanner: Boolean = true, -) : SettingsPanel(), SettingsDraftPage { + scroll: Boolean = true, +) : SettingsPanel(scroll), SettingsDraftPage { protected lateinit var form: C private set protected val jobs = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/DraftReadyConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/DraftReadyConfigurable.kt index 55f6602e65..e3529d7cb9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/DraftReadyConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/DraftReadyConfigurable.kt @@ -1,10 +1,11 @@ package ai.kilocode.client.settings.base import com.intellij.openapi.Disposable +import com.intellij.openapi.options.Configurable import kotlinx.coroutines.CoroutineScope import javax.swing.JComponent -abstract class DraftReadyConfigurable : KiloReadyConfigurable() { +abstract class DraftReadyConfigurableBase : KiloReadyConfigurableBase() { private var panel: T? = null final override fun createReadyComponent(cs: CoroutineScope): JComponent { @@ -31,3 +32,9 @@ abstract class DraftReadyConfigurable : KiloReadyConfigurable() protected abstract fun create(cs: CoroutineScope): T } + +abstract class DraftReadyConfigurable : DraftReadyConfigurableBase(), Configurable.NoScroll + +abstract class ScrollableDraftReadyConfigurable : DraftReadyConfigurableBase() { + override fun scrollReadyShell(): Boolean = false +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt index 638821550a..164563489f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.withContext import java.awt.BorderLayout import javax.swing.JComponent -abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoScroll { +abstract class KiloReadyConfigurableBase : SearchableConfigurable { private var shell: SettingsOverlayPanel? = null private var scope: CoroutineScope? = null private var ready: JComponent? = null @@ -161,3 +161,5 @@ abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoSc val edt = Dispatchers.EDT + ModalityState.any().asContextElement() } } + +abstract class KiloReadyConfigurable : KiloReadyConfigurableBase(), Configurable.NoScroll diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt new file mode 100644 index 0000000000..2f5b2e44e5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsInlineListPanel.kt @@ -0,0 +1,132 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SearchTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.event.KeyEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent + +/** + * Lightweight embedded settings list layout: toolbar, filter text field, then list. + * + * Use [SettingsListPanel] for full async settings pages. Use this class for retained inline lists + * embedded inside an existing settings form. + */ +internal abstract class SettingsInlineListPanel( + emptyText: String, + cfg: SettingsListConfig = SettingsListConfig.Equal, + private val selectionMode: Int = ListSelectionModel.SINGLE_SELECTION, +) : BaseContentPanel() { + private val search = SearchTextField(false) + protected val view = SettingsListView(emptyText, cfg) { key, cellId -> onCell(key, cellId) } + private var toolbar: ActionToolbar? = null + + @RequiresEdt + protected fun start() { + checkEdt() + view.list.selectionMode = selectionMode + view.minimumSize = JBUI.size(0, minListHeight()) + view.list.minimumSize = JBUI.size(0, minListHeight()) + view.onSelect = { toolbar?.updateActionsImmediately() } + next(toolbarRow()) + gap(UiStyle.Gap.sm()) + next(search) + gap(UiStyle.Gap.sm()) + next(view) + wireSearch() + } + + @RequiresEdt + fun setItems(items: List, enabled: Boolean) { + checkEdt() + view.update(items, SettingsListSelection.Preserve) + setEnabled(enabled) + toolbar?.updateActionsImmediately() + } + + @RequiresEdt + protected fun selectedKeys(): List { + checkEdt() + return view.list.selectedValuesList.map { it.key } + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + search.isEnabled = enabled + search.textEditor.isEnabled = enabled + view.isEnabled = enabled + view.setBusy(!enabled) + toolbar?.updateActionsImmediately() + } + + override fun getPreferredSize(): Dimension { + val base = super.getPreferredSize() + val missing = maxOf(0, minListHeight() - view.preferredSize.height) + return Dimension(base.width, base.height + missing) + } + + override fun getMinimumSize(): Dimension = preferredSize + + protected abstract fun onCell(key: String, cellId: String) + + protected open fun toolbarActions(): List = emptyList() + + private fun toolbarRow(): JComponent { + val row = JPanel(BorderLayout()) + UiStyle.Components.transparent(row) + toolbar = ActionManager.getInstance().createActionToolbar( + ActionPlaces.TOOLBAR, + DefaultActionGroup(toolbarActions()), + true, + ).apply { + targetComponent = this@SettingsInlineListPanel + updateActionsImmediately() + } + row.add(toolbar!!.component, BorderLayout.WEST) + return row + } + + private fun wireSearch() { + search.textEditor.registerKeyboardAction( + { view.primary() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(-1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + view.filter(search.text) + } + }) + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Settings inline list updates must run on EDT" } + } + + private fun minListHeight() = UiStyle.Gap.xl() + UiStyle.Gap.pad() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt index cf87dd58ff..174eed4139 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt @@ -11,7 +11,7 @@ import javax.swing.JComponent import javax.swing.ScrollPaneConstants import javax.swing.Scrollable -internal open class SettingsPanel : SettingsOverlayPanel() { +internal open class SettingsPanel(scroll: Boolean = true) : SettingsOverlayPanel() { val top = SettingsTop() val settings = Stack.vertical() @@ -20,10 +20,14 @@ internal open class SettingsPanel : SettingsOverlayPanel() { .next(top) .gap(UiStyle.Gap.lg()) .next(settings) - content.add(JBScrollPane(body).apply { - border = null - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - }, BorderLayout.CENTER) + if (scroll) { + content.add(JBScrollPane(body).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + }, BorderLayout.CENTER) + } else { + content.add(body, BorderLayout.CENTER) + } } fun setContent(component: JComponent) { 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 a498f01ab4..85134af5ae 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 @@ -65,11 +65,19 @@ + + = + components(panel).filterIsInstance() + + private fun levelSelectFor(panel: AutoApproveSettingsUi, tool: String): LevelSelect { + val index = LEVEL_SELECT_ORDER.indexOf(tool) + require(index >= 0) { "unknown tool $tool" } + return levelSelects(panel)[index] + } + + private fun inlineListFor(panel: AutoApproveSettingsUi, tool: String): SettingsInlineList { + val index = GRANULAR_ORDER.indexOf(tool) + require(index >= 0) { "unknown granular tool $tool" } + return components(panel).filterIsInstance()[index] + } + + private fun removeException(panel: AutoApproveSettingsUi, tool: String, pattern: String) { + val list = inlineListFor(panel, tool) + val jList = components(list).filterIsInstance>().single() + val idx = (0 until jList.model.size).first { jList.model.getElementAt(it).toString().contains(pattern) } + jList.selectedIndex = idx + UIUtil.dispatchAllInvocationEvents() + click(button(list, 1)) + } + + private fun button(list: SettingsInlineList, index: Int): JComponent = components(list) + .filterIsInstance() + .filter { it.javaClass.name.endsWith("ActionButton") } + .let { it[index] } + + private fun click(target: JComponent) { + target.setSize(target.preferredSize) + val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) + val press = MouseEvent( + target, + MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), + InputEvent.BUTTON1_DOWN_MASK, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + val release = MouseEvent( + target, + MouseEvent.MOUSE_RELEASED, + System.currentTimeMillis(), + 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + val clicked = MouseEvent( + target, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + target.dispatchEvent(press) + target.dispatchEvent(release) + target.dispatchEvent(clicked) + UIUtil.dispatchAllInvocationEvents() + } + + private fun selectLevel(combo: LevelSelect, level: String) { + val item = (0 until combo.itemCount).map { combo.getItemAt(it) } + .first { it is LevelSelect.Item.Level && it.value == level } + combo.selectedItem = item + } + + private fun selectInherit(combo: LevelSelect) { + val item = (0 until combo.itemCount).map { combo.getItemAt(it) }.first { it is LevelSelect.Item.Default } + combo.selectedItem = item + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(200) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } + + private companion object { + val GRANULAR_ORDER = listOf("external_directory", "bash", "read", "edit") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineListTest.kt new file mode 100644 index 0000000000..c058800de8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/autoapprove/SettingsInlineListTest.kt @@ -0,0 +1,169 @@ +package ai.kilocode.client.settings.autoapprove + +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBList +import com.intellij.util.ui.UIUtil +import java.awt.Container +import java.awt.Point +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import javax.swing.JComponent + +class SettingsInlineListTest : BasePlatformTestCase() { + fun `test empty list keeps minimum height for empty text`() { + edt { + val list = list() + list.syncItems(emptyList(), true) + layout(list) + + assertTrue(jbList(list).minimumSize.height >= UiStyle.Gap.xl()) + } + } + + fun `test filtering to no rows keeps minimum empty list area`() { + edt { + val list = list() + list.syncItems(listOf("*.env" to "deny", "*.key" to "deny", "*.pem" to "deny"), true) + layout(list) + + search(list).text = "nomatch" + layout(list) + + assertEquals(0, jbList(list).model.size) + assertTrue(jbList(list).minimumSize.height >= UiStyle.Gap.xl()) + } + } + + fun `test toolbar delete removes selected rows in bulk`() { + edt { + val removed = mutableListOf() + val list = SettingsInlineList("Add", "e.g. *.env", {}, { _, _ -> }, { removed += it }) + list.syncItems(listOf("*.env" to "deny", "*.key" to "deny"), true) + layout(list) + + val jList = jbList(list) + jList.setSelectionInterval(0, 1) + UIUtil.dispatchAllInvocationEvents() + click(button(list, 1)) + + assertEquals(listOf("*.env", "*.key"), removed) + } + } + + fun `test toolbar add invokes onAdd with the input override value`() { + edt { + val added = mutableListOf() + val list = SettingsInlineList("Add", "e.g. *.env", { added += it }, { _, _ -> }, {}) + list.input = { "git *" } + layout(list) + + click(button(list, 0)) + + assertEquals(listOf("git *"), added) + } + } + + fun `test syncItems retains the same list view instance across updates`() { + edt { + val list = list() + list.syncItems(listOf("*.env" to "deny"), true) + val jList = jbList(list) + + list.syncItems(listOf("*.env" to "deny", "*.key" to "deny"), true) + + assertSame(jList, jbList(list)) + } + } + + fun `test setEnabled disables search add and list`() { + edt { + val list = list() + list.syncItems(listOf("*.env" to "deny"), true) + + list.setEnabled(false) + + assertFalse(button(list, 0).isEnabled) + assertFalse(jbList(list).isEnabled) + } + } + + private fun list(): SettingsInlineList = SettingsInlineList("Add", "e.g. *.env", {}, { _, _ -> }, {}) + + private fun jbList(list: SettingsInlineList): JBList<*> = components(list).filterIsInstance>().single() + + private fun search(list: SettingsInlineList): javax.swing.text.JTextComponent = + components(list).filterIsInstance().first() + + private fun layout(root: Container) { + root.setSize(400, root.preferredSize.height.coerceAtLeast(50)) + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + UIUtil.dispatchAllInvocationEvents() + } + + private fun button(list: SettingsInlineList, index: Int): JComponent = components(list) + .filterIsInstance() + .filter { it.javaClass.name.endsWith("ActionButton") } + .let { it[index] } + + private fun click(target: JComponent) { + target.setSize(target.preferredSize) + val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) + val press = MouseEvent( + target, + MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), + InputEvent.BUTTON1_DOWN_MASK, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + val release = MouseEvent( + target, + MouseEvent.MOUSE_RELEASED, + System.currentTimeMillis(), + 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + val clicked = MouseEvent( + target, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + target.dispatchEvent(press) + target.dispatchEvent(release) + target.dispatchEvent(clicked) + UIUtil.dispatchAllInvocationEvents() + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(item: java.awt.Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } +} 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 57868aadf0..0fc7bc5248 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 @@ -14,6 +14,8 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto +import ai.kilocode.rpc.dto.PermissionConfigDto +import ai.kilocode.rpc.dto.PermissionRuleDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.TelemetryCaptureDto @@ -230,9 +232,31 @@ class FakeAppRpcApi : KiloAppRpcApi { skills = patch.skills?.let { SkillsConfigDto(paths = it.paths.orEmpty(), urls = it.urls.orEmpty()) } ?: config.skills, mcp = mcp, agent = agents, + permission = mergePermission(config.permission, patch.permission), ) } + /** Mirrors the CLI's PATCH deep-merge for `config.permission`: `null` deletes a tool/pattern. */ + private fun mergePermission(base: PermissionConfigDto?, patch: PermissionConfigDto?): PermissionConfigDto? { + if (patch == null) return base + val result = (base ?: emptyMap()).toMutableMap() + for ((tool, rule) in patch) { + when (rule) { + is PermissionRuleDto.Level -> { + if (rule.value == null) result.remove(tool) else result[tool] = rule + } + is PermissionRuleDto.Patterns -> { + val merged = ((result[tool] as? PermissionRuleDto.Patterns)?.map ?: emptyMap()).toMutableMap() + for ((pattern, level) in rule.map) { + if (level == null) merged.remove(pattern) else merged[pattern] = level + } + if (merged.isEmpty()) result.remove(tool) else result[tool] = PermissionRuleDto.Patterns(merged) + } + } + } + return result.takeIf { it.isNotEmpty() } + } + var fakeProfile: ProfileDto? = null var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") val orgProfiles = mutableMapOf() 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 1ab2f302bd..1324b3073d 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 @@ -70,6 +70,7 @@ data class ConfigDto( val skills: SkillsConfigDto? = null, val mcp: Map = emptyMap(), val agent: Map = emptyMap(), + val permission: PermissionConfigDto? = null, ) @Serializable @@ -129,6 +130,7 @@ data class ConfigPatchDto( val skills: SkillsPatchDto? = null, val mcp: Map? = null, val agents: Map = emptyMap(), + val permission: PermissionConfigDto? = null, ) @Serializable