fix(jetbrains): align permission rules with vscode

This commit is contained in:
kirillk
2026-07-19 11:59:55 -04:00
parent 6689b2dd7f
commit e9d0af5773
28 changed files with 1036 additions and 160 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Honor saved JetBrains bash permission rules when running with isolated dev storage.
@@ -581,7 +581,7 @@ internal suspend fun awaitReady(
}
}
private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}"""
private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask"}}"""
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
internal fun buildKiloCliEnv(
@@ -1166,7 +1166,7 @@ object KiloCliDataParser {
}?.toMap() ?: emptyMap()
val path = metaObj.path()
val diffs = metaObj.permissionDiffs(path)
val rules = metaObj.rules().ifEmpty { bashHierarchy(permission, patterns, always) }
val rules = metaObj.rules()
return PermissionRequestDto(
id = id,
sessionID = sid,
@@ -1645,24 +1645,6 @@ private fun JsonObject.decision(): String {
}
}
private fun bashHierarchy(permission: String, patterns: List<String>, always: List<String>): List<String> {
if (permission != "bash") return emptyList()
val out = linkedSetOf<String>()
val prefixes = always.mapNotNull { item -> item.removeSuffix(" *").takeIf { it != item && it.isNotBlank() } }
for (pattern in patterns) {
val text = pattern.trim()
if (text.isBlank()) continue
val prefix = prefixes
.sortedByDescending { it.length }
.firstOrNull { text == it || text.startsWith("$it ") }
?: text.substringBefore(' ')
val parts = prefix.split(Regex("\\s+")).filter { it.isNotBlank() }
for (i in 1..parts.size) out.add(parts.take(i).joinToString(" ") + " *")
if (text != prefix) out.add(text)
}
return out.ifEmpty { always }.toList()
}
private fun JsonObject?.permissionDiffs(path: String?): List<PermissionFileDiffDto> {
if (this == null) return emptyList()
val filediff = this["filediff"].obj()
@@ -238,6 +238,13 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
target(globalConfig())
}
override suspend fun refreshConfigFiles(directory: String) {
val files = withContext(Dispatchers.IO) {
listOf(localConfig(directory), globalConfig()).map { it.toFile() }
}
LocalFileSystem.getInstance().refreshIoFiles(files, true, true, null)
}
override suspend fun openLocalConfig(directory: String): Boolean = openConfig(withContext(Dispatchers.IO) {
localConfig(directory)
})
@@ -64,10 +64,10 @@ class KiloBackendCliManagerEnvTest {
}
@Test
fun `isolation disabled - default CLI config asks for edit and bash permissions`() {
fun `isolation disabled - default CLI config asks for edit permissions`() {
val env = manager.buildEnv("pwd123", emptyMap())
assertEquals("""{"permission":{"edit":"ask","bash":"ask"}}""", env["KILO_CONFIG_CONTENT"])
assertEquals("""{"permission":{"edit":"ask"}}""", env["KILO_CONFIG_CONTENT"])
}
@Test
@@ -2366,7 +2366,7 @@ class KiloCliDataParserTest {
}
@Test
fun `parsePermissionRequest - derives bash hierarchy from always prefix`() {
fun `parsePermissionRequest - uses always when metadata rules are absent`() {
val data = globalEvent("""
"type": "permission.asked",
"properties": {
@@ -2382,9 +2382,9 @@ class KiloCliDataParserTest {
val result = KiloCliDataParser.parseChatEvent("permission.asked", data)
assertNotNull(result)
val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked")
assertEquals(listOf("git *", "git add *", "git add ."), asked.request.rules)
assertEquals(listOf("git *", "git add *", "git add ."), asked.request.ruleDecisions.map { it.pattern })
assertEquals(listOf("pending", "pending", "pending"), asked.request.ruleDecisions.map { it.decision })
assertEquals(emptyList(), asked.request.rules)
assertEquals(listOf("git add *"), asked.request.ruleDecisions.map { it.pattern })
assertEquals(listOf("pending"), asked.request.ruleDecisions.map { it.decision })
}
@Test
@@ -83,7 +83,7 @@ class KiloWorkspaceService internal constructor(
LOG.info("Creating workspace for $directory")
val state = stream { state(directory) }
.stateIn(cs, SharingStarted.Eagerly, INIT)
Workspace(directory, state) { reload(directory) }
Workspace(directory, state, { reload(directory) }) { refreshConfigFiles(directory) }
}
// Refresh on every workspace access so config actions reflect file system changes.
refreshLocalConfigTarget(directory)
@@ -216,6 +216,15 @@ class KiloWorkspaceService internal constructor(
}
}
fun refreshConfigFiles(directory: String) {
cs.launch {
call { refreshConfigFiles(directory) }
localConfigTarget(directory)
globalConfigTarget()
ActivityTracker.getInstance().inc()
}
}
fun openLocalConfig(directory: String, done: (Boolean) -> Unit) {
cs.launch {
val ok = try {
@@ -14,4 +14,5 @@ class Workspace(
val directory: String,
val state: StateFlow<KiloWorkspaceStateDto>,
val reload: () -> Unit,
val refreshConfigFiles: () -> Unit = {},
)
@@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent
object KiloPluginSettings {
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
@@ -14,4 +15,14 @@ object KiloPluginSettings {
internal fun unsetAutoApprove() {
PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY)
}
fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false)
fun setPermissionRulesExpanded(value: Boolean) {
PropertiesComponent.getInstance().setValue(PERMISSION_RULES_EXPANDED_KEY, value.toString())
}
internal fun unsetPermissionRulesExpanded() {
PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY)
}
}
@@ -343,7 +343,7 @@ class SessionUi(
focus = focus,
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
reply = { id, dto, rules -> controller.replyPermission(id, dto, rules) },
selection = selection,
focus = focus,
)
@@ -661,6 +661,7 @@ class SessionController(
cs.launch {
try {
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
if (rules != null) workspace.refreshConfigFiles()
sessions.replyPermission(requestId, directory, reply)
capture("Approval Answered", sessionProps() + mapOf(
"requestId" to requestId,
@@ -19,6 +19,10 @@ object SessionViewIcons {
val eye = icon("eye")
val glasses = icon("glasses")
val mcp = icon("mcp")
val ruleApprove = icon("check-small")
val ruleApproveActive = icon("check-small-active")
val ruleDeny = icon("close-small")
val ruleDenyActive = icon("close-small-active")
val search = icon("magnifying-glass-menu")
val task = icon("task")
val warning = icon("warning")
@@ -1,39 +1,66 @@
package ai.kilocode.client.session.views.permission
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionRuleCandidate
import ai.kilocode.client.session.model.PermissionRuleDecision
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockFactory
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
import ai.kilocode.client.ui.md.hybrid.MdShellHighlight
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.Disposer
import com.intellij.ui.ColorUtil
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBHtmlPaneConfiguration
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
import com.intellij.ui.JBColor
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.Container
import java.awt.Cursor
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Rectangle
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.text.html.StyleSheet
import javax.swing.ScrollPaneConstants
/**
* Transcript-style permission view rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
@@ -43,10 +70,10 @@ import javax.swing.text.html.StyleSheet
* Shows a compact row with action label and target as an inline code fragment, plus diff badges.
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
private val reply: (String, PermissionReplyDto, PermissionAlwaysRulesDto?) -> Unit,
private val selection: SessionSelection? = null,
focus: (() -> Unit)? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView, Disposable {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
@@ -54,11 +81,17 @@ class PermissionView(
private val card = BaseQuestionView(selection, focus)
private val body = Stack.vertical()
private val body = Stack.vertical(gap = UiStyle.Gap.sm())
private val desc = makeDescription()
private val codeSlot = BorderLayoutPanel().apply { isVisible = false }
private val diffRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isVisible = false }
private val rules = PermissionRulesView(selection) { syncPrimaryText() }.apply { isVisible = false }
private val state = JBLabel().apply {
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
isVisible = false
}
// Track target panes for style updates
private val panes = mutableListOf<JBHtmlPane>()
private val regs = mutableListOf<Disposable>()
private var md: MdView? = null
private val diffViews = mutableListOf<PermissionDiffView>()
private val ID_DENY = "deny"
@@ -68,125 +101,91 @@ class PermissionView(
isOpaque = false
isVisible = false
card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title"))
card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
card.setContent(body)
card.setActions(listOf(
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") },
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") },
))
body.next(desc).next(codeSlot).next(diffRow).next(rules).next(state)
card.setActions(
listOf(
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.reject"), primary = false) { reject() },
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.allow.once"), primary = true) { allow() },
),
)
addToCenter(card)
}
/** Populate the view for [permission] and make it visible. */
@RequiresEdt
fun show(permission: Permission) {
val prev = requestId
requestId = permission.id
card.setHeader(KiloBundle.message("session.permission.title"))
body.removeAll()
disposeRegs()
panes.clear()
diffViews.clear()
syncDescription(description(permission))
val tool = permission.name
val cmd = permission.meta.command
val action = toolLabel(tool)
val target = cmd ?: resolveTarget(permission)
addDetailRow(action, target, permission.meta.fileDiffs)
addStateMessage(permission)
val target = if (tool == "bash") permission.meta.command else resolveTarget(permission)
syncCode(tool, target)
syncDiffs(permission.meta.fileDiffs)
rules.update(permission.meta.ruleDecisions, reset = prev != permission.id)
syncState(permission)
syncPrimaryText()
val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
card.setActionEnabled(ID_RUN, !responding)
card.setActionEnabled(ID_DENY, !responding)
rules.setControlsEnabled(!responding)
isVisible = true
refresh()
}
/** Hide this view and clear the active request id. */
@RequiresEdt
fun hideView() {
requestId = null
body.removeAll()
disposeRegs()
panes.clear()
disposeMd()
diffViews.clear()
diffRow.removeAll()
diffRow.isVisible = false
rules.update(emptyList(), reset = true)
state.isVisible = false
isVisible = false
refresh()
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
card.applyStyle(style)
for (pane in panes) {
applyTargetPane(pane)
}
desc.font = style.hintFont
desc.foreground = UiStyle.Colors.weak()
rules.applyStyle(style)
md?.let { applyCodeStyle(it) }
for (dv in diffViews) {
dv.applyStyle(style)
}
}
/** Adds a three-column permission detail row: tool, target, and changes. */
private fun addDetailRow(action: String, target: String?, diffs: List<PermissionFileDiff>) {
val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply {
isOpaque = false
}
val actionLbl = JBLabel(action).apply {
font = UiStyle.Fonts.bold()
}
row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST)
if (!target.isNullOrBlank()) {
val pane = targetPane(target)
panes.add(pane)
row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
}
@RequiresEdt
private fun syncDescription(text: String) {
if (desc.text != text) desc.text = text
desc.isVisible = text.isNotBlank()
}
@RequiresEdt
private fun syncDiffs(diffs: List<PermissionFileDiff>) {
diffRow.removeAll()
diffViews.clear()
diffRow.isVisible = diffs.isNotEmpty()
if (diffs.isNotEmpty()) {
val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
}
for (diff in diffs) {
val dv = PermissionDiffView(diff)
diffViews.add(dv)
changes.add(dv)
diffRow.add(dv)
}
row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
}
body.add(row)
}
private fun targetPane(text: String) = JBHtmlPane(
JBHtmlPaneStyleConfiguration {},
JBHtmlPaneConfiguration {
customStyleSheetProvider { targetSheet() }
},
).apply {
isEditable = false
isOpaque = true
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
applyTargetPane(this)
selection?.register(this)?.let(regs::add)
}
private fun applyTargetPane(pane: JBHtmlPane) {
pane.font = style.transcriptFont
pane.foreground = style.editorForeground
pane.background = SessionUiStyle.View.Surface.headerHoverBgColor()
pane.reloadCssStylesheets()
}
private fun targetSheet(): StyleSheet {
val sheet = StyleSheet()
val font = style.transcriptFont
val fg = ColorUtil.toHtmlColor(style.editorForeground)
val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor())
val family = font.name.replace("\\", "\\\\").replace("'", "\\'")
sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }")
sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }")
return sheet
diffRow.revalidate()
diffRow.repaint()
}
private fun resolveTarget(permission: Permission): String? {
@@ -201,19 +200,127 @@ class PermissionView(
}
}
private fun addStateMessage(permission: Permission) {
@RequiresEdt
private fun syncState(permission: Permission) {
val msg = when (permission.state) {
PermissionRequestState.ERROR ->
permission.message ?: KiloBundle.message("session.permission.error")
PermissionRequestState.RESPONDING ->
KiloBundle.message("session.permission.responding")
else -> null
} ?: return
val label = JBLabel(msg).apply {
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
}
body.add(label)
state.text = msg.orEmpty()
state.isVisible = msg != null
}
@RequiresEdt
private fun syncCode(tool: String, target: String?) {
if (target.isNullOrBlank()) {
codeSlot.isVisible = false
md?.clear()
return
}
val view = ensureMd()
val lang = if (tool == "bash") "shell-command" else ""
val text = fenced(target, lang)
if (view.markdown() != text) view.set(text)
applyCodeStyle(view)
codeSlot.isVisible = true
}
@RequiresEdt
private fun ensureMd(): MdView {
md?.let { return it }
val view = MdViewFactory.create(
style,
selection,
MdCodeBlockFactory.default(
MdCodeBlockOptions(
border = MdCodeBlockBorder.None,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
),
),
)
md = view
applyCodeStyle(view)
codeSlot.add(view.component, BorderLayout.CENTER)
return view
}
@RequiresEdt
private fun applyCodeStyle(view: MdView) {
view.applyStyle(style)
view.font = style.transcriptFont
view.foreground = style.editorForeground
view.background = style.editorBackground
view.preBg = style.editorBackground
view.codeFont = style.editorFamily
view.component.border = JBUI.Borders.empty()
}
private fun description(permission: Permission): String = if (permission.name == "bash") {
permission.meta.raw["description"] ?: toolLabel(permission.name)
} else {
toolLabel(permission.name)
}
private fun makeDescription(): JBTextArea {
val area = object : JBTextArea() {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
override fun scrollRectToVisible(aRect: Rectangle) {}
private fun withWidth(fallback: Int): Dimension {
val w = availableWidth()
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
val old = size
setSize(w, Int.MAX_VALUE)
val ps = super.getPreferredSize()
setSize(old)
return Dimension(w, ps.height)
}
private fun availableWidth(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
}.apply {
isEditable = false
isOpaque = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = UiStyle.Colors.weak()
font = style.hintFont
border = JBUI.Borders.empty()
isVisible = false
}
selection?.register(area)
return area
}
private fun fenced(text: String, lang: String): String = buildString {
val fence = fence(text)
append(fence).append(lang).append('\n')
append(text)
if (!text.endsWith('\n')) append('\n')
append(fence)
}
private fun toolLabel(tool: String): String = when (tool) {
@@ -238,11 +345,36 @@ class PermissionView(
else -> tool
}
private fun decide(value: String) {
@RequiresEdt
private fun allow() {
val id = requestId ?: return
card.setActionEnabled(ID_RUN, false)
card.setActionEnabled(ID_DENY, false)
reply(id, PermissionReplyDto(reply = value))
rules.setControlsEnabled(false)
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
}
@RequiresEdt
private fun reject() {
val id = requestId ?: return
card.setActionEnabled(ID_RUN, false)
card.setActionEnabled(ID_DENY, false)
rules.setControlsEnabled(false)
reply(id, PermissionReplyDto(reply = "reject"), rulePayload())
}
@RequiresEdt
private fun rulePayload(): PermissionAlwaysRulesDto? {
if (!rules.anyDecided()) return null
return PermissionAlwaysRulesDto(approvedAlways = rules.approved(), deniedAlways = rules.denied())
}
@RequiresEdt
private fun syncPrimaryText() {
card.setActionText(
ID_RUN,
KiloBundle.message(if (rules.anyDecided()) "session.permission.apply.allow" else "session.permission.allow.once"),
)
}
private fun refresh() {
@@ -252,17 +384,36 @@ class PermissionView(
parent?.repaint()
}
private fun disposeRegs() {
regs.forEach(Disposer::dispose)
regs.clear()
@RequiresEdt
private fun disposeMd() {
val view = md ?: return
md = null
codeSlot.remove(view.component)
codeSlot.isVisible = false
Disposer.dispose(view)
}
override fun dispose() {
disposeMd()
Disposer.dispose(rules)
}
private fun codeEditors(): List<EditorTextField> = mdScrolls().mapNotNull { it.viewport.view as? EditorTextField }
private fun mdScrolls(): List<JBScrollPane> = (md?.component as? JPanel)?.components?.filterIsInstance<JBScrollPane>() ?: emptyList()
private fun fence(text: String): String {
val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0
return "`".repeat(maxOf(3, size + 1))
}
// Test helpers
internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.run") }
internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.deny") }
internal fun codeLabelsForTest() = panes.toList()
internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.allow.once") || it.text == KiloBundle.message("session.permission.apply.allow") }
internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.reject") }
internal fun codeLabelsForTest() = codeEditors()
internal fun diffViewsForTest() = diffViews.toList()
internal fun headerFontForTest() = textAreas(card).first { it.font.isBold }.font
internal fun rulesForTest() = rules
private fun buttons(root: Container): List<JButton> {
val result = mutableListOf<JButton>()
@@ -282,3 +433,380 @@ class PermissionView(
return result
}
}
internal class PermissionRulesView(
private val selection: SessionSelection?,
private val changed: () -> Unit,
) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Disposable {
private val title = JBLabel(KiloBundle.message("session.permission.rules.title"))
private val arrow = JBLabel(SessionViewIcons.chevronCollapsed)
private val header = Stack.horizontal(gap = UiStyle.Gap.xs())
private var box: Stack? = null
private val rows = mutableListOf<RuleRow>()
private var style = SessionEditorStyle.current()
init {
header.next(arrow.align(HAlign.LEFT, VAlign.CENTER)).next(title.align(HAlign.LEFT, VAlign.CENTER)).fill(0)
header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
header.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
toggle()
}
})
next(header)
syncArrow()
}
private var candidates = emptyList<PermissionRuleCandidate>()
private var baseline = emptyMap<String, PermissionRuleDecision>()
private var decisions = emptyMap<String, PermissionRuleDecision>()
@RequiresEdt
fun update(candidates: List<PermissionRuleCandidate>, reset: Boolean = false) {
isVisible = candidates.isNotEmpty()
val old = if (reset) emptyMap() else decisions + rows.associate { it.pattern to it.decision }
val patterns = candidates.map { it.pattern }
val stale = this.candidates.map { it.pattern } != patterns
this.candidates = candidates
if (reset || stale) baseline = candidates.associate { it.pattern to it.decision }
decisions = candidates.associate { it.pattern to (old[it.pattern] ?: it.decision) }
if (candidates.isEmpty()) {
box?.let {
if (it.parent === this) remove(it)
}
box = null
disposeRows()
syncArrow()
changed()
return
}
if (stale && box != null) syncBody(rebuild = true) else syncRows()
syncExpanded()
syncArrow()
changed()
}
@RequiresEdt
private fun body(): Stack {
val current = box
if (current != null) return current
val root = Stack.vertical(gap = UiStyle.Gap.xs())
box = root
syncBody(rebuild = true)
return root
}
@RequiresEdt
private fun syncBody(rebuild: Boolean) {
val root = box ?: return
if (rebuild) {
root.removeAll()
disposeRows()
for (candidate in candidates) {
val row = RuleRow(candidate.pattern, style, selection) { pattern, decision ->
decisions = decisions + (pattern to decision)
syncRows()
changed()
}
rows.add(row)
root.next(row)
}
}
syncRows()
root.revalidate()
root.repaint()
}
@RequiresEdt
private fun syncRows() {
for (row in rows) row.update(decisions[row.pattern] ?: PermissionRuleDecision.PENDING)
}
@RequiresEdt
private fun syncExpanded() {
if (box?.parent === this) return
if (!KiloPluginSettings.getPermissionRulesExpanded()) return
add(body())
}
@RequiresEdt
fun toggle() {
if (candidates.isEmpty()) return
val root = body()
if (isExpanded()) remove(root) else add(root)
KiloPluginSettings.setPermissionRulesExpanded(isExpanded())
syncArrow()
revalidate()
repaint()
}
@RequiresEdt
fun isExpanded(): Boolean = box?.parent === this
@RequiresEdt
fun approved(): List<String> = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.APPROVED }
@RequiresEdt
fun denied(): List<String> = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.DENIED }
@RequiresEdt
fun anyDecided(): Boolean = decisions.any { baseline[it.key] != it.value }
@RequiresEdt
fun setControlsEnabled(enabled: Boolean) {
for (row in rows) row.setControlsEnabled(enabled)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (row in rows) row.applyStyle(style)
}
@RequiresEdt
fun approveButtonsForTest(): List<JButton> = rows.map { it.approveButtonForTest() }
@RequiresEdt
fun denyButtonsForTest(): List<JButton> = rows.map { it.denyButtonForTest() }
@RequiresEdt
fun commandFieldsForTest(): List<EditorTextField> = rows.map { it.commandFieldForTest() }
@RequiresEdt
fun decisionForTest(pattern: String): PermissionRuleDecision = decisions[pattern] ?: PermissionRuleDecision.PENDING
@RequiresEdt
fun bodyCreatedForTest(): Boolean = box != null
@RequiresEdt
private fun syncArrow() {
arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed
}
@RequiresEdt
private fun disposeRows() {
for (row in rows) Disposer.dispose(row)
rows.clear()
}
override fun dispose() {
disposeRows()
}
private class RuleRow(
val pattern: String,
style: SessionEditorStyle,
selection: SessionSelection?,
private val changed: (String, PermissionRuleDecision) -> Unit,
) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.xs()), Disposable {
var decision = PermissionRuleDecision.PENDING
private set
private val approve = RuleToggleButton(true) {
changed(pattern, if (decision == PermissionRuleDecision.APPROVED) PermissionRuleDecision.PENDING else PermissionRuleDecision.APPROVED)
}
private val deny = RuleToggleButton(false) {
changed(pattern, if (decision == PermissionRuleDecision.DENIED) PermissionRuleDecision.PENDING else PermissionRuleDecision.DENIED)
}
private val field = RuleCommandField(pattern, style, selection)
init {
next(approve.align(HAlign.LEFT, VAlign.CENTER))
next(deny.align(HAlign.LEFT, VAlign.CENTER))
gap(UiStyle.Gap.lg())
next(field.align(HAlign.LEFT, VAlign.CENTER))
fill(0)
update(PermissionRuleDecision.PENDING)
}
@RequiresEdt
fun update(value: PermissionRuleDecision) {
decision = value
approve.update(value == PermissionRuleDecision.APPROVED)
deny.update(value == PermissionRuleDecision.DENIED)
}
@RequiresEdt
fun setControlsEnabled(enabled: Boolean) {
approve.isEnabled = enabled
deny.isEnabled = enabled
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
field.applyStyle(style)
}
fun approveButtonForTest(): JButton = approve
fun denyButtonForTest(): JButton = deny
fun commandFieldForTest(): EditorTextField = field
override fun dispose() {
field.dispose()
}
}
private class RuleCommandField(
value: String,
private var style: SessionEditorStyle,
private val selection: SessionSelection?,
) : EditorTextField(
EditorFactory.getInstance().createDocument(value.trimEnd('\n')),
ProjectManager.getInstance().defaultProject,
PlainTextFileType.INSTANCE,
true,
false,
) {
private var reg: Disposable? = null
init {
setFontInheritedFromLAF(false)
font = style.editorFont
addSettingsProvider(::install)
reg = selection?.register(this)
}
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
this.style = style
font = style.editorFont
getEditor(false)?.let(::apply)
}
@RequiresEdt
fun dispose() {
reg?.let(Disposer::dispose)
reg = null
getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor)
}
private fun install(ed: com.intellij.openapi.editor.Editor) {
(ed as? EditorEx)?.let(::apply)
}
private fun apply(ed: EditorEx) {
style.applyToEditor(ed)
ed.setBorder(JBUI.Borders.empty())
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
ed.backgroundColor = style.editorBackground
ed.scrollPane.background = style.editorBackground
ed.scrollPane.isOpaque = true
ed.scrollPane.viewport.isOpaque = true
ed.scrollPane.viewport.background = style.editorBackground
ed.settings.isUseSoftWraps = false
ed.settings.isAdditionalPageAtBottom = false
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
syncHighlight(ed)
}
private fun syncHighlight(ed: EditorEx) {
ed.markupModel.removeAllHighlighters()
val size = ed.document.textLength
for (range in MdShellHighlight.command(text).ranges) {
val start = range.start.coerceAtMost(size)
val end = range.end.coerceAtMost(size)
if (start >= end) continue
ed.markupModel.addRangeHighlighter(
range.key,
start,
end,
HighlighterLayer.SYNTAX + 1,
HighlighterTargetArea.EXACT_RANGE,
)
}
}
}
private class RuleToggleButton(
private val approve: Boolean,
private val changed: () -> Unit,
) : JButton() {
private var active = false
private var over = false
init {
iconButton(this)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addActionListener { changed() }
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) = syncOver(true)
override fun mouseExited(e: MouseEvent) = syncOver(false)
})
update(false)
}
override fun getPreferredSize(): Dimension = JBUI.size(24, 24)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun paintComponent(g: Graphics) {
if (isEnabled && (active || over)) paintFill(g)
super.paintComponent(g)
}
@RequiresEdt
fun update(value: Boolean) {
active = value
icon = when {
approve && value -> SessionViewIcons.ruleApproveActive
approve -> SessionViewIcons.ruleApprove
value -> SessionViewIcons.ruleDenyActive
else -> SessionViewIcons.ruleDeny
}
val key = when {
approve && value -> "session.permission.rule.approve.remove"
approve -> "session.permission.rule.approve.add"
value -> "session.permission.rule.deny.remove"
else -> "session.permission.rule.deny.add"
}
val text = KiloBundle.message(key)
toolTipText = text
getAccessibleContext().accessibleName = text
repaint()
}
private fun paintFill(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val base = UiStyle.Colors.bg()
g2.color = when {
active -> UiStyle.Colors.blend(base, if (approve) approvedColor() else deniedColor(), 0.15f)
else -> UiStyle.Colors.actionHoverBackground()
}
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private fun syncOver(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
}
private fun approvedColor(): Color = JBColor.namedColor(
"Kilo.PermissionRule.approvedForeground",
JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)),
)
private fun deniedColor(): Color = JBColor.namedColor(
"Kilo.PermissionRule.deniedForeground",
JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)),
)
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#1F9D66" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#35D49A" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L14 14M14 6L6 14" stroke="#DB5866" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L14 14M14 6L6 14" stroke="#FF6B7A" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L14 14M14 6L6 14" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L14 14M14 6L6 14" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

@@ -52,6 +52,14 @@ session.permission.meta=Tool: {0} • Patterns: {1}
session.permission.run=Run
session.permission.allow=Allow
session.permission.deny=Deny
session.permission.allow.once=Allow once
session.permission.apply.allow=Allow
session.permission.reject=Reject
session.permission.rules.title=Auto-approve Rules
session.permission.rule.approve.add=Add to allowed
session.permission.rule.approve.remove=Remove from allowed
session.permission.rule.deny.add=Add to denied
session.permission.rule.deny.remove=Remove from denied
session.permission.command=Command
session.permission.patterns={0}:
session.permission.diff=Changes
@@ -360,6 +360,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() {
dir,
MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)),
reload = {},
refreshConfigFiles = {},
)
}
}
@@ -337,6 +337,7 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertEquals(1, rpc.permissionRulesSaved.size)
assertEquals("perm1", rpc.permissionRulesSaved[0].first)
assertEquals(1, rpc.permissionReplies.size)
assertEquals(listOf("/test"), projectRpc.refreshedConfigs.toList())
}
fun `test permission request maps rule decisions into model`() {
@@ -1037,7 +1037,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
reject = { _ -> },
)
val p = PermissionView(
reply = { _, _ -> },
reply = { _, _, _ -> },
)
val l = LoginRequiredView(openProfile = {}, dismiss = {})
return SessionMessageListPanel(model, parent, q, p, l, openFile)
@@ -1,34 +1,49 @@
package ai.kilocode.client.session.views.permission
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.model.PermissionRuleCandidate
import ai.kilocode.client.session.model.PermissionRuleDecision
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.editor.EditorFactory
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
private val replies = mutableListOf<Triple<String, PermissionReplyDto, PermissionAlwaysRulesDto?>>()
private lateinit var view: PermissionView
override fun setUp() {
super.setUp()
KiloPluginSettings.unsetPermissionRulesExpanded()
view = PermissionView(
reply = { id, dto -> replies.add(id to dto) },
reply = { id, dto, rules -> replies.add(Triple(id, dto, rules)) },
)
}
override fun tearDown() {
try {
view.dispose()
KiloPluginSettings.unsetPermissionRulesExpanded()
} finally {
super.tearDown()
}
}
fun `test run button replies once`() {
view.show(permission())
@@ -37,6 +52,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("once", replies.single().second.reply)
assertNull(replies.single().third)
assertFalse(view.runButtonForTest().isEnabled)
assertFalse(view.denyButtonForTest().isEnabled)
}
@@ -49,6 +65,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("reject", replies.single().second.reply)
assertNull(replies.single().third)
}
fun `test view is visible after show`() {
@@ -99,7 +116,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty())
}
fun `test bash permission shows action and command on same row`() {
fun `test bash permission shows action and command editor`() {
view.show(
Permission(
id = "perm4",
@@ -113,10 +130,9 @@ class PermissionViewTest : BasePlatformTestCase() {
val text = allText(view)
assertTrue("Expected Shell action label in text, got: $text", text.contains("Shell"))
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
val labels = view.codeLabelsForTest()
assertEquals("Expected exactly one target pane for command", 1, labels.size)
assertTrue("Expected command in target pane, got: ${labels[0].text}", labels[0].text.contains("git status --short"))
assertEquals("Expected exactly one command editor", 1, labels.size)
assertTrue("Expected command in editor, got: ${labels[0].text}", labels[0].text.contains("git status --short"))
}
fun `test bash permission shows only header and compact detail`() {
@@ -134,12 +150,12 @@ class PermissionViewTest : BasePlatformTestCase() {
val text = allText(view)
assertTrue("Expected permission header, got: $text", text.contains("Permission required"))
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
assertTrue("Expected command editor text", view.codeLabelsForTest().single().text.contains("git status --short"))
// State message should not appear for PENDING state
assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?"))
}
fun `test non-bash patterns show action and path as separate labels`() {
fun `test non-bash patterns show action and path in editor`() {
view.show(
Permission(
id = "perm5",
@@ -153,11 +169,10 @@ class PermissionViewTest : BasePlatformTestCase() {
val text = allText(view)
assertTrue("Expected 'Read' in text, got: $text", text.contains("Read"))
assertTrue("Expected path in text, got: $text", text.containsPath("src/App.kt"))
val labels = view.codeLabelsForTest()
assertEquals("Expected exactly one target pane for the pattern", 1, labels.size)
assertTrue("Expected path in target pane, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt"))
assertEquals("Expected exactly one target editor for the pattern", 1, labels.size)
assertTrue("Expected path in editor, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt"))
}
fun `test multiple patterns joined in code label`() {
@@ -200,8 +215,8 @@ class PermissionViewTest : BasePlatformTestCase() {
)
val text = allText(view)
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
assertTrue("Should render target file in editor", view.codeLabelsForTest().single().text.containsPath("src/A.kt"))
assertEquals("Should render target file once in labels, got: $text", 1, pathOccurrences(text, "src/A.kt"))
// Patch markers should NOT appear — no diff content is shown
assertFalse("Should not render patch content, got: $text", text.contains("@@"))
assertFalse("Should not render old line, got: $text", text.contains("-old"))
@@ -237,8 +252,8 @@ class PermissionViewTest : BasePlatformTestCase() {
)
val text = allText(view)
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
assertTrue("Should render target file in editor", view.codeLabelsForTest().single().text.containsPath("src/A.kt"))
assertEquals("Should render target file once in labels, got: $text", 1, pathOccurrences(text, "src/A.kt"))
// No "unavailable" fallback text expected in new design
assertFalse("Should not render unavailable fallback, got: $text", text.contains("unavailable"))
val badge = view.diffViewsForTest().single().badgeForTest()
@@ -284,7 +299,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertFalse("Should not render patch markers, got: $text", text.contains("@@"))
}
fun `test no rule controls rendered`() {
fun `test rule controls render collapsed when candidates exist`() {
view.show(
Permission(
id = "perm7",
@@ -292,15 +307,49 @@ class PermissionViewTest : BasePlatformTestCase() {
name = "edit",
patterns = listOf("*.kt"),
always = listOf("src/**"),
meta = PermissionMeta(rules = listOf("rule1")),
meta = PermissionMeta(
ruleDecisions = listOf(
PermissionRuleCandidate("*.kt"),
PermissionRuleCandidate("src/**", PermissionRuleDecision.APPROVED),
),
),
)
)
val text = allText(view)
assertFalse("Should not contain 'Manage Auto-Approve Rules'", text.contains("Manage Auto-Approve Rules"))
// Only Run and Deny buttons — not extra rule toggle buttons
val btns = buttons(view)
assertEquals("Expected exactly 2 buttons (Run and Deny)", 2, btns.size)
assertTrue("Should contain rules title, got: $text", text.contains("Auto-approve Rules"))
assertFalse("Rules should be collapsed by default", view.rulesForTest().isExpanded())
assertTrue("Rules body should be lazy", !view.rulesForTest().bodyCreatedForTest())
view.rulesForTest().toggle()
val approve = view.rulesForTest().approveButtonsForTest()
val deny = view.rulesForTest().denyButtonsForTest()
assertEquals(2, approve.size)
assertEquals(2, deny.size)
assertEquals(PermissionRuleDecision.PENDING, view.rulesForTest().decisionForTest("*.kt"))
assertEquals(PermissionRuleDecision.APPROVED, view.rulesForTest().decisionForTest("src/**"))
assertEquals("Add to allowed", approve[0].toolTipText)
assertEquals("Remove from allowed", approve[1].toolTipText)
assertEquals("Add to denied", deny[1].toolTipText)
val commands = view.rulesForTest().commandFieldsForTest()
assertEquals(2, commands.size)
assertEquals("*.kt", commands[0].text)
assertEquals("src/**", commands[1].text)
assertEquals("Allow once", view.runButtonForTest().text)
assertEquals("Reject", view.denyButtonForTest().text)
assertEquals("Expected exactly 6 buttons including rule toggles", 6, buttons(view).size)
view.runButtonForTest().doClick()
assertNull(replies.single().third)
}
fun `test no rule controls render when no candidates`() {
view.show(permission())
val text = allText(view)
assertFalse("Should not contain rules title, got: $text", text.contains("Auto-approve Rules"))
assertFalse(view.rulesForTest().isVisible)
assertEquals("Allow once", view.runButtonForTest().text)
}
fun `test responding state disables buttons`() {
@@ -384,6 +433,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("once", replies.single().second.reply)
assertNull(replies.single().third)
}
fun `test deny button uses bundle text and rejects`() {
@@ -393,6 +443,156 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("reject", replies.single().second.reply)
assertNull(replies.single().third)
}
fun `test approved rule changes label and replies with rules`() {
view.show(
Permission(
id = "perm_rules",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "git add .",
ruleDecisions = listOf(
PermissionRuleCandidate("git *"),
PermissionRuleCandidate("git add *"),
),
),
)
)
assertEquals("Allow once", view.runButtonForTest().text)
view.rulesForTest().toggle()
view.rulesForTest().approveButtonsForTest()[1].doClick()
assertEquals("Allow", view.runButtonForTest().text)
view.runButtonForTest().doClick()
assertEquals(1, replies.size)
assertEquals("perm_rules", replies.single().first)
assertEquals("once", replies.single().second.reply)
assertEquals(listOf("git add *"), replies.single().third?.approvedAlways)
assertEquals(emptyList<String>(), replies.single().third?.deniedAlways)
}
fun `test denied rule changes label and replies with denied rules`() {
view.show(
Permission(
id = "perm_deny_rules",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "git clean -fd",
ruleDecisions = listOf(PermissionRuleCandidate("git clean *")),
),
)
)
view.rulesForTest().toggle()
view.rulesForTest().approveButtonsForTest()[0].doClick()
assertEquals(PermissionRuleDecision.APPROVED, view.rulesForTest().decisionForTest("git clean *"))
view.rulesForTest().denyButtonsForTest()[0].doClick()
assertEquals(PermissionRuleDecision.DENIED, view.rulesForTest().decisionForTest("git clean *"))
assertEquals("Allow", view.runButtonForTest().text)
view.runButtonForTest().doClick()
assertEquals(emptyList<String>(), replies.single().third?.approvedAlways)
assertEquals(listOf("git clean *"), replies.single().third?.deniedAlways)
}
fun `test reject with changed rules replies with rules`() {
view.show(
Permission(
id = "perm_reject_rules",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "git push",
ruleDecisions = listOf(PermissionRuleCandidate("git push *")),
),
)
)
view.rulesForTest().toggle()
view.rulesForTest().denyButtonsForTest()[0].doClick()
view.denyButtonForTest().doClick()
assertEquals("reject", replies.single().second.reply)
assertEquals(emptyList<String>(), replies.single().third?.approvedAlways)
assertEquals(listOf("git push *"), replies.single().third?.deniedAlways)
}
fun `test active rule toggle clears back to allow once`() {
view.show(
Permission(
id = "perm_clear_rules",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "git status",
ruleDecisions = listOf(PermissionRuleCandidate("git status")),
),
)
)
view.rulesForTest().toggle()
view.rulesForTest().approveButtonsForTest()[0].doClick()
view.rulesForTest().approveButtonsForTest()[0].doClick()
assertEquals(PermissionRuleDecision.PENDING, view.rulesForTest().decisionForTest("git status"))
assertEquals("Allow once", view.runButtonForTest().text)
view.runButtonForTest().doClick()
assertNull(replies.single().third)
}
fun `test rules expansion persists for new view`() {
view.show(
Permission(
id = "perm_persist_rules",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "pwd",
ruleDecisions = listOf(PermissionRuleCandidate("pwd")),
),
)
)
view.rulesForTest().toggle()
assertTrue(KiloPluginSettings.getPermissionRulesExpanded())
val next = PermissionView(reply = { id, dto, rules -> replies.add(Triple(id, dto, rules)) })
try {
next.show(
Permission(
id = "perm_persist_rules_next",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "pwd",
ruleDecisions = listOf(PermissionRuleCandidate("pwd")),
),
)
)
assertTrue(next.rulesForTest().isExpanded())
assertTrue(next.rulesForTest().bodyCreatedForTest())
} finally {
next.dispose()
}
}
// ------ shared card shell ------
@@ -410,10 +610,30 @@ class PermissionViewTest : BasePlatformTestCase() {
val labels = findAll<JBLabel>(view)
assertTrue(
"Expected permission warning icon in header",
labels.any { it.icon == SessionViewIcons.warning },
labels.any { it.icon == AllIcons.General.Warning },
)
}
fun `test permission description renders as content row`() {
view.show(
Permission(
id = "perm_desc",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "bun test",
raw = mapOf("description" to "Run the targeted tests"),
),
)
)
val areas = findAll<javax.swing.text.JTextComponent>(view).filter { it.isVisible }
assertTrue("Expected description content row", areas.any { it.text == "Run the targeted tests" && !it.font.isBold })
assertTrue(areas.any { it.text == "Permission required" && it.font.isBold })
}
// ------ button types ------
fun `test run button uses default style key`() {
@@ -456,8 +676,8 @@ class PermissionViewTest : BasePlatformTestCase() {
val labels = view.codeLabelsForTest()
assertNotNull("Should have at least one code label for command", labels.firstOrNull())
assertEquals("Code label font family should use transcript family", style.transcriptFont.name, labels[0].font.name)
assertEquals(style.transcriptFont.size, labels[0].font.size)
assertEquals("Code label font family should use editor family", style.editorFont.name, labels[0].font.name)
assertEquals(style.editorFont.size, labels[0].font.size)
}
fun `test permission header uses headerFont not editor font family`() {
@@ -480,7 +700,7 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals("Permission header should equal headerFont", style.headerFont, header)
}
fun `test code label uses code background`() {
fun `test command editor uses editor background`() {
view.show(
Permission(
id = "perm_bg",
@@ -494,7 +714,71 @@ class PermissionViewTest : BasePlatformTestCase() {
val labels = view.codeLabelsForTest()
assertFalse("Expected code labels", labels.isEmpty())
assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background)
assertEquals(SessionEditorStyle.current().editorBackground, labels[0].background)
}
fun `test command editor is retained and disposed`() {
val base = EditorFactory.getInstance().allEditors.size
view.show(
Permission(
id = "perm_retain",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(command = "git status"),
)
)
val editor = view.codeLabelsForTest().single()
editor.getEditor(true)
val count = EditorFactory.getInstance().allEditors.size
repeat(40) { i ->
view.show(
Permission(
id = "perm_retain",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(command = "echo $i"),
state = if (i % 2 == 0) PermissionRequestState.PENDING else PermissionRequestState.RESPONDING,
)
)
assertSame(editor, view.codeLabelsForTest().single())
view.codeLabelsForTest().single().getEditor(true)
assertEquals(count, EditorFactory.getInstance().allEditors.size)
}
view.hideView()
UIUtil.dispatchAllInvocationEvents()
assertTrue(view.codeLabelsForTest().isEmpty())
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
fun `test rule command field uses editor font after applyStyle`() {
view.show(
Permission(
id = "perm_rule_codefont",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(
command = "git log --oneline -10",
ruleDecisions = listOf(PermissionRuleCandidate("git log *")),
),
)
)
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
view.applyStyle(style)
view.rulesForTest().toggle()
val field = view.rulesForTest().commandFieldsForTest().single()
assertEquals("git log *", field.text)
assertEquals(style.editorFont.name, field.font.name)
assertEquals(style.editorFont.size, field.font.size)
}
private fun permission() = Permission(
@@ -515,6 +799,7 @@ class PermissionViewTest : BasePlatformTestCase() {
private fun allText(root: Container): String = buildString {
fun collect(c: Container) {
for (comp in c.components) {
if (!comp.isVisible) continue
if (comp is javax.swing.text.JTextComponent) append(comp.text).append(" ")
if (comp is javax.swing.JLabel) append(comp.text).append(" ")
if (comp is AbstractButton) append(comp.text).append(" ")
@@ -53,6 +53,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
private set
var globalConfigPathCalls = 0
private set
val refreshedConfigs = CopyOnWriteArrayList<String>()
override suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String {
assertNotEdt("resolveProjectDirectory")
@@ -113,6 +114,11 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return ConfigTargetDto(globalConfigPath, globalConfigDisplayPath, globalConfigExists)
}
override suspend fun refreshConfigFiles(directory: String) {
assertNotEdt("refreshConfigFiles")
refreshedConfigs.add(directory)
}
override suspend fun openLocalConfig(directory: String): Boolean {
assertNotEdt("openLocalConfig")
localConfigs.add(directory)
@@ -63,6 +63,9 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Resolve the editable global config target. */
suspend fun globalConfigTarget(): ConfigTargetDto
/** Refresh local and global config files after external CLI writes. */
suspend fun refreshConfigFiles(directory: String)
/** Open or create the local config file in the IDE. */
suspend fun openLocalConfig(directory: String): Boolean