mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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.
|
||||
+22
@@ -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))
|
||||
|
||||
+39
@@ -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("""
|
||||
|
||||
+11
@@ -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) {
|
||||
|
||||
+9
@@ -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) {
|
||||
|
||||
+11
@@ -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)
|
||||
|
||||
|
||||
+13
@@ -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}" }
|
||||
|
||||
|
||||
+15
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+166
@@ -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(),
|
||||
)
|
||||
+31
-146
@@ -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)
|
||||
}
|
||||
|
||||
+11
@@ -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)
|
||||
|
||||
+7
@@ -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].
|
||||
|
||||
+11
@@ -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) {
|
||||
|
||||
+1
@@ -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")
|
||||
|
||||
+27
-3
@@ -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 {
|
||||
|
||||
+86
-28
@@ -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
|
||||
|
||||
+187
-145
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -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 ->
|
||||
|
||||
+17
-2
@@ -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()
|
||||
}
|
||||
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+40
-9
@@ -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,
|
||||
|
||||
+12
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+51
@@ -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()
|
||||
+52
@@ -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))
|
||||
}
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
@@ -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")
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+200
-7
@@ -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 *")))
|
||||
|
||||
+51
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user