mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
feat(jetbrains): add auto-approve settings
This commit is contained in:
+4
@@ -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 {
|
||||
|
||||
+32
@@ -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()))
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+18
@@ -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"
|
||||
}
|
||||
}
|
||||
+90
@@ -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) } },
|
||||
)
|
||||
}
|
||||
+187
@@ -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() } }
|
||||
+86
@@ -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)
|
||||
}
|
||||
}
|
||||
+84
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+102
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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>()
|
||||
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+132
@@ -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()
|
||||
}
|
||||
+9
-5
@@ -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.
|
||||
|
||||
+9
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+218
@@ -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()))
|
||||
}
|
||||
}
|
||||
+310
@@ -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")
|
||||
}
|
||||
}
|
||||
+169
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user