Merge pull request #13242 from Kilo-Org/plan-jetbrains-show-diff-on-permission-request

feat(jetbrains): permission-prompt diffs and approval-reason transparency on tool cards
This commit is contained in:
Kirill Kalishev
2026-08-20 13:07:47 -04:00
committed by GitHub
38 changed files with 1234 additions and 355 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show proposed file changes in permission prompts before approval.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show why and how each tool call was allowed. Expanded tool cards (edits, shell commands, and every other tool) now display a shield footer such as "Auto-approved by your global config", including the matched rule or agent and an outside-workspace note. A new "Show approval reason on tool cards" toggle at the bottom of Auto-Approve settings (on by default) controls the footer.
@@ -48,6 +48,7 @@ import ai.kilocode.rpc.dto.ModelTerminalBenchDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import ai.kilocode.rpc.dto.ToolApprovalDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionFileDiffDto
import ai.kilocode.rpc.dto.PermissionReplyDto
@@ -117,6 +118,7 @@ object KiloCliDataParser {
private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE)
private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:")
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
private val APPROVAL_SOURCES = setOf("agent", "global", "project", "yolo", "session", "manual", "default")
// ================================================================
// SSE event parsing
@@ -1148,6 +1150,9 @@ object KiloCliDataParser {
val view = sequenceOf(topMeta?.get("view"), stateMeta?.get("view"))
.mapNotNull(::parseTodoView)
.firstOrNull()
val approval = sequenceOf(stateMeta?.get("approval"), topMeta?.get("approval"))
.mapNotNull(::parseToolApproval)
.firstOrNull()
return PartDto(
id = obj.str("id") ?: "",
sessionID = obj.str("sessionID") ?: "",
@@ -1165,6 +1170,7 @@ object KiloCliDataParser {
title = state?.str("title"),
input = state.map("input"),
metadata = meta,
approval = approval,
output = state?.str("output"),
error = state?.str("error"),
time = obj.time("time") ?: state.time("time"),
@@ -1176,6 +1182,22 @@ object KiloCliDataParser {
)
}
private fun parseToolApproval(raw: JsonElement?): ToolApprovalDto? {
val obj = raw.obj() ?: return null
val source = obj.str("source") ?: return null
if (source !in APPROVAL_SOURCES) return null
val rule = obj["rule"].obj()
return ToolApprovalDto(
source = source,
agent = obj.str("agent"),
rulePermission = rule?.str("permission"),
rulePattern = rule?.str("pattern"),
ruleAction = rule?.str("action"),
outsideWorkspace = obj.flag("outsideWorkspace", false),
outsideWorkspacePath = obj.str("outsideWorkspacePath"),
)
}
private fun sanitizePart(part: PartDto, role: String): PartDto {
if (role != "user" || part.type != "text") return part
return part.copy(text = part.text?.let(::sanitizeUserPromptText))
@@ -415,6 +415,45 @@ class KiloCliDataParserTest {
assertTrue(result.part.metadata["view"]?.contains("compact") == true)
}
@Test
fun `parseChatEvent - tool part parses typed approval metadata`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "part_bash",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "tool",
"tool": "bash",
"callID": "call_bash",
"state": {
"status": "completed",
"input": { "command": "pwd" },
"metadata": {
"approval": {
"source": "global",
"rule": { "permission": "bash", "pattern": "pwd", "action": "allow" },
"outsideWorkspace": true,
"outsideWorkspacePath": "/tmp/project"
}
}
}
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated
assertEquals("global", result.part.approval?.source)
assertEquals("bash", result.part.approval?.rulePermission)
assertEquals("pwd", result.part.approval?.rulePattern)
assertEquals("allow", result.part.approval?.ruleAction)
assertEquals(true, result.part.approval?.outsideWorkspace)
assertEquals("/tmp/project", result.part.approval?.outsideWorkspacePath)
}
@Test
fun `parseChatEvent - empty top metadata todos overrides fallback todos`() {
val data = globalEvent("""
@@ -5,6 +5,7 @@ import com.intellij.ide.util.PropertiesComponent
object KiloPluginSettings {
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext"
private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason"
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
private const val WORKTREE_SESSION_LIST_EXPANDED_KEY = "kilo.worktree.sessionListExpanded"
@@ -28,6 +29,16 @@ object KiloPluginSettings {
PropertiesComponent.getInstance().unsetValue(AUTO_EDITOR_CONTEXT_KEY)
}
fun getShowApprovalReason(): Boolean = PropertiesComponent.getInstance().getBoolean(SHOW_APPROVAL_REASON_KEY, true)
fun setShowApprovalReason(value: Boolean) {
PropertiesComponent.getInstance().setValue(SHOW_APPROVAL_REASON_KEY, value.toString())
}
internal fun unsetShowApprovalReason() {
PropertiesComponent.getInstance().unsetValue(SHOW_APPROVAL_REASON_KEY)
}
fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false)
fun setPermissionRulesExpanded(value: Boolean) {
@@ -29,6 +29,7 @@ import ai.kilocode.client.session.ui.prompt.MentionAction
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.prompt.SlashAction
import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts
import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.popup.HeaderPopupController
import ai.kilocode.client.session.ui.SessionDropOverlay
@@ -367,6 +368,7 @@ class SessionUi(
)
permission = PermissionView(
reply = { id, dto, rules -> controller.replyPermission(id, dto, rules) },
openFile = fileLinks::open,
selection = selection,
focus = focus,
)
@@ -664,6 +666,13 @@ class SessionUi(
applyStyle(SessionEditorStyle.current())
}
})
bus.subscribe(ApprovalReasonVisibilityListener.TOPIC, ApprovalReasonVisibilityListener { visible ->
ApplicationManager.getApplication().invokeLater {
if (disposed) return@invokeLater
if (!this::messageBody.isInitialized) return@invokeLater
messageBody.syncApprovalReasons(visible)
}
})
}
private fun onSessionLoaded(show: Boolean) {
@@ -86,6 +86,7 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var title: String? = null
var input: Map<String, String> = emptyMap()
var metadata: Map<String, String> = emptyMap()
var approval: ToolApproval? = null
var childSessionId: String? = null
var childTools: List<Tool> = emptyList()
var output: String? = null
@@ -95,6 +96,16 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var todoView: TodoViewDto? = null
}
data class ToolApproval(
val source: String,
val agent: String? = null,
val rulePermission: String? = null,
val rulePattern: String? = null,
val ruleAction: String? = null,
val outsideWorkspace: Boolean = false,
val outsideWorkspacePath: String? = null,
)
/** Context compaction marker. */
class Compaction(id: String) : Content(id)
@@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionRevertDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.ToolApprovalDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -510,6 +511,7 @@ class SessionModel {
existing.title = dto.title
existing.input = dto.input
existing.metadata = dto.metadata
existing.approval = dto.approval?.toModel()
existing.childSessionId = childID(existing)
if (old != null && old != existing.childSessionId) {
childRefs.remove(old)
@@ -566,6 +568,7 @@ class SessionModel {
title = dto.title
input = dto.input
metadata = dto.metadata
approval = dto.approval?.toModel()
childSessionId = childID(this)
output = dto.output
error = dto.error
@@ -929,6 +932,16 @@ private fun renderTool(tool: Tool): String {
return "tool#${tool.id} ${tool.name} [$state]$title$data"
}
private fun ToolApprovalDto.toModel() = ToolApproval(
source = source,
agent = agent,
rulePermission = rulePermission,
rulePattern = rulePattern,
ruleAction = ruleAction,
outsideWorkspace = outsideWorkspace,
outsideWorkspacePath = outsideWorkspacePath,
)
private fun renderMap(map: Map<String, String>): String =
map.entries.sortedBy { it.key }.joinToString(",", "{", "}") { "${it.key}=${it.value}" }
@@ -0,0 +1,15 @@
package ai.kilocode.client.session.settings
import com.intellij.util.messages.Topic
fun interface ApprovalReasonVisibilityListener {
fun changed(visible: Boolean)
companion object {
@JvmField
val TOPIC: Topic<ApprovalReasonVisibilityListener> = Topic.create(
"Kilo approval reason visibility",
ApprovalReasonVisibilityListener::class.java,
)
}
}
@@ -0,0 +1,166 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.AbstractSessionPartView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.tool.EditFileChange
import ai.kilocode.client.session.views.tool.PatchBody
import ai.kilocode.client.session.views.tool.setFont
import ai.kilocode.client.session.views.tool.setForeground
import ai.kilocode.client.session.views.tool.setIcon
import ai.kilocode.client.ui.DiffBadge
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import javax.swing.JComponent
internal abstract class ChangesCardView(
private val openFile: SessionFileOpener,
private val selection: SessionSelection?,
protected val parts: Header,
private val body: PatchBody,
private val linkFiles: Boolean,
) : AbstractSessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget {
protected var style = SessionEditorStyle.current()
protected var files = emptyList<EditFileChange>()
protected var items = emptyList<DiffFileDto>()
protected var openDiff: SessionDiffOpener = { _, _, _ -> }
protected var sessionId: String? = null
override val copyEligible: Boolean get() = items.any(::openable)
override val copyAnchor: JComponent get() = parts.anchor
override val copyToolbar: JComponent get() = parts.diff
init {
body.parent = this
body.overflow = ::openDiffViewer
parts.diff.addActionListener { openDiffViewer() }
applyStyle(style)
}
override fun copyText(): String? = null
@RequiresEdt
protected fun render(value: List<DiffFileDto>) {
items = value
files = value.map(::file)
val additions = files.sumOf { it.additions }
val deletions = files.sumOf { it.deletions }
parts.update(files.size, additions, deletions)
parts.diff.isEnabled = value.any(::openable)
syncExpandable(files.any { it.patch.isNotBlank() })
if (isExpanded()) body.updateFiles(files)
revalidate()
repaint()
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
body.updateFiles(files)
body.applyStyle(style)
return true
}
@RequiresEdt
override fun update(content: Content) = Unit
@RequiresEdt
override fun headerPopup(): HeaderPopupRequest? =
popup(popupKind, popupName, files.any { it.patch.isNotBlank() }) {
PatchBody.popup(selection, openFile, files, style, linkFiles, ::openDiffViewer)
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
parts.applyStyle(style)
body.applyStyle(style)
refresh()
}
override fun dispose() {
body.disposeBody()
super.dispose()
}
@RequiresEdt
protected fun cardBodyCreated() = body.created()
@RequiresEdt
protected fun cardBodyAttached() = body.attached(this)
@RequiresEdt
protected fun cardCodeEditors() = body.codeEditors()
@RequiresEdt
protected fun openDiffViewer() {
val open = items.filter(::openable)
if (open.isEmpty()) return
openDiff(open, diffTitle(), diffToken())
}
protected abstract val popupKind: String
protected abstract val popupName: String
protected abstract fun openable(dto: DiffFileDto): Boolean
protected abstract fun diffTitle(): String
protected abstract fun diffToken(): String
class Header(
title: String,
val badge: JComponent,
private val stats: DiffBadge,
) {
val glyph = JBLabel()
val title = JBLabel(title)
val count = JBLabel()
val diff = toolbarButton(
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {},
).apply { isEnabled = false }
val anchor: JComponent = hoverPlaceholder(diff)
val panel = PartHeader().apply {
leading(glyph)
left(this@Header.title)
titleGap()
left(count, PartHeader.centered(badge), anchor)
}
@RequiresEdt
fun update(total: Int, additions: Int, deletions: Int) {
count.text = KiloBundle.message(if (total == 1) "session.changes.count.one" else "session.changes.count.other", total)
stats.update(additions, deletions)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
setIcon(glyph, SessionViewIcons.edit)
setForeground(glyph, SessionUiStyle.View.Tool.completed())
setFont(title, style.boldEditorFont)
setFont(count, style.transcriptFont)
setForeground(title, SessionUiStyle.Colors.foreground())
setForeground(count, SessionUiStyle.Text.Secondary.foreground())
}
}
}
private fun file(dto: DiffFileDto) = EditFileChange(
path = dto.file,
type = dto.status.orEmpty(),
additions = dto.additions,
deletions = dto.deletions,
patch = dto.patch.orEmpty(),
)
@@ -3,64 +3,35 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.AbstractSessionPartView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.tool.EditFileChange
import ai.kilocode.client.session.views.tool.POPUP_OPTS
import ai.kilocode.client.session.views.tool.PatchBody
import ai.kilocode.client.session.views.tool.setFont
import ai.kilocode.client.session.views.tool.setForeground
import ai.kilocode.client.session.views.tool.setIcon
import ai.kilocode.client.ui.DiffBars
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import javax.swing.JComponent
class ModifiedFilesView private constructor(
private val openFile: SessionFileOpener,
private val selection: SessionSelection? = null,
private val parts: Header = Header(),
private val body: PatchBody = PatchBody(selection, openFile),
) : AbstractSessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget {
internal class ModifiedFilesView private constructor(
openFile: SessionFileOpener,
selection: SessionSelection?,
parts: ChangesCardView.Header,
body: PatchBody,
) : ChangesCardView(openFile, selection, parts, body, linkFiles = true) {
override val contentId = CONTENT_ID
private var style = SessionEditorStyle.current()
private var files = emptyList<EditFileChange>()
private var diffs = emptyList<DiffFileDto>()
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var turnId: String = CONTENT_ID
init {
isVisible = false
}
constructor(
openFile: SessionFileOpener,
selection: SessionSelection? = null,
) : this(openFile, selection, Header(), PatchBody(selection, openFile))
init {
body.parent = this
body.overflow = ::openDiffViewer
parts.diff.addActionListener { openDiffViewer() }
isVisible = false
applyStyle(style)
}
override val copyEligible: Boolean get() = diffs.isNotEmpty()
override val copyAnchor: JComponent get() = parts.anchor
override val copyToolbar: JComponent get() = parts.diff
) : this(
openFile,
selection,
modifiedHeader(),
PatchBody(selection, openFile),
)
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) {
this.openDiff = openDiff
@@ -71,10 +42,8 @@ class ModifiedFilesView private constructor(
/** Returns true when anything visible changed, so the parent only relayouts on a real change. */
@RequiresEdt
fun setDiffs(diffs: List<DiffFileDto>): Boolean {
val next = diffs.map(::file)
this.diffs = diffs
if (files == next) {
val visible = next.isNotEmpty()
if (items == diffs) {
val visible = diffs.isNotEmpty()
parts.diff.isEnabled = visible
if (isVisible == visible) return false
isVisible = visible
@@ -82,121 +51,37 @@ class ModifiedFilesView private constructor(
repaint()
return true
}
files = next
val visible = files.isNotEmpty()
val additions = files.sumOf { it.additions }
val deletions = files.sumOf { it.deletions }
val visible = diffs.isNotEmpty()
if (isVisible != visible) isVisible = visible
if (!visible) collapse()
parts.update(files.size, additions, deletions)
parts.diff.isEnabled = visible
if (isExpanded()) body.updateFiles(files)
revalidate()
repaint()
render(diffs)
return true
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
body.updateFiles(files)
body.applyStyle(style)
return true
}
internal fun bodyCreated() = cardBodyCreated()
@RequiresEdt
override fun update(content: Content) = Unit
override fun copyText(): String? = null
@RequiresEdt
override fun headerPopup(): HeaderPopupRequest? =
popup("tool", "changes", files.isNotEmpty()) { buildPopup(files) }
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
parts.applyStyle(style)
body.applyStyle(style)
refresh()
}
override fun dispose() {
body.disposeBody()
super.dispose()
}
@RequiresEdt
internal fun bodyCreated() = body.created()
@RequiresEdt
internal fun bodyVisible() = body.attached(this)
internal fun bodyVisible() = cardBodyAttached()
@RequiresEdt
internal fun countText() = parts.count.text
private fun openDiffViewer() {
if (diffs.isEmpty()) return
openDiff(diffs, KiloBundle.message("diff.editor.changedFiles.title"), "turn:${sessionId ?: "pending"}:$turnId")
}
override val popupKind = "tool"
override val popupName = "changes"
@RequiresEdt
private fun buildPopup(files: List<EditFileChange>): HeaderPopupBody {
val owner = Disposer.newDisposable("Modified files popup body")
val popup = PatchBody(selection, openFile, POPUP_OPTS).also {
it.parent = owner
it.overflow = ::openDiffViewer
}
val panel = popup.mountFiles(files)
popup.applyStyle(style)
return HeaderPopupBody(panel, owner, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
}
override fun openable(dto: DiffFileDto) = true
private class Header {
val glyph = JBLabel()
val title = JBLabel(KiloBundle.message("session.changes.modified"))
val count = JBLabel()
val diff = toolbarButton(
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {},
).apply { isEnabled = false }
val anchor = hoverPlaceholder(diff)
val bars = DiffBars(0, 0)
// Left-aligned header: icon, title, file count, sticks change badge, open-in-diff.
val panel = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
left(count, PartHeader.centered(bars), anchor)
}
override fun diffTitle() = KiloBundle.message("diff.editor.changedFiles.title")
@RequiresEdt
fun update(total: Int, additions: Int, deletions: Int) {
val text = KiloBundle.message(if (total == 1) "session.changes.count.one" else "session.changes.count.other", total)
if (count.text != text) count.text = text
bars.update(additions, deletions)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
setIcon(glyph, SessionViewIcons.edit)
setForeground(glyph, SessionUiStyle.View.Tool.completed())
setFont(title, style.boldEditorFont)
setFont(count, style.transcriptFont)
setForeground(title, SessionUiStyle.Colors.foreground())
setForeground(count, SessionUiStyle.Text.Secondary.foreground())
}
}
override fun diffToken() = "turn:${sessionId ?: "pending"}:$turnId"
private companion object {
const val CONTENT_ID = "session-modified-files"
}
}
private fun file(dto: DiffFileDto) = EditFileChange(
path = dto.file,
type = "",
additions = dto.additions,
deletions = dto.deletions,
patch = dto.patch.orEmpty(),
)
private fun modifiedHeader(): ChangesCardView.Header {
val badge = DiffBars(0, 0)
return ChangesCardView.Header(KiloBundle.message("session.changes.modified"), badge, badge)
}
@@ -228,6 +228,8 @@ class SessionMessageListPanel(
this.openDiff = openDiff
this.sessionId = sessionId
banner?.setDiffOpener(openDiff, sessionId)
permission?.setDiffOpener(openDiff, sessionId)
permission?.setHoverSink(::hover)
turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) }
}
@@ -501,6 +503,15 @@ class SessionMessageListPanel(
for (mv in msgToView.values) mv.setHiddenQuestionTool(ref)
}
@RequiresEdt
fun syncApprovalReasons(visible: Boolean) {
var changed = false
for (mv in msgToView.values) changed = mv.syncApprovalReasons(visible) || changed
if (!changed) return
reflow()
refresh()
}
private fun syncSettled(state: SessionState = model.state) {
val active = if (state.isBusy()) turnViews.values.lastOrNull { !model.isQueued(it.id) } else null
for (view in turnViews.values) view.setSettled(view !== active)
@@ -113,6 +113,13 @@ object SessionUiStyle {
const val BODY_EXTRA_HEIGHT = 16
}
/**
* Left inset for expanded card content that should read as nested under the header — the diff
* body's filename row and the auto-approve rule rows both use it. Reuse this wherever expanded
* content needs indenting so the amount stays consistent across cards.
*/
fun contentIndent() = UiStyle.Gap.pad()
/**
* Standard transparent inset separating an expanded card header from its content, and
* separating stacked content surfaces inside a [ai.kilocode.client.session.ui.SessionContentPanel].
@@ -20,6 +20,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.views.tool.ApprovalReasonTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.ToolbarButtonAction
@@ -129,6 +130,16 @@ class MessageView(
rebuildParts()
}
@RequiresEdt
fun syncApprovalReasons(visible: Boolean): Boolean {
var changed = false
for (view in parts.values) {
if (view is ApprovalReasonTarget) changed = view.syncApprovalReason(visible) || changed
}
if (changed) refresh()
return changed
}
/** Add or update the renderer for [content]. */
@RequiresEdt
fun upsertPart(content: Content) {
@@ -26,6 +26,7 @@ object SessionViewIcons {
val ruleDeny = icon("close-small")
val ruleDenyActive = icon("close-small-active")
val search = icon("magnifying-glass-menu")
val shield: Icon = IconLoader.getIcon("/icons/shield.svg", SessionViewIcons::class.java)
val task = icon("task")
val warning = icon("warning")
val windowCursor = icon("window-cursor")
@@ -42,6 +42,7 @@ import javax.swing.SwingUtilities
abstract class AbstractSessionPartView(
header: JComponent,
private val makeBody: () -> JComponent,
private val makeFooter: (() -> JComponent)? = null,
expanded: Boolean = false,
private val expandable: Boolean = true,
private val compact: Boolean = false,
@@ -53,13 +54,14 @@ abstract class AbstractSessionPartView(
expanded: Boolean = false,
expandable: Boolean = true,
compact: Boolean = false,
) : this(header, { body }, expanded, expandable, compact)
) : this(header, { body }, null, expanded, expandable, compact)
protected val arrow = JBLabel()
protected val row = Row()
private val clickable = linkedSetOf<Component>()
private val watched = linkedSetOf<Component>()
private var body: JComponent? = null
private var footer: JComponent? = null
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
@@ -99,7 +101,7 @@ abstract class AbstractSessionPartView(
row.add(arrow, BorderLayout.EAST)
add(row, BorderLayout.NORTH)
watch(row)
if (expanded && expandable) add(body(), BorderLayout.CENTER)
if (expanded && expandable) attachBody()
if (!expandable) syncExpandable(false) else syncArrow()
}
@@ -125,7 +127,7 @@ abstract class AbstractSessionPartView(
open fun expand(): Boolean {
if (!expandable) return false
if (isExpanded()) return false
add(body(), BorderLayout.CENTER)
attachBody()
return true
}
@@ -133,6 +135,7 @@ abstract class AbstractSessionPartView(
val item = body ?: return false
if (item.parent !== this) return false
remove(item)
footer?.takeIf { it.parent === this }?.let(::remove)
return true
}
@@ -145,10 +148,18 @@ abstract class AbstractSessionPartView(
val item = body ?: return false
val attached = item.parent === this
if (attached) remove(item)
footer?.takeIf { it.parent === this }?.let(::remove)
body = null
footer = null
return attached
}
protected fun footerHeight(): Int {
val item = footer ?: return 0
if (!item.isVisible) return 0
return expandedGap() + item.preferredSize.height
}
private fun toggleLocal(): Boolean {
val fn = resize ?: return toggleBody()
val expanded = isExpanded()
@@ -340,6 +351,19 @@ abstract class AbstractSessionPartView(
return makeBody().also { body = it }
}
private fun attachBody() {
add(body(), BorderLayout.CENTER)
val item = footer()
if (item != null) add(item, BorderLayout.SOUTH)
}
private fun footer(): JComponent? {
val item = footer
if (item != null) return item
val make = makeFooter ?: return null
return make().also { footer = it }
}
private fun syncCursor(cursor: Cursor): Boolean {
var changed = false
clickable.forEach {
@@ -1,44 +1,102 @@
package ai.kilocode.client.session.views.permission
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.ChangesCardView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.views.tool.PatchBody
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.layout.Stack
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.util.concurrency.annotations.RequiresEdt
/**
* Renders a single [PermissionFileDiff] inside a permission card as a compact diff-stat badge.
* Patch content and file path are intentionally not displayed here; the permission target row
* already shows the path.
* Renders proposed file changes inside a permission card with the same expandable body, popup
* preview, and full diff-editor affordance used for modified files.
*/
class PermissionDiffView(
private val diff: PermissionFileDiff,
) : BorderLayoutPanel(), SessionEditorStyleTarget {
internal class PermissionDiffView private constructor(
openFile: SessionFileOpener,
selection: SessionSelection?,
parts: ChangesCardView.Header,
body: PatchBody,
) : ChangesCardView(openFile, selection, parts, body, linkFiles = false) {
override val contentId = CONTENT_ID
private val badge = DiffStatBadge(diff.additions, diff.deletions)
private var requestId: String? = null
init {
isOpaque = false
val row = buildRow()
addToCenter(row)
constructor(
diffs: List<PermissionFileDiff>,
openFile: SessionFileOpener,
selection: SessionSelection?,
) : this(openFile, selection, permissionHeader(), PatchBody(selection, openFile, linkFiles = false)) {
setDiffs(diffs)
}
override fun applyStyle(style: SessionEditorStyle) {
// Badge colors are theme-derived and update through Swing repainting.
@RequiresEdt
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, requestId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
this.requestId = requestId
}
private fun buildRow() = JBUI.Panels.simplePanel().apply {
isOpaque = false
border = JBUI.Borders.empty()
val inner = Stack.horizontal()
inner.add(badge)
addToCenter(inner)
@RequiresEdt
fun setDiffs(value: List<PermissionFileDiff>) {
val dtos = value.map(::dto)
if (items == dtos) return
render(dtos)
}
// Test helpers
internal fun badgeForTest() = badge
@RequiresEdt
internal fun bodyCreated() = cardBodyCreated()
@RequiresEdt
internal fun badgeForTest() = parts.badge as DiffStatBadge
@RequiresEdt
internal fun openDiffForTest() = openDiffViewer()
@RequiresEdt
internal fun openDiffEnabledForTest() = parts.diff.isEnabled
@RequiresEdt
internal fun openDiffButtonForTest() = parts.diff
@RequiresEdt
internal fun openDiffAnchorForTest() = parts.anchor
@RequiresEdt
internal fun codeEditorsForTest() = cardCodeEditors()
@RequiresEdt
internal fun countTextForTest() = parts.count.text
override val popupKind = "permission"
override val popupName = "diff"
override fun openable(dto: DiffFileDto) = hasOpenableContent(dto)
override fun diffTitle() = KiloBundle.message("session.permission.diff")
override fun diffToken() = "permission:${sessionId ?: "pending"}:${requestId ?: "pending"}"
private companion object {
const val CONTENT_ID = "permission-diff"
}
}
private fun permissionHeader(): ChangesCardView.Header {
val badge = DiffStatBadge(0, 0)
return ChangesCardView.Header(KiloBundle.message("session.permission.diff"), badge, badge)
}
private fun dto(diff: PermissionFileDiff) = DiffFileDto(
file = diff.file,
additions = diff.additions,
deletions = diff.deletions,
patch = diff.patch,
before = diff.before,
after = diff.after,
)
private fun hasOpenableContent(dto: DiffFileDto) = !dto.patch.isNullOrBlank() || dto.before != null || dto.after != null
@@ -2,6 +2,9 @@ package ai.kilocode.client.session.views.permission
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionRuleCandidate
@@ -13,6 +16,9 @@ import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.AbstractSessionPartView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.client.ui.editor.BashCommandHighlighter
@@ -36,6 +42,7 @@ import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
@@ -53,6 +60,7 @@ import java.awt.Rectangle
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Icon
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
@@ -67,6 +75,7 @@ import javax.swing.ScrollPaneConstants
*/
class PermissionView(
private val reply: (String, PermissionReplyDto, PermissionAlwaysRulesDto?) -> Unit,
private val openFile: SessionFileOpener = { _, _ -> },
private val selection: SessionSelection? = null,
focus: (() -> Unit)? = null,
) : DialogView(selection, focus), SessionView, Disposable {
@@ -75,11 +84,14 @@ class PermissionView(
private var requestId: String? = null
private var responding = false
private var style = SessionEditorStyle.current()
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var hover: ((PartView, Boolean) -> Unit)? = null
private val body = Stack.vertical(gap = UiStyle.Gap.sm())
private val desc = makeDescription()
private val codeSlot = BorderLayoutPanel().apply { isVisible = false }
private val diffRow = Stack.horizontal().apply { isVisible = false }
private val diffRow = Stack.vertical().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)
@@ -87,7 +99,7 @@ class PermissionView(
}
private var md: MdView? = null
private val diffViews = mutableListOf<PermissionDiffView>()
private var diffView: PermissionDiffView? = null
private val ID_DENY = "deny"
private val ID_RUN = "run"
@@ -149,15 +161,26 @@ class PermissionView(
refresh()
}
@RequiresEdt
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
diffView?.setDiffOpener(openDiff, sessionId, requestId)
}
@RequiresEdt
fun setHoverSink(sink: (PartView, Boolean) -> Unit) {
hover = sink
diffView?.hover = sink
}
/** Hide this view and clear the active request id. */
@RequiresEdt
fun hideView() {
requestId = null
responding = false
disposeMd()
diffViews.clear()
diffRow.removeAll()
diffRow.isVisible = false
disposeDiffs()
rules.update(emptyList(), reset = true)
state.isVisible = false
isVisible = false
@@ -172,9 +195,7 @@ class PermissionView(
desc.foreground = SessionUiStyle.Text.Secondary.foreground()
rules.applyStyle(style)
md?.let { applyCodeStyle(it) }
for (dv in diffViews) {
dv.applyStyle(style)
}
diffView?.applyStyle(style)
}
@RequiresEdt
@@ -185,16 +206,26 @@ class PermissionView(
@RequiresEdt
private fun syncDiffs(diffs: List<PermissionFileDiff>) {
diffRow.removeAll()
diffViews.clear()
diffRow.isVisible = diffs.isNotEmpty()
if (diffs.isNotEmpty()) {
for (diff in diffs) {
val dv = PermissionDiffView(diff)
diffViews.add(dv)
diffRow.add(dv)
}
if (diffs.isEmpty()) {
disposeDiffs()
return
}
// Retain the card across the RESPONDING/ERROR re-renders of the same request so an
// expanded inline preview is not torn down; setDiffs updates it in place. The card is
// disposed in hideView when the request resolves, so a new request always starts fresh.
val existing = diffView
if (existing != null) {
existing.setDiffOpener(openDiff, sessionId, requestId)
existing.setDiffs(diffs)
} else {
val dv = PermissionDiffView(diffs, openFile, selection)
dv.setDiffOpener(openDiff, sessionId, requestId)
dv.hover = hover
dv.applyStyle(style)
diffView = dv
diffRow.add(dv)
}
diffRow.isVisible = true
diffRow.revalidate()
diffRow.repaint()
}
@@ -311,50 +342,7 @@ class PermissionView(
}
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 = SessionUiStyle.Text.Secondary.foreground()
font = SessionUiStyle.Text.Secondary.font(style)
border = JBUI.Borders.empty()
isVisible = false
}
val area = wrappingSecondaryText(style).apply { isVisible = false }
selection?.register(area)
return area
}
@@ -436,8 +424,17 @@ class PermissionView(
Disposer.dispose(view)
}
@RequiresEdt
private fun disposeDiffs() {
diffView?.let(Disposer::dispose)
diffView = null
diffRow.removeAll()
diffRow.isVisible = false
}
override fun dispose() {
disposeMd()
disposeDiffs()
Disposer.dispose(rules)
}
@@ -454,7 +451,7 @@ class PermissionView(
internal fun runButtonForTest() = buttons(this).first { it.text == KiloBundle.message("session.permission.allow") || it.text == KiloBundle.message("session.permission.allow.once") }
internal fun denyButtonForTest() = buttons(this).first { it.text == KiloBundle.message("session.permission.reject") }
internal fun codeLabelsForTest() = codeEditors()
internal fun diffViewsForTest() = diffViews.toList()
internal fun diffViewsForTest() = listOfNotNull(diffView)
internal fun headerFontForTest() = textAreas(this).first { it.font.isBold }.font
internal fun rulesForTest() = rules
@@ -477,37 +474,81 @@ class PermissionView(
}
}
internal class PermissionRulesView(
/**
* Transparent, non-editable, secondary-styled text area that soft-wraps to its parent width. Shared
* by the permission description and the auto-approve rule hints so wrapping prose reads the same
* everywhere instead of clipping in a single-line label.
*/
private fun wrappingSecondaryText(style: SessionEditorStyle): 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
}
}
area.isEditable = false
area.isOpaque = false
area.isFocusable = false
area.caret.isVisible = false
area.caret.isSelectionVisible = false
area.lineWrap = true
area.wrapStyleWord = true
area.foreground = SessionUiStyle.Text.Secondary.foreground()
area.font = SessionUiStyle.Text.Secondary.font(style)
area.border = JBUI.Borders.empty()
return area
}
internal class PermissionRulesView private constructor(
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 val inset = Stack.vertical(gap = UiStyle.Gap.xs()).apply {
border = JBUI.Borders.emptyLeft(SessionViewIcons.chevronCollapsed.iconWidth)
}
private var box: Stack? = null
private val parts: Header,
private val box: Stack,
) : AbstractSessionPartView(parts.panel, box, expanded = KiloPluginSettings.getPermissionRulesExpanded()) {
override val contentId = CONTENT_ID
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)
next(inset)
syncArrow()
}
private var candidates = emptyList<PermissionRuleCandidate>()
private var baseline = emptyMap<String, PermissionRuleDecision>()
private var decisions = emptyMap<String, PermissionRuleDecision>()
constructor(selection: SessionSelection?, changed: () -> Unit) :
this(selection, changed, Header(), Stack.vertical(gap = UiStyle.Gap.xs()))
init {
// Indent the rule rows under the header, matching the diff body's nested content inset.
box.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.contentIndent())
parts.applyStyle(style)
}
@RequiresEdt
fun update(candidates: List<PermissionRuleCandidate>, reset: Boolean = false) {
isVisible = candidates.isNotEmpty()
@@ -517,51 +558,47 @@ internal class PermissionRulesView(
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) }
syncExpandable(candidates.isNotEmpty())
if (candidates.isEmpty()) {
box?.let {
if (it.parent === inset) inset.remove(it)
}
box = null
disposeRows()
syncArrow()
box.removeAll()
changed()
return
}
if (stale && box != null) syncBody(rebuild = true) else syncRows()
syncExpanded()
syncArrow()
if (isExpanded()) {
if (stale) rebuildRows() else syncRows()
}
changed()
}
// The rule rows are the card body: built lazily on first expand and rebuilt only when the
// candidate set changes, so the editor-backed command fields are not created while collapsed.
@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
override fun expand(): Boolean {
val changed = super.expand()
if (changed) rebuildRows()
return changed
}
@RequiresEdt
private fun syncBody(rebuild: Boolean) {
val root = box ?: return
if (rebuild) {
root.removeAll()
disposeRows()
for (candidate in candidates) {
val row = RuleRow(candidate.pattern, candidate.defaultDecision, style, selection) { pattern, decision ->
decisions = decisions + (pattern to decision)
syncRows()
changed()
}
rows.add(row)
root.next(row)
override fun update(content: Content) = Unit
@RequiresEdt
private fun rebuildRows() {
box.removeAll()
disposeRows()
for (candidate in candidates) {
val row = RuleRow(candidate.pattern, candidate.defaultDecision, style, selection) { pattern, decision ->
decisions = decisions + (pattern to decision)
syncRows()
changed()
}
rows.add(row)
box.next(row)
}
syncRows()
root.revalidate()
root.repaint()
box.revalidate()
box.repaint()
}
@RequiresEdt
@@ -569,27 +606,6 @@ internal class PermissionRulesView(
for (row in rows) row.update(decisions[row.pattern] ?: PermissionRuleDecision.PENDING)
}
@RequiresEdt
private fun syncExpanded() {
if (box?.parent === inset) return
if (!KiloPluginSettings.getPermissionRulesExpanded()) return
inset.add(body())
}
@RequiresEdt
fun toggle() {
if (candidates.isEmpty()) return
val root = body()
if (isExpanded()) inset.remove(root) else inset.add(root)
KiloPluginSettings.setPermissionRulesExpanded(isExpanded())
syncArrow()
revalidate()
repaint()
}
@RequiresEdt
fun isExpanded(): Boolean = box?.parent === inset
@RequiresEdt
fun approved(): List<String> = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.APPROVED }
@@ -605,9 +621,15 @@ internal class PermissionRulesView(
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
parts.applyStyle(style)
for (row in rows) row.applyStyle(style)
refresh()
}
override fun userToggled() {
KiloPluginSettings.setPermissionRulesExpanded(isExpanded())
}
@RequiresEdt
@@ -620,12 +642,7 @@ internal class PermissionRulesView(
fun commandFieldsForTest(): List<EditorTextField> = rows.map { it.commandFieldForTest() }
@RequiresEdt
fun hintLabelsForTest(): List<JBLabel> = rows.map { it.hintLabelForTest() }
@RequiresEdt
private fun syncArrow() {
arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed
}
fun hintLabelsForTest(): List<JBTextArea> = rows.map { it.hintLabelForTest() }
@RequiresEdt
private fun disposeRows() {
@@ -635,6 +652,29 @@ internal class PermissionRulesView(
override fun dispose() {
disposeRows()
super.dispose()
}
// Card-style header shared with the change/modified cards: leading permission glyph and title.
// The collapse/expand chevron on the trailing edge is owned by AbstractSessionPartView.
private class Header {
val glyph = JBLabel(SHIELD_ICON)
val title = JBLabel(KiloBundle.message("session.permission.rules.title"))
val panel = PartHeader().apply {
leading(glyph)
left(title)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
title.font = style.boldEditorFont
title.foreground = SessionUiStyle.Colors.foreground()
}
}
private companion object {
const val CONTENT_ID = "session-permission-rules"
val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PermissionRulesView::class.java)
}
private class RuleRow(
@@ -653,7 +693,8 @@ internal class PermissionRulesView(
private val deny = RuleToggleButton(false) {
changed(pattern, if (decision == PermissionRuleDecision.DENIED) PermissionRuleDecision.PENDING else PermissionRuleDecision.DENIED)
}
private val hint = JBLabel()
private val hint = wrappingSecondaryText(style)
private val hintReg = selection?.register(hint)
private val field = RuleCommandField(pattern, style, selection)
private val controls = Stack.horizontal(gap = UiStyle.Gap.xs())
@@ -664,7 +705,7 @@ internal class PermissionRulesView(
controls.next(field.align(HAlign.LEFT, VAlign.CENTER))
controls.fill(0)
next(controls)
next(hint.align(HAlign.LEFT, VAlign.CENTER))
next(hint)
applyStyle(style)
update(PermissionRuleDecision.PENDING)
}
@@ -706,9 +747,10 @@ internal class PermissionRulesView(
fun commandFieldForTest(): EditorTextField = field
fun hintLabelForTest(): JBLabel = hint
fun hintLabelForTest(): JBTextArea = hint
override fun dispose() {
hintReg?.let(Disposer::dispose)
field.dispose()
}
}
@@ -9,6 +9,9 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.AbstractSessionPartView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.tool.ApprovalReasonTarget
import ai.kilocode.client.session.views.tool.ToolApprovalFooter
import ai.kilocode.client.session.views.tool.approvalReasonsVisible
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -28,16 +31,19 @@ class QuestionResultView(
tool: Tool,
selection: SessionSelection? = null,
private val parts: QuestionParts = questionParts(selection),
) : AbstractSessionPartView(parts.header, { parts.body }) {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { parts.body }, { footer }), ApprovalReasonTarget {
override val contentId: String = tool.id
private var result = parse(tool)
private var item = tool
private var style = SessionEditorStyle.current()
init {
applyStyle(style)
syncLabels()
syncApprovalReason(approvalReasonsVisible())
}
override fun expand(): Boolean {
@@ -49,11 +55,16 @@ class QuestionResultView(
override fun update(content: Content) {
if (content !is Tool) return
item = content
val next = parse(content)
if (next == result) return
if (next == result) {
if (syncApprovalReason(approvalReasonsVisible())) refresh()
return
}
result = next
syncLabels()
if (isExpanded()) parts.body.set(result.questions, result.answers)
syncApprovalReason(approvalReasonsVisible())
refresh()
}
@@ -62,9 +73,16 @@ class QuestionResultView(
var changed = setFont(parts.title, style.boldFont)
changed = setFont(parts.sub, style.smallFont) || changed
changed = parts.body.applyStyle(style) || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ")
fun bodyText(): String = result.questions.mapIndexed { i, q ->
@@ -10,6 +10,9 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.AbstractSessionPartView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.tool.ApprovalReasonTarget
import ai.kilocode.client.session.views.tool.ToolApprovalFooter
import ai.kilocode.client.session.views.tool.approvalReasonsVisible
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.TodoDto
@@ -19,8 +22,11 @@ import com.intellij.util.ui.JBUI
import java.awt.Font
import javax.swing.JComponent
class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
AbstractSessionPartView(parts.header, parts.list, expanded = true) {
class TodoWriteView(
tool: Tool,
private val parts: TodoParts = todoParts(),
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { parts.list }, { footer }, expanded = true), ApprovalReasonTarget {
override val contentId = tool.id
@@ -62,9 +68,17 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.transcriptFont) || changed
parts.list.applyStyle(style)
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ")
internal fun rowCount() = parts.list.rowCount()
internal fun rowText(index: Int) = parts.list.rowText(index)
@@ -89,6 +103,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
hiddenBefore = data.before,
hiddenAfter = data.after,
)
footer.update(item, approvalReasonsVisible())
syncExpandable(true)
refresh()
}
@@ -20,7 +20,8 @@ abstract class BaseSearchToolView(
private val selection: SessionSelection? = null,
private val parts: ToolParts,
private val repo: String? = null,
) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }) {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }, { footer }), ApprovalReasonTarget {
override val contentId: String = tool.id
@@ -52,7 +53,7 @@ abstract class BaseSearchToolView(
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight()
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() + footerHeight()
return Dimension(size.width, minOf(size.height, height))
}
@@ -62,6 +63,7 @@ abstract class BaseSearchToolView(
item = content
var changed = sync()
changed = syncBody() || changed
changed = syncApprovalReason(approvalReasonsVisible()) || changed
if (changed) refresh()
}
@@ -124,9 +126,17 @@ abstract class BaseSearchToolView(
parts.targets.forEach { changed = setFont(it, style.regularFont) || changed }
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = applyBodyStyle() || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
private fun sync(): Boolean {
val expand = canExpand(item)
var changed = false
@@ -145,6 +155,7 @@ abstract class BaseSearchToolView(
body.foreground = bodyColor()
changed = true
}
changed = footer.update(item, approvalReasonsVisible()) || changed
return changed
}
@@ -46,7 +46,8 @@ class EditToolView(
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool, openFile),
private var body: EditBody = editBody(tool, selection, openFile),
) : AbstractSessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { body.mount(tool) }, { footer }), UiDataProvider, SessionCopyTarget, ApprovalReasonTarget {
override val contentId: String = tool.id
@@ -126,7 +127,7 @@ class EditToolView(
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + expandedGap() + (body.panel()?.preferredSize?.height ?: 0)
val height = row.preferredSize.height + expandedGap() + (body.panel()?.preferredSize?.height ?: 0) + footerHeight()
return Dimension(size.width, minOf(size.height, height))
}
@@ -138,6 +139,7 @@ class EditToolView(
changed = swapBody() || changed
changed = sync() || changed
changed = syncBody() || changed
changed = syncApprovalReason(approvalReasonsVisible()) || changed
if (changed) refresh()
}
@@ -207,9 +209,17 @@ class EditToolView(
changed = setFont(parts.link, style.transcriptFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = body.applyStyle(style) || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
private fun expandable(): Boolean =
editDiff(item).isNotBlank() || output(item).isNotBlank() || !item.error.isNullOrBlank()
@@ -232,6 +242,7 @@ class EditToolView(
syncDiffAction(count)
changed = syncFilesTag(count) || changed
changed = syncBadge() || changed
changed = footer.update(item, approvalReasonsVisible()) || changed
return changed
}
@@ -4,6 +4,7 @@ import ai.kilocode.client.diff.DiffLineNumbers
import ai.kilocode.client.diff.installDiffGutter
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -18,6 +19,7 @@ import ai.kilocode.client.ui.md.MdViewFactory
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
@@ -62,6 +64,7 @@ class PatchBody(
private val selection: SessionSelection?,
private val openFile: SessionFileOpener,
private val opts: MdCodeBlockOptions = DIFF_OPTS,
private val linkFiles: Boolean = true,
) : EditBody {
override var parent: Disposable? = null
override var overflow: (() -> Unit)? = null
@@ -69,7 +72,7 @@ class PatchBody(
private var root: Stack? = null
private var owner: Disposable? = null
private val views = mutableListOf<MdView>()
private val links = mutableListOf<FileLinkLabel>()
private val links = mutableListOf<JBLabel>()
private var style = SessionEditorStyle.current()
private var signature = ""
private val rows = mutableListOf<List<DiffLineNumbers.Row>>()
@@ -157,10 +160,14 @@ class PatchBody(
// (gutter reinit) and freezes; defer to the diff tab, which streams diffs off the EDT.
panel.next(diffOverflowPanel(open))
} else {
files.filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
val patched = files.filter { it.patch.isNotBlank() }
val named = patched.size > 1
patched.forEachIndexed { index, file ->
if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))
panel.next(header(file))
panel.gap(UiStyle.Gap.sm())
if (named) {
panel.next(header(file))
panel.gap(UiStyle.Gap.sm())
}
val md = MdViewFactory.create(style, selection, MdCodeBlockFactory.default(opts))
Disposer.register(disposable, md)
applyMd(md)
@@ -182,16 +189,21 @@ class PatchBody(
@RequiresEdt
private fun header(file: EditFileChange): JComponent {
val link = FileLinkLabel(openFile).apply {
val label = if (linkFiles) FileLinkLabel(openFile).apply {
foreground = SessionUiStyle.Colors.foreground()
font = style.transcriptFont
setTarget(file.path, tail(file.path))
isVisible = true
} else JBLabel(tail(file.path)).apply {
foreground = SessionUiStyle.Colors.foreground()
font = style.transcriptFont
}
links.add(link)
links.add(label)
// Indent only the filename row; the diff content below stays flush to the card edge.
val row = Stack.horizontal(UiStyle.Gap.sm())
.next(link)
.next(label)
.next(DiffStatBadge(file.additions, file.deletions))
row.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.contentIndent())
return JBUI.Panels.simplePanel(row).apply {
isOpaque = false
}
@@ -218,8 +230,27 @@ class PatchBody(
?: emptyList()).forEach { installDiffGutter(it, rows) }
}
private companion object {
val DIFF_OPTS = MdCodeBlockOptions(
companion object {
@RequiresEdt
internal fun popup(
selection: SessionSelection?,
openFile: SessionFileOpener,
files: List<EditFileChange>,
style: SessionEditorStyle,
linkFiles: Boolean,
overflow: () -> Unit,
): HeaderPopupBody {
val owner = Disposer.newDisposable("Patch popup body")
val body = PatchBody(selection, openFile, POPUP_OPTS, linkFiles).also {
it.parent = owner
it.overflow = overflow
}
val panel = body.mountFiles(files)
body.applyStyle(style)
return HeaderPopupBody(panel, owner, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
}
private val DIFF_OPTS = MdCodeBlockOptions(
border = MdCodeBlockBorder.None,
maxLines = SessionUiStyle.View.Tool.DIFF_LINES,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
@@ -36,7 +36,8 @@ class ShellToolView(
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
private val body: ShellBody = ShellBody(selection),
) : AbstractSessionPartView(parts.header, { body.mount(tool) }), UiDataProvider {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { body.mount(tool) }, { footer }), UiDataProvider, ApprovalReasonTarget {
override val contentId: String = tool.id
@@ -73,6 +74,7 @@ class ShellToolView(
if (was != content.name || !canExpand(content)) changed = collapse() || changed
changed = sync() || changed
changed = syncBody() || changed
changed = syncApprovalReason(approvalReasonsVisible()) || changed
if (changed) refresh()
}
@@ -157,9 +159,17 @@ class ShellToolView(
changed = setFont(parts.link, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = body.applyStyle(style) || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
private fun sync(): Boolean {
val expand = canExpand(item)
var changed = false
@@ -173,6 +183,7 @@ class ShellToolView(
changed = setForeground(parts.sub, SessionUiStyle.Text.Secondary.foreground()) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
changed = footer.update(item, approvalReasonsVisible()) || changed
return changed
}
@@ -32,7 +32,8 @@ class TaskToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }, { footer }), UiDataProvider, ApprovalReasonTarget {
override val contentId: String = tool.id
@@ -60,6 +61,7 @@ class TaskToolView(
val follow = tailVisible()
var changed = sync()
changed = syncRows() || changed
changed = syncApprovalReason(approvalReasonsVisible()) || changed
if (content.childTools.isNotEmpty() && !collapsed) changed = expand() || changed
followTail(follow || fresh)
if (changed) refresh()
@@ -98,6 +100,7 @@ class TaskToolView(
changed = setFont(parts.sub, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
for (row in rows.values) changed = row.applyStyle(style) || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@@ -105,10 +108,17 @@ class TaskToolView(
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight()
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() + footerHeight()
return Dimension(size.width, minOf(size.height, height))
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
private fun sync(): Boolean {
var changed = false
changed = syncExpandable(item.childTools.isNotEmpty()) || changed
@@ -120,6 +130,7 @@ class TaskToolView(
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
changed = footer.update(item, approvalReasonsVisible()) || changed
return changed
}
@@ -0,0 +1,51 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.style.SessionEditorStyle
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.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
interface ApprovalReasonTarget {
@RequiresEdt
fun syncApprovalReason(visible: Boolean): Boolean
}
class ToolApprovalFooter : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.sm()) {
private val glyph = JBLabel(SessionViewIcons.shield)
private val label = JBLabel()
init {
next(glyph)
next(label)
isVisible = false
}
@RequiresEdt
fun update(tool: Tool, visible: Boolean): Boolean {
val note = if (visible) describeToolApproval(tool.approval) else null
var changed = false
changed = setVisible(this, note != null) || changed
changed = setVisible(glyph, note != null) || changed
changed = setVisible(label, note != null) || changed
changed = setText(label, note?.text.orEmpty()) || changed
return changed
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle): Boolean {
var changed = false
changed = setFont(label, style.smallEditorFont) || changed
changed = setFont(glyph, style.smallEditorFont) || changed
changed = setForeground(label, SessionUiStyle.Text.Secondary.foreground()) || changed
changed = setForeground(glyph, SessionUiStyle.Text.Secondary.foreground()) || changed
return changed
}
}
internal fun approvalReasonsVisible() = KiloPluginSettings.getShowApprovalReason()
@@ -0,0 +1,52 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.ToolApproval
data class ToolApprovalNote(
val decision: String,
val details: String,
) {
val text: String get() = listOf(decision, details).filter { it.isNotBlank() }.joinToString(" ")
}
fun describeToolApproval(approval: ToolApproval?): ToolApprovalNote? {
if (approval == null) return null
val manual = approval.source == "manual"
val decision = if (manual) {
KiloBundle.message("session.part.tool.approval.manual")
} else {
KiloBundle.message("session.part.tool.approval.auto")
}
val parts = buildList {
if (!manual) source(approval)?.let(::add)
rule(approval)?.let(::add)
outside(approval)?.let(::add)
}
return ToolApprovalNote(decision, parts.joinToString(" "))
}
private fun source(approval: ToolApproval): String? = when (approval.source) {
"agent" -> approval.agent
?.let { KiloBundle.message("session.part.tool.approval.source.agent", it) }
?: KiloBundle.message("session.part.tool.approval.source.agent.default")
"global" -> KiloBundle.message("session.part.tool.approval.source.global")
"project" -> KiloBundle.message("session.part.tool.approval.source.project")
"yolo" -> KiloBundle.message("session.part.tool.approval.source.yolo")
"session" -> KiloBundle.message("session.part.tool.approval.source.session")
"default" -> KiloBundle.message("session.part.tool.approval.source.default")
else -> null
}
private fun rule(approval: ToolApproval): String? {
val permission = approval.rulePermission ?: return null
val pattern = approval.rulePattern ?: return null
if (permission == "*" && pattern == "*") return null
return KiloBundle.message("session.part.tool.approval.rule", permission, pattern)
}
private fun outside(approval: ToolApproval): String? {
if (!approval.outsideWorkspace) return null
val path = approval.outsideWorkspacePath?.takeIf { it.isNotBlank() } ?: return null
return KiloBundle.message("session.part.tool.approval.outsideWorkspace", tail(path))
}
@@ -22,7 +22,8 @@ class ToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR),
) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider {
private val footer: ToolApprovalFooter = ToolApprovalFooter(),
) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }, { footer }), UiDataProvider, ApprovalReasonTarget {
override val contentId: String = tool.id
@@ -55,7 +56,7 @@ class ToolView(
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight()
val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() + footerHeight()
return Dimension(size.width, minOf(size.height, height))
}
@@ -68,6 +69,7 @@ class ToolView(
if (was != content.name || !canExpand(content)) changed = collapse() || changed
changed = sync() || changed
changed = syncBody() || changed
changed = syncApprovalReason(approvalReasonsVisible()) || changed
if (changed) refresh()
}
@@ -132,9 +134,17 @@ class ToolView(
changed = setFont(parts.link, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = applyBodyStyle() || changed
changed = footer.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun syncApprovalReason(visible: Boolean): Boolean {
val changed = footer.update(item, visible)
if (changed) refresh()
return changed
}
private fun sync(): Boolean {
val expand = canExpand(item)
var changed = false
@@ -146,6 +156,7 @@ class ToolView(
body.foreground = bodyColor()
changed = true
}
changed = footer.update(item, approvalReasonsVisible()) || changed
return changed
}
@@ -1,7 +1,12 @@
package ai.kilocode.client.settings.autoapprove
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.settings.base.BaseContentPanel
import ai.kilocode.client.settings.base.SettingsRow
import ai.kilocode.client.settings.base.SettingsToggle
import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener
import com.intellij.openapi.application.ApplicationManager
import com.intellij.util.concurrency.annotations.RequiresEdt
private enum class ExceptionKind { PATH, COMMAND }
@@ -39,6 +44,16 @@ internal class AutoApproveContent(
init {
granular.forEach { (_, section) -> next(section) }
section(KiloBundle.message("settings.autoApprove.title")).row(tools)
next(SettingsRow(
KiloBundle.message("settings.autoApprove.showReason.title"),
KiloBundle.message("settings.autoApprove.showReason.description"),
SettingsToggle(KiloPluginSettings.getShowApprovalReason()) { visible ->
KiloPluginSettings.setShowApprovalReason(visible)
ApplicationManager.getApplication().messageBus
.syncPublisher(ApprovalReasonVisibilityListener.TOPIC)
.changed(visible)
},
))
}
@RequiresEdt
@@ -0,0 +1,5 @@
package ai.kilocode.client.ui
internal interface DiffBadge {
fun update(additions: Int, deletions: Int)
}
@@ -11,7 +11,7 @@ import javax.swing.JPanel
internal class DiffBars(
additions: Int,
deletions: Int,
) : JPanel() {
) : JPanel(), DiffBadge {
private var additions = additions
private var deletions = deletions
@@ -19,7 +19,7 @@ internal class DiffBars(
isOpaque = false
}
fun update(additions: Int, deletions: Int) {
override fun update(additions: Int, deletions: Int) {
if (this.additions == additions && this.deletions == deletions) return
this.additions = additions
this.deletions = deletions
@@ -20,7 +20,7 @@ internal class DiffStatBadge(
private val inset: Int = 0,
// When false the badge paints only its text, without the rounded background pill or padding.
private val fill: Boolean = true,
) : JPanel(GridBagLayout()) {
) : JPanel(GridBagLayout()), DiffBadge {
constructor(additions: Int, deletions: Int) : this(additions, deletions, Variant.REGULAR, 0)
internal enum class Variant {
@@ -68,7 +68,7 @@ internal class DiffStatBadge(
return Dimension(dim.width, variant.height())
}
fun update(additions: Int, deletions: Int) {
override fun update(additions: Int, deletions: Int) {
removed.isVisible = deletions > 0
added.isVisible = additions > 0
if (removed.isVisible) removed.text = "-$deletions"
@@ -164,6 +164,17 @@ session.part.tool.copy=Copy
session.part.tool.openDiff=Open in Diff Viewer
session.part.tool.error=Error
session.part.tool.agent={0} Agent
session.part.tool.approval.auto=Auto-approved
session.part.tool.approval.manual=Approved by you
session.part.tool.approval.source.agent=by the {0} agent
session.part.tool.approval.source.agent.default=by the agent
session.part.tool.approval.source.global=by your global config
session.part.tool.approval.source.project=by the project config
session.part.tool.approval.source.yolo=by auto-approve (YOLO) mode
session.part.tool.approval.source.session=by a session auto-approve rule
session.part.tool.approval.source.default=by default
session.part.tool.approval.rule=matched `{0}` rule `{1}`
session.part.tool.approval.outsideWorkspace=(outside your workspace: {0})
session.part.tool.pending=Pending
session.part.tool.read=Read
session.part.tool.edit=Edit
@@ -474,6 +485,8 @@ settings.context.watcher.input.prompt=Enter a glob pattern to ignore:
settings.autoApprove.displayName=Auto-Approve
settings.autoApprove.title=Auto-Approve
settings.autoApprove.description=Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask.
settings.autoApprove.showReason.title=Show approval reason on tool cards
settings.autoApprove.showReason.description=Show a footer on tool calls explaining why they were allowed, such as a matched rule, agent default, or auto-approve mode.
settings.autoApprove.default=Default ({0})
settings.autoApprove.level.allow=Allow
settings.autoApprove.level.ask=Ask
@@ -99,6 +99,17 @@ class ModifiedFilesViewTest : BasePlatformTestCase() {
assertNull(view.headerPopup())
}
fun `test single file body omits filename header`() {
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
view.toggle()
assertTrue(view.bodyCreated())
assertEquals(1, diffScrolls(view).size)
val links = components(view).filterIsInstance<JBLabel>().filter { it.text?.contains("<u>") == true }
assertTrue("single-file changes should not render a file header", links.isEmpty())
}
fun `test open in diff uses changed files title`() {
val titles = mutableListOf<String>()
view.setDiffOpener({ _, title, _ -> titles.add(title) }, "ses", "turn")
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolApproval
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -15,6 +16,7 @@ import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Container
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
@@ -144,6 +146,20 @@ class ToolViewTest : BasePlatformTestCase() {
assertFalse(view.isExpanded())
}
fun `test expanded tool shows approval footer`() {
val t = tool("p1", "bash", ToolExecState.COMPLETED).also {
it.input = mapOf("command" to "pwd")
it.output = "/tmp"
it.approval = ToolApproval(source = "global")
}
val view = track(ToolView(t))
view.toggle()
view.syncApprovalReason(true)
assertTrue(texts(view).any { it.contains("Auto-approved by your global config") })
}
fun `test collapsed bash hides body`() {
val t = tool("p1", "bash", ToolExecState.COMPLETED).also {
it.input = mapOf("command" to "git log")
@@ -464,4 +480,10 @@ class ToolViewTest : BasePlatformTestCase() {
return (header.layout as BorderLayout).hgap
}
private fun texts(root: Container): List<String> = root.components.flatMap { child ->
val own = (child as? javax.swing.JLabel)?.text?.let(::listOf) ?: emptyList()
val nested = (child as? Container)?.let(::texts) ?: emptyList()
own + nested
}
}
@@ -7,10 +7,14 @@ import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.model.PermissionRuleCandidate
import ai.kilocode.client.session.model.PermissionRuleDecision
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.views.base.DialogView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.tool.FileLinkLabel
import ai.kilocode.client.ui.md.MdCommon
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
@@ -335,7 +339,9 @@ class PermissionViewTest : BasePlatformTestCase() {
assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("test/*.kt"))
}
fun `test diff preview renders only stat badge without duplicate file path`() {
fun `test diff preview renders collapsed then expands inline patch`() {
val opens = mutableListOf<Triple<List<DiffFileDto>, String, String>>()
view.setDiffOpener({ files, title, key -> opens.add(Triple(files, title, key)) }, "ses")
view.show(
Permission(
id = "perm6",
@@ -370,6 +376,112 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals("-2", badge.removedLabelForTest().text)
assertEquals("+1", badge.addedLabelForTest().text)
assertNotSame("Removed and added labels should use different colors", badge.removedLabelForTest().foreground, badge.addedLabelForTest().foreground)
assertFalse(diffs.single().bodyCreated())
diffs.single().expand()
val expanded = diffs.single().codeEditorsForTest().single().text
assertTrue("Should render old line after expansion, got: $expanded", expanded.contains("old"))
assertTrue("Should render new line after expansion, got: $expanded", expanded.contains("new"))
assertFalse("Should strip hunk marker in editor preview, got: $expanded", expanded.contains("@@"))
assertFalse("Single-file permission diffs should not render an extra filename", findAll<JBLabel>(diffs.single()).any { it.text == "A.kt" })
assertTrue("Permission diffs should not render proposed filenames as links", findAll<FileLinkLabel>(diffs.single()).isEmpty())
assertTrue(diffs.single().bodyCreated())
diffs.single().openDiffForTest()
assertEquals(1, opens.size)
assertEquals("permission:ses:perm6", opens.single().third)
assertEquals("src/A.kt", opens.single().first.single().file)
assertEquals("@@ -1 +1 @@\n-old\n+new", opens.single().first.single().patch)
}
fun `test diff view exposes open diff toolbar target`() {
view.show(
Permission(
id = "perm_toolbar",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = "@@ -1 +1 @@\n-old\n+new",
additions = 1,
deletions = 1,
),
),
),
),
)
val diff = view.diffViewsForTest().single()
assertTrue(diff is SessionCopyTarget)
val target = diff as SessionCopyTarget
assertTrue(target.copyEligible)
assertSame(diff.openDiffButtonForTest(), target.copyToolbar)
assertSame(diff.openDiffAnchorForTest(), target.copyAnchor)
assertNull(target.copyText())
}
fun `test diff view does not expose toolbar without openable content`() {
view.show(
Permission(
id = "perm_toolbar_empty",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = null,
additions = 1,
deletions = 0,
),
),
),
),
)
assertFalse((view.diffViewsForTest().single() as SessionCopyTarget).copyEligible)
}
fun `test diff hover sink drives popup`() {
val events = mutableListOf<Pair<PartView, Boolean>>()
view.setHoverSink { part, value -> events.add(part to value) }
view.show(
Permission(
id = "perm_hover",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = "@@ -1 +1 @@\n-old\n+new",
additions = 1,
deletions = 1,
),
),
),
),
)
val diff = view.diffViewsForTest().single()
assertNotNull(diff.headerPopup())
diff.setHovered(true)
diff.setHovered(false)
assertEquals(listOf(diff to true, diff to false), events)
diff.expand()
assertNull(diff.headerPopup())
}
fun `test diff preview shows no unavailable fallback text`() {
@@ -401,9 +513,10 @@ class PermissionViewTest : BasePlatformTestCase() {
val badge = view.diffViewsForTest().single().badgeForTest()
assertEquals("-1", badge.removedLabelForTest().text)
assertEquals("+3", badge.addedLabelForTest().text)
assertFalse(view.diffViewsForTest().single().openDiffEnabledForTest())
}
fun `test multiple diffs render each file separately`() {
fun `test multiple diffs render as one expandable group`() {
view.show(
Permission(
id = "perm_multi_diff",
@@ -431,14 +544,28 @@ class PermissionViewTest : BasePlatformTestCase() {
)
val diffs = view.diffViewsForTest()
assertEquals("Expected two diff views", 2, diffs.size)
assertEquals("-1", diffs[0].badgeForTest().removedLabelForTest().text)
assertEquals("+1", diffs[0].badgeForTest().addedLabelForTest().text)
assertEquals("-3", diffs[1].badgeForTest().removedLabelForTest().text)
assertEquals("+2", diffs[1].badgeForTest().addedLabelForTest().text)
assertEquals("Expected one grouped diff view", 1, diffs.size)
assertEquals("-4", diffs.single().badgeForTest().removedLabelForTest().text)
assertEquals("+3", diffs.single().badgeForTest().addedLabelForTest().text)
assertEquals("2 files", diffs.single().countTextForTest())
// Patch content should not be in text
val text = allText(view)
assertFalse("Should not render patch markers, got: $text", text.contains("@@"))
diffs.single().expand()
val editors = diffs.single().codeEditorsForTest()
assertEquals(2, editors.size)
val expanded = editors.joinToString("\n") { it.text }
assertTrue("Should render first file diff after expansion, got: $expanded", expanded.contains("a"))
assertTrue("Should render first file diff after expansion, got: $expanded", expanded.contains("b"))
assertTrue("Should render second file diff after expansion, got: $expanded", expanded.contains("c"))
assertTrue("Should render second file diff after expansion, got: $expanded", expanded.contains("d"))
assertFalse("Should strip hunk markers in editor preview, got: $expanded", expanded.contains("@@"))
val labels = findAll<JBLabel>(diffs.single()).map { it.text }
assertTrue("Multi-file permission diffs should render filenames as plain text", labels.contains("A.kt"))
assertTrue("Multi-file permission diffs should render filenames as plain text", labels.contains("B.kt"))
assertTrue("Permission diffs should not render filenames as links", findAll<FileLinkLabel>(diffs.single()).isEmpty())
}
fun `test rule controls render collapsed when candidates exist`() {
@@ -987,6 +1114,72 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
fun `test diff editors are lazy and disposed after churn`() {
val base = EditorFactory.getInstance().allEditors.size
repeat(20) { i ->
view.show(
Permission(
id = "perm_diff_$i",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = "@@ -1 +1 @@\n-old$i\n+new$i",
additions = 1,
deletions = 1,
),
),
),
),
)
val diff = view.diffViewsForTest().single()
assertFalse(diff.bodyCreated())
diff.expand()
diff.codeEditorsForTest().forEach { it.getEditor(true) }
view.hideView()
UIUtil.dispatchAllInvocationEvents()
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
}
fun `test diff view is retained and keeps expansion across same-request re-render`() {
fun request(state: PermissionRequestState) = Permission(
id = "perm_retain_diff",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = "@@ -1 +1 @@\n-old\n+new",
additions = 1,
deletions = 1,
),
),
),
state = state,
)
view.show(request(PermissionRequestState.PENDING))
val diff = view.diffViewsForTest().single()
diff.expand()
assertTrue(diff.isExpanded())
// The RESPONDING tick re-renders the same request; the card must survive so the
// expanded inline preview is not torn down.
view.show(request(PermissionRequestState.RESPONDING))
assertSame(diff, view.diffViewsForTest().single())
assertTrue("Expanded preview should persist across re-render", diff.isExpanded())
}
fun `test stale rule command fields are released on rebuild`() {
val base = EditorFactory.getInstance().allEditors.size
view.show(permissionWithRules("perm_rules_rebuild", listOf("git status *")))
@@ -0,0 +1,51 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.session.model.ToolApproval
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class ToolApprovalTextTest : BasePlatformTestCase() {
fun `test global approval includes source and rule`() {
val note = describeToolApproval(ToolApproval(
source = "global",
rulePermission = "bash",
rulePattern = "pwd",
ruleAction = "allow",
))
assertEquals("Auto-approved by your global config matched `bash` rule `pwd`", note?.text)
}
fun `test manual approval uses manual decision`() {
val note = describeToolApproval(ToolApproval(source = "manual"))
assertEquals("Approved by you", note?.text)
}
fun `test agent approval includes agent name`() {
val note = describeToolApproval(ToolApproval(source = "agent", agent = "build"))
assertEquals("Auto-approved by the build agent", note?.text)
}
fun `test catch all rule is hidden`() {
val note = describeToolApproval(ToolApproval(
source = "session",
rulePermission = "*",
rulePattern = "*",
ruleAction = "allow",
))
assertEquals("Auto-approved by a session auto-approve rule", note?.text)
}
fun `test outside workspace shows path tail`() {
val note = describeToolApproval(ToolApproval(
source = "project",
outsideWorkspace = true,
outsideWorkspacePath = "/tmp/outside",
))
assertEquals("Auto-approved by the project config (outside your workspace: outside)", note?.text)
}
}
@@ -72,6 +72,7 @@ data class PartDto(
val title: String? = null,
val input: Map<String, String> = emptyMap(),
val metadata: Map<String, String> = emptyMap(),
val approval: ToolApprovalDto? = null,
val output: String? = null,
val error: String? = null,
val time: PartTimeDto? = null,
@@ -87,6 +88,17 @@ data class PartDto(
val source: PartSourceDto? = null,
)
@Serializable
data class ToolApprovalDto(
val source: String,
val agent: String? = null,
val rulePermission: String? = null,
val rulePattern: String? = null,
val ruleAction: String? = null,
val outsideWorkspace: Boolean = false,
val outsideWorkspacePath: String? = null,
)
@Serializable
data class PartSourceDto(
val type: String,