From e9d0af577359e27728d4b47442d861ac2e5c6e1e Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 19 Jul 2026 11:59:55 -0400 Subject: [PATCH] fix(jetbrains): align permission rules with vscode --- .changeset/jetbrains-permission-rules.md | 5 + .../backend/cli/KiloBackendCliManager.kt | 2 +- .../kilocode/backend/cli/KiloCliDataParser.kt | 20 +- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 7 + .../cli/KiloBackendCliManagerEnvTest.kt | 4 +- .../backend/cli/KiloCliDataParserTest.kt | 8 +- .../client/app/KiloWorkspaceService.kt | 11 +- .../ai/kilocode/client/app/Workspace.kt | 1 + .../client/plugin/KiloPluginSettings.kt | 11 + .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../session/controller/SessionController.kt | 1 + .../client/session/views/SessionViewIcons.kt | 4 + .../views/permission/PermissionView.kt | 734 +++++++++++++++--- .../icons/views/check-small-active.svg | 3 + .../icons/views/check-small-active_dark.svg | 3 + .../resources/icons/views/check-small.svg | 3 + .../icons/views/check-small_dark.svg | 3 + .../icons/views/close-small-active.svg | 3 + .../icons/views/close-small-active_dark.svg | 3 + .../resources/icons/views/close-small.svg | 3 + .../icons/views/close-small_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 8 + .../client/actions/KiloRecoveryActionsTest.kt | 1 + .../session/controller/PromptLifecycleTest.kt | 1 + .../session/ui/SessionMessageListPanelTest.kt | 2 +- .../views/permission/PermissionViewTest.kt | 341 +++++++- .../client/testing/FakeWorkspaceRpcApi.kt | 6 + .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 3 + 28 files changed, 1036 insertions(+), 160 deletions(-) create mode 100644 .changeset/jetbrains-permission-rules.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small_dark.svg diff --git a/.changeset/jetbrains-permission-rules.md b/.changeset/jetbrains-permission-rules.md new file mode 100644 index 00000000000..93ad5a92075 --- /dev/null +++ b/.changeset/jetbrains-permission-rules.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Honor saved JetBrains bash permission rules when running with isolated dev storage. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index fb545a75091..ce7b9fd256b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -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( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 76301d160f2..8d59728beb6 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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, always: List): List { - if (permission != "bash") return emptyList() - val out = linkedSetOf() - 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 { if (this == null) return emptyList() val filediff = this["filediff"].obj() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 7d0311b6c61..595aa7698e1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -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) }) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt index 9024ce35614..2033ab8fd3b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 0604257a1b0..b45af2d502a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 5836ae0cead..bb02fa68af5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt index a490a267ec1..6ed99d98954 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt @@ -14,4 +14,5 @@ class Workspace( val directory: String, val state: StateFlow, val reload: () -> Unit, + val refreshConfigFiles: () -> Unit = {}, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index f410fbc3c34..16641aab323 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a375feb4563..86333825399 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 4152c3e5f9d..7716cc14b19 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt index 4ce4d3b423d..6eb68f643b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -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") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index a3acdfe1c2e..35ef0d0ae47 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -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() - private val regs = mutableListOf() + private var md: MdView? = null private val diffViews = mutableListOf() 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) { - 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) { + 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 = "
${XmlStringUtil.escapeString(text)}
" - 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 = mdScrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun mdScrolls(): List = (md?.component as? JPanel)?.components?.filterIsInstance() ?: 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 { val result = mutableListOf() @@ -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() + 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() + private var baseline = emptyMap() + private var decisions = emptyMap() + + @RequiresEdt + fun update(candidates: List, 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 = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.APPROVED } + + @RequiresEdt + fun denied(): List = 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 = rows.map { it.approveButtonForTest() } + + @RequiresEdt + fun denyButtonsForTest(): List = rows.map { it.denyButtonForTest() } + + @RequiresEdt + fun commandFieldsForTest(): List = 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)), +) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active.svg new file mode 100644 index 00000000000..5456a7768d9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active_dark.svg new file mode 100644 index 00000000000..4e9c204836d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small-active_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small.svg new file mode 100644 index 00000000000..aad4492699d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small_dark.svg new file mode 100644 index 00000000000..cb40cf1481c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/check-small_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active.svg new file mode 100644 index 00000000000..75cf9101650 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active_dark.svg new file mode 100644 index 00000000000..5c73fe97915 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small-active_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small.svg new file mode 100644 index 00000000000..9a56b9bdc72 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small_dark.svg new file mode 100644 index 00000000000..274744271cd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/close-small_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 07867f4f1ff..ed50fa40bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 8629fa1c762..477a78333ca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -360,6 +360,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { dir, MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)), reload = {}, + refreshConfigFiles = {}, ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 25d2c4bdbf6..8b9a5b8e596 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index e9a1c58725a..3c12ebdda27 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 6b52bef0020..e198183702f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -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>() + private val replies = mutableListOf>() 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(), 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(), 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(), 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(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(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(" ") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 4c1b60ef21b..86f8a07b5e0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -53,6 +53,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { private set var globalConfigPathCalls = 0 private set + val refreshedConfigs = CopyOnWriteArrayList() 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) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 0356b6c0e67..fcee8fc217b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -63,6 +63,9 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** 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