feat(jetbrains): add auto-approve settings

This commit is contained in:
kirillk
2026-07-20 15:22:16 -04:00
parent 2d7d5d5637
commit aae91e4c52
24 changed files with 1605 additions and 11 deletions
@@ -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 {
@@ -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<PermissionRuleDto.Level>(bash)
assertEquals("ask", bash.value)
assertIs<PermissionRuleDto.Patterns>(read)
assertEquals(mapOf("*" to "allow", "*.env" to "deny"), read.map)
assertIs<PermissionRuleDto.Level>(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()))
@@ -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"
}
@@ -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
@@ -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<JComponent>() {
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"
}
}
@@ -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) } },
)
}
@@ -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<String, PermissionRuleDto> = 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<Pair<String, String>> {
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>): 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<String>): 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<String>, 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<String>): 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<String, PermissionRuleDto>()
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<String, PermissionRuleDto>): Map<String, PermissionRuleDto> =
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() } }
@@ -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<AutoApproveContent, PermissionDraft, ConfigPatchDto, KiloAppStateDto, Unit>(
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)
}
}
@@ -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<String>) -> 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()
}
}
@@ -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<LevelSelect.Item>(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<Item>()
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
}
}
@@ -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<String>) -> 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<Pair<String, String>>, 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<AnAction> = 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<SettingsListCell> = listOf(
SettingsListCell(id = "level", label = levelLabel(level), alwaysVisible = true),
)
}
}
@@ -36,7 +36,8 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R, W>(
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<Job>()
@@ -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<T : JComponent> : KiloReadyConfigurable() {
abstract class DraftReadyConfigurableBase<T : JComponent> : KiloReadyConfigurableBase() {
private var panel: T? = null
final override fun createReadyComponent(cs: CoroutineScope): JComponent {
@@ -31,3 +32,9 @@ abstract class DraftReadyConfigurable<T : JComponent> : KiloReadyConfigurable()
protected abstract fun create(cs: CoroutineScope): T
}
abstract class DraftReadyConfigurable<T : JComponent> : DraftReadyConfigurableBase<T>(), Configurable.NoScroll
abstract class ScrollableDraftReadyConfigurable<T : JComponent> : DraftReadyConfigurableBase<T>() {
override fun scrollReadyShell(): Boolean = false
}
@@ -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
@@ -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<SettingsListItem>, enabled: Boolean) {
checkEdt()
view.update(items, SettingsListSelection.Preserve)
setEnabled(enabled)
toolbar?.updateActionsImmediately()
}
@RequiresEdt
protected fun selectedKeys(): List<String> {
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<AnAction> = 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()
}
@@ -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) {
@@ -65,11 +65,19 @@
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.context"
groupWeight="0"
groupWeight="-1"
instance="ai.kilocode.client.settings.context.ContextConfigurable"
bundle="messages.KiloBundle"
key="settings.context.displayName"/>
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings"
id="ai.kilocode.jetbrains.settings.autoApprove"
groupWeight="0"
instance="ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable"
bundle="messages.KiloBundle"
key="settings.autoApprove.displayName"/>
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings.agentBehavior"
id="ai.kilocode.jetbrains.settings.agentBehavior.agents"
@@ -338,6 +338,41 @@ settings.context.watcher.remove=Remove selected patterns
settings.context.watcher.empty=No ignore patterns configured.
settings.context.watcher.input.title=Add ignore pattern
settings.context.watcher.input.prompt=Enter a glob pattern to ignore:
settings.autoApprove.displayName=Auto-Approve
settings.autoApprove.title=Auto-Approve
settings.autoApprove.description=Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask.
settings.autoApprove.default=Default ({0})
settings.autoApprove.level.allow=Allow
settings.autoApprove.level.ask=Ask
settings.autoApprove.level.deny=Deny
settings.autoApprove.wildcardLabel.commands=All commands (*)
settings.autoApprove.wildcardLabel.paths=All paths (*)
settings.autoApprove.exceptions=Exceptions
settings.autoApprove.exceptions.count=Exceptions ({0})
settings.autoApprove.exceptions.empty=No exceptions
settings.autoApprove.add=Add
settings.autoApprove.delete=Delete
settings.autoApprove.delete.description=Delete selected exceptions
settings.autoApprove.addCommand=Add command
settings.autoApprove.addPath=Add path
settings.autoApprove.placeholder.command=e.g. git *
settings.autoApprove.placeholder.path=e.g. *.env
settings.autoApprove.tool.external_directory=Access files outside workspace. Triggered when accessing files outside the current project directory.
settings.autoApprove.tool.bash=Run terminal commands. Allows execution of shell commands (e.g., git status).
settings.autoApprove.tool.read=Read files. Allows the agent to read files matching the specified path.
settings.autoApprove.tool.edit=Modify files. Allows the agent to create or edit files, including patches and multi-file updates.
settings.autoApprove.tool.glob=Match files by pattern. Allows file matching using glob patterns (e.g., src/**/*.ts).
settings.autoApprove.tool.grep=Search file contents. Allows regex-based search inside files.
settings.autoApprove.tool.list=List directory contents. Allows viewing files and folders within a directory.
settings.autoApprove.tool.task=Launch sub-agents. Allows starting specialized sub-agents for specific tasks.
settings.autoApprove.tool.skill=Load skills. Allows loading predefined skills by name.
settings.autoApprove.tool.lsp=Query language server. Allows running language server queries for code intelligence.
settings.autoApprove.tool.todoreadwrite=Manage task list. Allows reading and updating the internal task list.
settings.autoApprove.tool.webfetch=Fetch a URL. Allows retrieving content from a specific URL.
settings.autoApprove.tool.websearch=Search the web. Allows performing external web searches.
settings.autoApprove.tool.doom_loop=Prevent repeated identical actions. Triggered when the same tool call repeats with identical input.
settings.autoApprove.save.pending=Saving auto-approve settings…
settings.autoApprove.save.failed=Failed to save auto-approve settings.
settings.providers.displayName=Providers
settings.agentBehavior.displayName=Agent Behavior
settings.agentBehavior.description=Configure agents, MCP servers, rules, workflows, and skills.
@@ -4,6 +4,7 @@ import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.settings.context.ContextConfigurable
import ai.kilocode.client.settings.models.ModelsConfigurable
import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable
import ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable
import ai.kilocode.client.settings.providers.ProvidersConfigurable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.Configurable
@@ -39,6 +40,13 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() {
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior", AgentBehaviorConfigurable.ID)
}
fun `test auto approve uses platform configurable scrollpane`() {
val auto: Configurable = AutoApproveConfigurable()
val context: Configurable = ContextConfigurable()
assertFalse(auto is Configurable.NoScroll)
assertTrue(context is Configurable.NoScroll)
}
fun `test root implements SearchableConfigurable but not Parent`() {
// Root should be SearchableConfigurable so it can be found by ID,
// but NOT SearchableConfigurable.Parent to avoid duplicating XML-registered child configurables.
@@ -98,7 +106,7 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() {
edt {
val panel = cfg.createComponent()
val labels = links(panel as Container).map { it.text }
assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior", "Context"), labels)
assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior", "Auto-Approve", "Context"), labels)
}
}
@@ -0,0 +1,218 @@
package ai.kilocode.client.settings.autoapprove
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.PermissionRuleDto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AutoApproveSettingsStateTest {
@Test
fun `draft reads permission config`() {
val draft = permissionDraft(ConfigDto(permission = mapOf("bash" to PermissionRuleDto.Level("allow"))))
assertEquals("allow", wildcardLevel(draft.rules["bash"]))
}
@Test
fun `default level rules match CLI defaults`() {
assertEquals("ask", defaultLevel("external_directory"))
assertEquals("ask", defaultLevel("bash"))
assertEquals("ask", defaultLevel("doom_loop"))
assertEquals("allow", defaultLevel("read"))
assertEquals("allow", defaultLevel("edit"))
}
@Test
fun `effective level falls back to default when unset`() {
val draft = PermissionDraft()
assertEquals("ask", effectiveLevel(draft, "bash"))
assertEquals("allow", effectiveLevel(draft, "read"))
}
@Test
fun `inherited wildcard is true when tool absent or patterns wildcard missing`() {
assertTrue(inheritedWildcard(null))
assertFalse(inheritedWildcard(PermissionRuleDto.Level("allow")))
assertTrue(inheritedWildcard(PermissionRuleDto.Patterns(mapOf("*.env" to "deny"))))
assertFalse(inheritedWildcard(PermissionRuleDto.Patterns(mapOf("*" to "ask", "*.env" to "deny"))))
}
@Test
fun `mostRestrictive orders allow ask deny`() {
assertEquals("deny", mostRestrictive(listOf("allow", "deny", "ask")))
assertEquals("ask", mostRestrictive(listOf("allow", "ask")))
assertEquals("allow", mostRestrictive(listOf("allow", "allow")))
assertEquals("allow", mostRestrictive(emptyList()))
}
@Test
fun `setWildcard on unset tool emits scalar patch`() {
val from = PermissionDraft()
val to = setWildcard(from, "bash", "deny")
assertEquals(mapOf("bash" to PermissionRuleDto.Level("deny")), permissionPatch(from, to))
}
@Test
fun `setWildcard preserves existing exceptions as patterns`() {
val from = PermissionDraft(rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*.env" to "deny"))))
val to = setWildcard(from, "read", "ask")
assertEquals("ask", wildcardLevel(to.rules["read"]))
assertEquals(listOf("*.env" to "deny"), exceptions(to.rules["read"]))
assertEquals(
mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to "ask", "*.env" to "deny"))),
permissionPatch(from, to),
)
}
@Test
fun `inheritWildcard removes tool entirely when no exceptions`() {
val from = PermissionDraft(rules = mapOf("bash" to PermissionRuleDto.Level("deny")))
val to = inheritWildcard(from, "bash")
assertTrue(to.rules.isEmpty())
assertEquals(mapOf("bash" to PermissionRuleDto.Level(null)), permissionPatch(from, to))
}
@Test
fun `inheritWildcard clears only wildcard when exceptions remain`() {
val from = PermissionDraft(
rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to "ask", "*.env" to "deny"))),
)
val to = inheritWildcard(from, "read")
assertTrue(inheritedWildcard(to.rules["read"]))
assertEquals(listOf("*.env" to "deny"), exceptions(to.rules["read"]))
assertEquals(
mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to null, "*.env" to "deny"))),
permissionPatch(from, to),
)
}
@Test
fun `addException on scalar wildcard preserves the wildcard as star pattern`() {
val from = PermissionDraft(rules = mapOf("bash" to PermissionRuleDto.Level("ask")))
val to = addException(from, "bash", "git *")
assertEquals(listOf("git *" to "allow"), exceptions(to.rules["bash"]))
assertEquals("ask", wildcardLevel(to.rules["bash"]))
assertEquals(
mapOf("bash" to PermissionRuleDto.Patterns(mapOf("*" to "ask", "git *" to "allow"))),
permissionPatch(from, to),
)
}
@Test
fun `setException changes an existing exception level`() {
val from = PermissionDraft(
rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.env" to "deny"))),
)
val to = setException(from, "read", "*.env", "ask")
assertEquals(listOf("*.env" to "ask"), exceptions(to.rules["read"]))
assertEquals(
mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.env" to "ask"))),
permissionPatch(from, to),
)
}
@Test
fun `removeException deletes a single pattern and keeps others`() {
val from = PermissionDraft(
rules = mapOf(
"read" to PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.env" to "deny", "*.key" to "deny")),
),
)
val to = removeException(from, "read", "*.env")
assertEquals(listOf("*.key" to "deny"), exceptions(to.rules["read"]))
assertEquals(
mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.key" to "deny", "*.env" to null))),
permissionPatch(from, to),
)
}
@Test
fun `removeException removing the last exception removes the tool key`() {
val from = PermissionDraft(rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*.env" to "deny"))))
val to = removeException(from, "read", "*.env")
assertTrue(to.rules.isEmpty())
assertEquals(mapOf("read" to PermissionRuleDto.Level(null)), permissionPatch(from, to))
}
@Test
fun `grouped set applies scalar level to both ids`() {
val from = PermissionDraft()
val to = setGrouped(from, listOf("todoread", "todowrite"), "ask")
assertEquals(
mapOf("todoread" to PermissionRuleDto.Level("ask"), "todowrite" to PermissionRuleDto.Level("ask")),
permissionPatch(from, to),
)
}
@Test
fun `grouped inherit deletes both ids`() {
val from = PermissionDraft(
rules = mapOf(
"todoread" to PermissionRuleDto.Level("deny"),
"todowrite" to PermissionRuleDto.Level("deny"),
),
)
val to = inheritGrouped(from, listOf("todoread", "todowrite"))
assertEquals(
mapOf("todoread" to PermissionRuleDto.Level(null), "todowrite" to PermissionRuleDto.Level(null)),
permissionPatch(from, to),
)
}
@Test
fun `scalar to patterns transition emits full desired patterns`() {
val from = PermissionDraft(rules = mapOf("edit" to PermissionRuleDto.Level("ask")))
val to = addException(from, "edit", "*.env")
assertEquals(
mapOf("edit" to PermissionRuleDto.Patterns(mapOf("*" to "ask", "*.env" to "allow"))),
permissionPatch(from, to),
)
}
@Test
fun `no-op diff returns null`() {
val draft = PermissionDraft(
rules = mapOf(
"bash" to PermissionRuleDto.Patterns(mapOf("*" to "ask", "git *" to "allow")),
"read" to PermissionRuleDto.Level("allow"),
),
)
assertNull(permissionPatch(draft, draft))
assertNull(patch(from = draft, to = draft))
}
@Test
fun `patch wraps the permission patch in a ConfigPatchDto`() {
val from = PermissionDraft()
val to = setWildcard(from, "bash", "deny")
assertEquals(mapOf("bash" to PermissionRuleDto.Level("deny")), patch(from, to)?.permission)
}
@Test
fun `savedMatches drops null-valued entries before comparing`() {
val base = PermissionDraft(rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*.env" to "deny"))))
val draftWithNull = PermissionDraft(
rules = mapOf("read" to PermissionRuleDto.Patterns(mapOf("*" to null, "*.env" to "deny"))),
)
assertTrue(savedMatches(base, draftWithNull))
assertFalse(savedMatches(base, PermissionDraft()))
}
}
@@ -0,0 +1,310 @@
package ai.kilocode.client.settings.autoapprove
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.PermissionRuleDto
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import java.awt.Point
import java.awt.event.InputEvent
import java.awt.event.MouseEvent
import javax.swing.AbstractButton
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.text.JTextComponent
// Matches AutoApproveContent's fixed construction order.
private val LEVEL_SELECT_ORDER = listOf(
"external_directory", "bash", "read", "edit",
"glob", "grep", "list", "task", "skill", "lsp",
"todoread+todowrite",
"websearch", "webfetch", "doom_loop",
)
class AutoApproveSettingsUiTest : 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: AutoApproveSettingsUi? = 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())
rpc.state.value = state
app._state.value = state
edt { ui = AutoApproveSettingsUi(uiScope, app, workspaces) }
flushUntil { text(requireUi()).contains("External Directory") }
}
override fun tearDown() {
try {
val panel = ui
if (panel != null) edt { panel.dispose() }
ui = null
uiScope.cancel()
appScope.cancel()
} finally {
super.tearDown()
}
}
fun `test page is not editable before app is ready`() {
rpc.state.value = KiloAppStateDto(KiloAppStatusDto.LOADING)
app._state.value = KiloAppStateDto(KiloAppStatusDto.LOADING)
edt { ui = AutoApproveSettingsUi(uiScope, app, workspaces) }
flushUntil { text(requireUi()).contains("External Directory") }
edt {
assertTrue(levelSelects(requireUi()).all { !it.isEnabled })
}
}
fun `test setting a simple tool level sends the expected patch`() {
val panel = requireUi()
edt {
selectLevel(levelSelectFor(panel, "read"), "deny")
panel.applyDraft()
}
flushUntil { rpc.configPatches.isNotEmpty() }
assertEquals(mapOf("read" to PermissionRuleDto.Level("deny")), rpc.configPatches.single().permission)
}
fun `test choosing Default reverts a tool to inherited`() {
val panel = requireUi()
rpc.state.value = rpc.state.value.copy(config = ConfigDto(permission = mapOf("bash" to PermissionRuleDto.Level("deny"))))
app._state.value = rpc.state.value
flushUntil { !edt { panel.modified() } }
edt {
selectInherit(levelSelectFor(panel, "bash"))
panel.applyDraft()
}
flushUntil { rpc.configPatches.isNotEmpty() }
assertEquals(mapOf("bash" to PermissionRuleDto.Level(null)), rpc.configPatches.single().permission)
}
fun `test adding an exception to a granular tool sends full patterns patch`() {
val panel = requireUi()
edt {
val list = inlineListFor(panel, "bash")
list.input = { "git *" }
click(button(list, 0))
panel.applyDraft()
}
flushUntil { rpc.configPatches.isNotEmpty() }
val rule = rpc.configPatches.single().permission?.get("bash")
assertEquals(PermissionRuleDto.Patterns(mapOf("git *" to "allow")), rule)
}
fun `test removing an exception sends a null delete for that pattern only`() {
val panel = requireUi()
rpc.state.value = rpc.state.value.copy(
config = ConfigDto(permission = mapOf(
"read" to PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.env" to "deny", "*.key" to "deny")),
)),
)
app._state.value = rpc.state.value
flushUntil { !edt { panel.modified() } }
edt {
removeException(panel, "read", "*.env")
panel.applyDraft()
}
flushUntil { rpc.configPatches.isNotEmpty() }
val rule = rpc.configPatches.single().permission?.get("read")
assertEquals(PermissionRuleDto.Patterns(mapOf("*" to "allow", "*.key" to "deny", "*.env" to null)), rule)
}
fun `test grouped todo row uses the most restrictive level and applies to both ids`() {
val panel = requireUi()
edt {
selectLevel(levelSelectFor(panel, "todoread+todowrite"), "deny")
panel.applyDraft()
}
flushUntil { rpc.configPatches.isNotEmpty() }
assertEquals(
mapOf("todoread" to PermissionRuleDto.Level("deny"), "todowrite" to PermissionRuleDto.Level("deny")),
rpc.configPatches.single().permission,
)
}
fun `test isModified reflects unsaved changes and resetDraft reverts them`() {
val panel = requireUi()
edt {
assertFalse(panel.modified())
selectLevel(levelSelectFor(panel, "read"), "deny")
assertTrue(panel.modified())
panel.resetDraft()
assertFalse(panel.modified())
}
}
fun `test reselecting the already explicit level leaves the page unmodified`() {
val panel = requireUi()
rpc.state.value = rpc.state.value.copy(config = ConfigDto(permission = mapOf("read" to PermissionRuleDto.Level("allow"))))
app._state.value = rpc.state.value
flushUntil { !edt { panel.modified() } }
edt {
selectLevel(levelSelectFor(panel, "read"), "allow")
assertFalse(panel.modified())
}
}
private fun requireUi(): AutoApproveSettingsUi = requireNotNull(ui)
private fun levelSelects(panel: AutoApproveSettingsUi): List<LevelSelect> =
components(panel).filterIsInstance<LevelSelect>()
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<SettingsInlineList>()[index]
}
private fun removeException(panel: AutoApproveSettingsUi, tool: String, pattern: String) {
val list = inlineListFor(panel, tool)
val jList = components(list).filterIsInstance<JBList<*>>().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<JComponent>()
.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 <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) }
}
}
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)
}
private companion object {
val GRANULAR_ORDER = listOf("external_directory", "bash", "read", "edit")
}
}
@@ -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<String>()
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<String>()
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<JBList<*>>().single()
private fun search(list: SettingsInlineList): javax.swing.text.JTextComponent =
components(list).filterIsInstance<javax.swing.text.JTextComponent>().first()
private fun layout(root: Container) {
root.setSize(400, root.preferredSize.height.coerceAtLeast(50))
root.doLayout()
root.components.filterIsInstance<Container>().forEach { layout(it) }
UIUtil.dispatchAllInvocationEvents()
}
private fun button(list: SettingsInlineList, index: Int): JComponent = components(list)
.filterIsInstance<JComponent>()
.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<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(item: java.awt.Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
}
@@ -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<String?, ProfileDto?>()
@@ -70,6 +70,7 @@ data class ConfigDto(
val skills: SkillsConfigDto? = null,
val mcp: Map<String, McpConfigDto> = emptyMap(),
val agent: Map<String, AgentConfigDto> = emptyMap(),
val permission: PermissionConfigDto? = null,
)
@Serializable
@@ -129,6 +130,7 @@ data class ConfigPatchDto(
val skills: SkillsPatchDto? = null,
val mcp: Map<String, McpConfigDto?>? = null,
val agents: Map<String, AgentConfigPatchDto> = emptyMap(),
val permission: PermissionConfigDto? = null,
)
@Serializable