From 663548da399413fba88d5943403a268debad43e7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 19 May 2026 12:12:41 -0400 Subject: [PATCH 01/21] feat(kilo-jetbrains): add rich permission views and runtime auto-approve - Extend PermissionRequestDto with command, filePath, rules, fileDiffs fields - Parse rich permission metadata (command, diff previews, file diffs) in KiloCliDataParser - Rewrite PermissionView as plain Swing with command/pattern/diff display; remove Kotlin UI DSL - Add KiloAutoApproveService: client-side toggle persisted via PropertiesComponent - Integrate auto-approve into SessionController (live events and recovery) - Add shield toggle button to PromptPanel; wire auto-approve flow through SessionUi - Add tests for parser metadata extraction, view rendering, controller auto-approve, and recovery --- .../kilocode/backend/cli/KiloCliDataParser.kt | 96 ++++++- .../backend/cli/KiloCliDataParserTest.kt | 142 ++++++++++ .../client/app/KiloAutoApproveService.kt | 39 +++ .../ai/kilocode/client/session/SessionUi.kt | 19 +- .../session/controller/SessionController.kt | 85 +++++- .../client/session/model/Permission.kt | 2 + .../client/session/ui/prompt/PromptPanel.kt | 30 ++ .../client/session/views/PermissionView.kt | 267 ++++++++++++++++-- .../src/main/resources/icons/shield.svg | 3 + .../src/main/resources/icons/shield_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 33 ++- .../session/controller/PromptLifecycleTest.kt | 58 ++++ .../controller/SessionControllerTestBase.kt | 6 +- .../session/controller/SessionRecoveryTest.kt | 26 ++ .../client/session/ui/PromptPanelTest.kt | 48 ++++ .../session/views/PermissionViewTest.kt | 175 +++++++++++- .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 15 + 17 files changed, 999 insertions(+), 48 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 9e097feae5c..943af6eb45f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PartTimeDto @@ -453,11 +454,27 @@ object KiloCliDataParser { val permission = obj.str("permission") ?: return null val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() - val meta = obj["metadata"]?.jsonObject?.let { m -> - m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") } - } ?: emptyMap() - val ref = toolRef(obj) - return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref) + val metaObj = obj["metadata"].obj() + val meta = metaObj?.entries?.mapNotNull { (key, value) -> + val text = value.scalar() ?: return@mapNotNull null + key to text + }?.toMap() ?: emptyMap() + val path = metaObj.path() + val diffs = metaObj.permissionDiffs(path) + return PermissionRequestDto( + id = id, + sessionID = sid, + permission = permission, + patterns = patterns, + metadata = meta, + always = always, + tool = toolRef(obj), + message = obj.str("message") ?: metaObj?.str("message"), + command = metaObj?.str("command") ?: obj.str("command"), + rules = metaObj.rules(), + filePath = path, + fileDiffs = diffs, + ) } internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? { @@ -601,6 +618,11 @@ object KiloCliDataParser { /** * Build the JSON body for `POST /permission/{requestID}/reply`. */ + internal fun parseRulesJson(text: String): List { + val arr = runCatching { json.parseToJsonElement(text).jsonArray }.getOrNull() ?: return listOf(text) + return arr.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } + } + fun buildPermissionReplyJson(reply: PermissionReplyDto): String { val sb = StringBuilder() sb.append("""{"reply":${escape(reply.reply)}""") @@ -669,6 +691,70 @@ object KiloCliDataParser { } } +// Permission metadata helpers + +private fun JsonElement?.obj(): JsonObject? = runCatching { this?.jsonObject }.getOrNull() +private fun JsonElement?.arr(): JsonArray? = runCatching { this?.jsonArray }.getOrNull() + +private fun JsonObject?.path(): String? { + if (this == null) return null + return str("filepath") ?: str("filePath") ?: str("file") ?: str("path") +} + +private fun JsonObject?.rules(): List { + if (this == null) return emptyList() + val raw = this["rules"] ?: return emptyList() + val arr = raw.arr() + if (arr != null) { + return arr.mapNotNull { it.jsonPrimitive.contentOrNull } + } + val text = runCatching { raw.jsonPrimitive.contentOrNull }.getOrNull() ?: return emptyList() + if (text.startsWith("[")) { + return runCatching { + KiloCliDataParser.parseRulesJson(text) + }.getOrElse { listOf(text) } + } + return listOf(text) +} + +private fun JsonObject?.permissionDiffs(path: String?): List { + if (this == null) return emptyList() + val filediff = this["filediff"].obj() + if (filediff != null) { + val file = filediff.str("file") ?: filediff.str("relativePath") ?: path ?: return emptyList() + return listOf( + PermissionFileDiffDto( + file = file, + patch = filediff.str("patch"), + before = filediff.str("before"), + after = filediff.str("after"), + additions = filediff.long("additions")?.safeInt() ?: 0, + deletions = filediff.long("deletions")?.safeInt() ?: 0, + ) + ) + } + val files = this["files"].arr() + if (files != null) { + return files.mapNotNull { elem -> + val item = elem.obj() ?: return@mapNotNull null + val file = item.str("relativePath") ?: item.str("filePath") ?: item.str("file") ?: return@mapNotNull null + PermissionFileDiffDto( + file = file, + patch = item.str("patch"), + before = item.str("before"), + after = item.str("after"), + additions = item.long("additions")?.safeInt() ?: 0, + deletions = item.long("deletions")?.safeInt() ?: 0, + ) + } + } + val diff = str("diff") + if (diff != null) { + return listOf(PermissionFileDiffDto(file = path ?: "patch", patch = diff)) + } + return emptyList() +} + // JsonObject convenience extensions private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 6b1f3c980fe..d10ab08f816 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1140,6 +1140,148 @@ class KiloCliDataParserTest { assertEquals("q1", result[0].id) } + // ================================================================ + // parsePermissionRequest — rich metadata + // ================================================================ + + @Test + fun `parsePermissionRequest - command metadata extracted`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_cmd", + "sessionID": "ses_1", + "permission": "bash", + "patterns": [], + "always": [], + "metadata": {"command": "git status --short"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals("git status --short", asked.request.command) + assertEquals("git status --short", asked.request.metadata["command"]) + } + + @Test + fun `parsePermissionRequest - diff and filepath fallback`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_diff", + "sessionID": "ses_1", + "permission": "edit", + "patterns": [], + "always": [], + "metadata": {"filepath": "src/App.kt", "diff": "@@ -1 +1 @@"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals("src/App.kt", asked.request.filePath) + assertEquals(1, asked.request.fileDiffs.size) + assertEquals("src/App.kt", asked.request.fileDiffs[0].file) + assertEquals("@@ -1 +1 @@", asked.request.fileDiffs[0].patch) + } + + @Test + fun `parsePermissionRequest - filediff object`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_filediff", + "sessionID": "ses_1", + "permission": "edit", + "patterns": [], + "always": [], + "metadata": { + "filediff": { + "file": "src/A.kt", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 1 + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals(1, asked.request.fileDiffs.size) + assertEquals("src/A.kt", asked.request.fileDiffs[0].file) + assertEquals("@@ -1 +1 @@", asked.request.fileDiffs[0].patch) + assertEquals(1, asked.request.fileDiffs[0].additions) + assertEquals(1, asked.request.fileDiffs[0].deletions) + } + + @Test + fun `parsePermissionRequest - files array`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_files", + "sessionID": "ses_1", + "permission": "edit", + "patterns": [], + "always": [], + "metadata": { + "files": [ + {"relativePath": "src/A.kt", "patch": "@@", "additions": 2, "deletions": 0}, + {"filePath": "src/B.kt", "patch": "@@", "additions": 0, "deletions": 3} + ] + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals(2, asked.request.fileDiffs.size) + assertEquals("src/A.kt", asked.request.fileDiffs[0].file) + assertEquals(2, asked.request.fileDiffs[0].additions) + assertEquals("src/B.kt", asked.request.fileDiffs[1].file) + assertEquals(3, asked.request.fileDiffs[1].deletions) + } + + @Test + fun `parsePermissionRequest - malformed files metadata returns empty diffs`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_bad", + "sessionID": "ses_1", + "permission": "edit", + "patterns": [], + "always": [], + "metadata": {"files": "not-an-array"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertTrue(asked.request.fileDiffs.isEmpty()) + } + + @Test + fun `parsePermissionRequest - old json without new fields uses defaults`() { + val raw = """[ + {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} + ]""" + val result = KiloCliDataParser.parsePermissionRequests(raw) + assertEquals(1, result.size) + assertNull(result[0].command) + assertTrue(result[0].rules.isEmpty()) + assertTrue(result[0].fileDiffs.isEmpty()) + assertNull(result[0].filePath) + assertNull(result[0].message) + } + // ================================================================ // Helpers // ================================================================ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt new file mode 100644 index 00000000000..21e38ce7b8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.app + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.components.Service +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Application-level service tracking the runtime auto-approve toggle. + * + * This is a **client-side** toggle only. It does NOT write CLI config or + * create persistent permission rules. While enabled, each permission request + * is automatically replied with `"once"`. + */ +@Service(Service.Level.APP) +class KiloAutoApproveService { + companion object { + internal const val KEY = "kilo.permission.autoApprove.enabled" + } + + private val props = PropertiesComponent.getInstance() + private val state = MutableStateFlow(props.getBoolean(KEY, false)) + val enabled: StateFlow = state.asStateFlow() + + fun active(): Boolean = state.value + + fun set(value: Boolean) { + if (state.value == value) return + props.setValue(KEY, value.toString()) + state.value = value + } + + fun toggle(): Boolean { + val next = !state.value + set(next) + return next + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 612032ca90d..b225f2d6565 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloAutoApproveService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent @@ -27,12 +28,15 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.awt.BorderLayout import javax.swing.BoxLayout import javax.swing.JComponent @@ -49,7 +53,7 @@ class SessionUi( workspace: Workspace, sessions: KiloSessionService, app: KiloAppService, - cs: CoroutineScope, + private val cs: CoroutineScope, ref: SessionRef? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, private val manager: SessionManager? = null, @@ -61,6 +65,7 @@ class SessionUi( private val project = project private val app = app + private val auto = service() private var opening = ref != null private var pending = false private var loaded: Boolean? = null @@ -79,6 +84,7 @@ class SessionUi( beforeUpdate = { if (opening) false else scroll.atBottom() }, afterUpdate = { if (!opening) scroll.followBottom(it) }, loaded = ::onSessionLoaded, + auto = auto, ) @@ -163,6 +169,8 @@ class SessionUi( project = project, onSend = { text -> sendPrompt(text) }, onAbort = { controller.abort() }, + autoApprove = { auto.active() }, + onAutoApproveToggle = { controller.toggleAutoApprove() }, ) sessionContent.add(header, BorderLayout.NORTH) @@ -260,6 +268,15 @@ class SessionUi( is SessionModelEvent.Cleared -> Unit } } + + prompt.setAutoApprove(auto.active()) + cs.launch { + auto.enabled.collect { value -> + ApplicationManager.getApplication().invokeLater { + if (!Disposer.isDisposed(this@SessionUi)) prompt.setAutoApprove(value) + } + } + } } private fun bindStyle() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 3c0da06ba82..fd45ace65e6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloAutoApproveService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle @@ -11,6 +12,7 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.model.Question @@ -74,6 +76,7 @@ class SessionController( private val beforeUpdate: () -> Boolean = { false }, private val afterUpdate: (Boolean) -> Unit = {}, private val loaded: (Boolean) -> Unit = {}, + private val auto: KiloAutoApproveService? = null, ) : Disposable { companion object { @@ -304,6 +307,7 @@ class SessionController( fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } + updatePermission(requestId, PermissionRequestState.RESPONDING) cs.launch { try { if (rules != null) sessions.savePermissionRules(requestId, directory, rules) @@ -311,10 +315,29 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + updatePermission( + requestId, + PermissionRequestState.ERROR, + e.message ?: KiloBundle.message("session.permission.error"), + ) + } } } } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { + assertEdt() + val current = model.state + if (current !is SessionState.AwaitingPermission) return + if (current.permission.id != id) return + val perm = current.permission.copy( + state = state, + message = message ?: current.permission.message, + ) + updateModel { model.setState(SessionState.AwaitingPermission(perm)) } + } + fun replyQuestion(requestId: String, answers: QuestionReplyDto) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } @@ -341,6 +364,28 @@ class SessionController( } } + fun toggleAutoApprove(): Boolean { + assertEdt() + val next = auto?.toggle() ?: false + if (next) drainPermissions() + return next + } + + private fun drainPermissions() { + val id = sid ?: return + cs.launch { + try { + val pending = sessions.pendingPermissions(directory).filter { it.sessionID == id } + for (req in pending) { + sessions.replyPermission(req.id, directory, PermissionReplyDto("once")) + } + LOG.debug { "${ChatLogSummary.sid(id)} kind=auto-approve drain count=${pending.size}" } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(id)} kind=auto-approve drain failed message=${e.message}", e) + } + } + } + init { (ref as? SessionRef.Local)?.session?.let { model.setSession(it) } when (val item = ref) { @@ -568,6 +613,12 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch" } + if (permissions.isNotEmpty() && auto?.active() == true) { + for (req in permissions) { + sessions.replyPermission(req.id, directory, PermissionReplyDto("once")) + } + return + } runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -669,7 +720,12 @@ class SessionController( } is ChatEventDto.PermissionAsked -> { - model.setState(SessionState.AwaitingPermission(toPermission(event.request))) + val perm = toPermission(event.request) + if (auto?.active() == true) { + replyPermission(perm.id, PermissionReplyDto("once")) + return + } + model.setState(SessionState.AwaitingPermission(perm)) } is ChatEventDto.PermissionReplied -> { @@ -1213,17 +1269,40 @@ private fun ConfigWarningDto.toDetailLine(): String { private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } - val file = dto.metadata["file"] ?: dto.metadata["path"] val state = dto.metadata["state"]?.let { raw -> PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) } } ?: PermissionRequestState.PENDING + val diffs = dto.fileDiffs.map { + PermissionFileDiff( + file = it.file, + patch = it.patch, + before = it.before, + after = it.after, + additions = it.additions, + deletions = it.deletions, + ) + } + val file = dto.filePath + ?: dto.metadata["filepath"] + ?: dto.metadata["filePath"] + ?: dto.metadata["file"] + ?: dto.metadata["path"] return Permission( id = dto.id, sessionId = dto.sessionID, name = dto.permission, patterns = dto.patterns, always = dto.always, - meta = PermissionMeta(filePath = file, raw = dto.metadata), + meta = PermissionMeta( + command = dto.command ?: dto.metadata["command"], + rules = dto.rules, + diff = dto.metadata["diff"], + filePath = file, + fileDiff = diffs.firstOrNull(), + fileDiffs = diffs, + raw = dto.metadata, + ), + message = dto.message ?: dto.metadata["message"], tool = ref, state = state, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt index d6034eee7ca..efc9d568497 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt @@ -17,10 +17,12 @@ data class Permission( ) data class PermissionMeta( + val command: String? = null, val rules: List = emptyList(), val diff: String? = null, val filePath: String? = null, val fileDiff: PermissionFileDiff? = null, + val fileDiffs: List = emptyList(), val raw: Map = emptyMap(), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 9ba3f57bfd4..a52282c55dc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -61,12 +61,15 @@ class PromptPanel( private val project: Project, private val onSend: (String) -> Unit, private val onAbort: () -> Unit, + private val autoApprove: () -> Boolean = { false }, + private val onAutoApproveToggle: () -> Boolean = { false }, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext { companion object { private val LOG = KiloLog.create(PromptPanel::class.java) private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) + private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java) } val mode = ModePicker() @@ -130,6 +133,13 @@ class PromptPanel( addActionListener { onReset() } } + private val autoIcon = HoverIcon().apply { + icon = SHIELD_ICON + addActionListener { + setAutoApprove(onAutoApproveToggle()) + } + } + @Volatile private var busy = false private var ready = false @@ -152,6 +162,7 @@ class PromptPanel( ) applyStyle(style) + setAutoApprove(autoApprove()) shell.add(editor, BorderLayout.CENTER) val bar = BorderLayoutPanel().apply { @@ -167,6 +178,8 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(reset) bar.add(Box.createHorizontalGlue()) + bar.add(autoIcon) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(button) shell.add(bar, BorderLayout.SOUTH) add(shell, BorderLayout.CENTER) @@ -210,6 +223,8 @@ class PromptPanel( internal fun buttonForTest(): JButton = button + internal fun autoApproveButtonForTest(): JButton = autoIcon + internal val defaultFocusedComponent: JComponent get() = editor override fun applyStyle(style: SessionEditorStyle) { @@ -233,6 +248,21 @@ class PromptPanel( editor.requestFocusInWindow() } + fun setAutoApprove(value: Boolean) { + autoIcon.putClientProperty("selected", value) + autoIcon.toolTipText = if (value) { + KiloBundle.message("prompt.autoApprove.enabled") + } else { + KiloBundle.message("prompt.autoApprove.disabled") + } + autoIcon.accessibleContext.accessibleName = if (value) { + KiloBundle.message("prompt.autoApprove.disable") + } else { + KiloBundle.message("prompt.autoApprove.enable") + } + autoIcon.repaint() + } + override fun addNotify() { super.addNotify() bindKeymap() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt index be38165e41a..ff98addbea3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt @@ -2,25 +2,35 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionFileDiff +import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.FlowLayout +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants /** * Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel] * at the end of the transcript when the session is in * [ai.kilocode.client.session.model.SessionState.AwaitingPermission]. * - * Unlike the old docked [ai.kilocode.client.session.ui.PermissionPanel], this view lives inside - * the scrollable transcript so the user can scroll through prior messages while a permission is pending. + * Shows a rich card with command/pattern/diff details and Run/Deny actions. */ class PermissionView( private val reply: (String, PermissionReplyDto) -> Unit, @@ -30,44 +40,119 @@ class PermissionView( private var requestId: String? = null private var style = SessionEditorStyle.current() + private val card = BorderLayoutPanel() + private val header = JBLabel() + private val details = JPanel() + private val actions = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) + private val run = JButton(KiloBundle.message("session.permission.run")) + private val deny = JButton(KiloBundle.message("session.permission.deny")) + + // Track text areas so we can update their font on style change + private val textAreas = mutableListOf() + init { - isOpaque = false isVisible = false + + card.background = SessionUiStyle.View.surface() + card.border = JBUI.Borders.compound( + SessionUiStyle.View.card(), + JBUI.Borders.empty( + UiStyle.Gap.sm(), + UiStyle.Gap.pad(), + UiStyle.Gap.sm(), + UiStyle.Gap.pad(), + ), + ) + + val top = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply { + isOpaque = false + val icon = JBLabel(AllIcons.General.Warning) + add(icon) + add(Box.createHorizontalStrut(UiStyle.Gap.xs())) + header.font = style.boldUiFont + add(header) + } + + details.layout = BoxLayout(details, BoxLayout.Y_AXIS) + details.isOpaque = false + + actions.isOpaque = false + actions.add(run) + actions.add(deny) + + val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(top) + add(Box.createVerticalStrut(UiStyle.Gap.sm())) + add(details) + add(Box.createVerticalStrut(UiStyle.Gap.sm())) + add(actions) + } + + card.add(body, BorderLayout.CENTER) + add(card, BorderLayout.CENTER) + + run.addActionListener { decide("once") } + deny.addActionListener { decide("reject") } } /** Populate the view for [permission] and make it visible. */ fun show(permission: Permission) { requestId = permission.id - val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" } - removeAll() + header.text = KiloBundle.message("session.permission.title") + header.font = style.boldUiFont - val card = BorderLayoutPanel() - card.isOpaque = true - card.background = SessionUiStyle.View.surface() - card.border = SessionUiStyle.View.card() + details.removeAll() + textAreas.clear() - card.add(panel { - row { - icon(AllIcons.General.Warning).gap(RightGap.SMALL) - label(KiloBundle.message("session.permission.title")).bold() + val toolName = permission.name + val cmd = permission.meta.command + if (cmd != null || toolName == "bash") { + addCommandBlock(cmd ?: "") + } else { + addPatternBlock(toolName, permission.patterns) + } + + val msg = permission.message + if (!msg.isNullOrBlank()) { + val msgLabel = JBLabel(msg).apply { + foreground = UIUtil.getContextHelpForeground() + font = style.smallUiFont } - row { - label(KiloBundle.message("session.permission.meta", permission.name, patterns)) + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + details.add(msgLabel) + } + + val diffs = permission.meta.fileDiffs + if (diffs.isNotEmpty()) { + details.add(Box.createVerticalStrut(UiStyle.Gap.sm())) + val diffTitle = JBLabel(KiloBundle.message("session.permission.diff")).apply { + font = style.boldUiFont } - val msg = permission.message - if (!msg.isNullOrBlank()) { - row { - comment(msg) + details.add(diffTitle) + for (diff in diffs) { + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + addDiffBlock(diff) + } + } else { + val fallbackDiff = permission.meta.diff + val fallbackPath = permission.meta.filePath + if (fallbackDiff != null) { + details.add(Box.createVerticalStrut(UiStyle.Gap.sm())) + val diffTitle = JBLabel(KiloBundle.message("session.permission.diff")).apply { + font = style.boldUiFont } + details.add(diffTitle) + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + addDiffBlock(PermissionFileDiff(file = fallbackPath ?: "patch", patch = fallbackDiff, additions = 0, deletions = 0)) } - row { - button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL) - button(KiloBundle.message("session.permission.deny")) { decide("reject") } - }.layout(RowLayout.INDEPENDENT) - }.also { it.isOpaque = false }, BorderLayout.CENTER) + } - add(card, BorderLayout.CENTER) + val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED + run.isEnabled = !responding + deny.isEnabled = !responding isVisible = true refresh() @@ -76,19 +161,137 @@ class PermissionView( /** Hide this view and clear the active request id. */ fun hideView() { requestId = null - removeAll() + details.removeAll() + textAreas.clear() isVisible = false refresh() } override fun applyStyle(style: SessionEditorStyle) { this.style = style + header.font = style.boldUiFont + for (area in textAreas) { + area.font = style.transcriptFont + area.background = style.editorScheme.defaultBackground + } + } + + private fun addCommandBlock(cmd: String) { + val label = JBLabel(KiloBundle.message("session.permission.command")).apply { + font = style.boldUiFont + } + details.add(label) + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + val area = JBTextArea(cmd).apply { + isEditable = false + lineWrap = false + font = style.transcriptFont + background = style.editorScheme.defaultBackground + foreground = UIUtil.getLabelForeground() + } + textAreas.add(area) + val scroll = JBScrollPane(area).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1) + } + details.add(scroll) + } + + private fun addPatternBlock(tool: String, patterns: List) { + val lbl = toolLabel(tool) + val filtered = patterns.filter { it != "*" } + if (filtered.isEmpty()) { + val noDetails = JBLabel(KiloBundle.message("session.permission.no.details", lbl)).apply { + font = style.uiFont + } + details.add(noDetails) + return + } + if (filtered.size == 1) { + val row = JBLabel("$lbl ${filtered[0]}").apply { + font = style.uiFont + } + details.add(row) + return + } + val title = JBLabel(KiloBundle.message("session.permission.patterns", lbl)).apply { + font = style.boldUiFont + } + details.add(title) + for (p in filtered) { + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + val pathLabel = JBLabel(p).apply { + font = style.transcriptFont + foreground = UIUtil.getContextHelpForeground() + } + details.add(pathLabel) + } + } + + private fun addDiffBlock(diff: PermissionFileDiff) { + val fileRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply { + isOpaque = false + val fileLabel = JBLabel(diff.file).apply { + font = style.transcriptFont + } + add(fileLabel) + if (diff.additions > 0 || diff.deletions > 0) { + val summary = JBLabel(KiloBundle.message("session.permission.diff.summary", diff.additions, diff.deletions)).apply { + font = style.smallUiFont + foreground = UIUtil.getContextHelpForeground() + } + add(summary) + } + } + details.add(fileRow) + val patch = diff.patch + if (!patch.isNullOrBlank()) { + details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) + val area = JBTextArea(patch).apply { + isEditable = false + lineWrap = false + font = style.transcriptFont + background = style.editorScheme.defaultBackground + foreground = UIUtil.getLabelForeground() + } + textAreas.add(area) + val scroll = JBScrollPane(area).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1) + } + details.add(scroll) + } + } + + private fun toolLabel(tool: String): String = when (tool) { + "read" -> KiloBundle.message("session.permission.tool.read") + "edit" -> KiloBundle.message("session.permission.tool.edit") + "write" -> KiloBundle.message("session.permission.tool.write") + "patch" -> KiloBundle.message("session.permission.tool.patch") + "multiedit" -> KiloBundle.message("session.permission.tool.multiedit") + "glob" -> KiloBundle.message("session.permission.tool.glob") + "grep" -> KiloBundle.message("session.permission.tool.grep") + "list" -> KiloBundle.message("session.permission.tool.list") + "bash" -> KiloBundle.message("session.permission.tool.bash") + "external_directory" -> KiloBundle.message("session.permission.tool.external_directory") + "webfetch" -> KiloBundle.message("session.permission.tool.webfetch") + "websearch" -> KiloBundle.message("session.permission.tool.websearch") + "codesearch" -> KiloBundle.message("session.permission.tool.codesearch") + "todoread" -> KiloBundle.message("session.permission.tool.todoread") + "todowrite" -> KiloBundle.message("session.permission.tool.todowrite") + "task" -> KiloBundle.message("session.permission.tool.task") + "skill" -> KiloBundle.message("session.permission.tool.skill") + "lsp" -> KiloBundle.message("session.permission.tool.lsp") + else -> tool } private fun decide(value: String) { val id = requestId ?: return + run.isEnabled = false + deny.isEnabled = false reply(id, PermissionReplyDto(reply = value)) - hideView() } private fun refresh() { @@ -97,4 +300,8 @@ class PermissionView( parent?.revalidate() parent?.repaint() } + + // Test helpers + internal fun runButtonForTest() = run + internal fun denyButtonForTest() = deny } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg new file mode 100644 index 00000000000..822bbc97cf0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg new file mode 100644 index 00000000000..a8f3398dfe1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 28bc445d745..eaaa582f2db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -13,10 +13,41 @@ session.scroll.bottom=Scroll to bottom session.tab.new=New Session session.tab.untitled=Untitled Session -session.permission.title=Permission request +session.permission.title=Permission required +session.permission.title.subagent=Permission required (subagent) session.permission.meta=Tool: {0} • Patterns: {1} +session.permission.run=Run session.permission.allow=Allow session.permission.deny=Deny +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +prompt.autoApprove.enable=Enable auto-approve +prompt.autoApprove.disable=Disable auto-approve +prompt.autoApprove.enabled=Auto-approve enabled. Permission requests will be approved once automatically. +prompt.autoApprove.disabled=Auto-approve disabled. Click to auto-approve permission requests. session.question.dismiss=Dismiss session.question.submit=Submit session.question.next=Next diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 727f527b25f..d316ff139cd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,8 +1,12 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.app.KiloAutoApproveService +import ai.kilocode.client.session.model.PermissionFileDiff +import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto @@ -10,6 +14,7 @@ import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto +import com.intellij.ide.util.PropertiesComponent class PromptLifecycleTest : SessionControllerTestBase() { @@ -175,6 +180,59 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("q1", rpc.questionRejects[0].first) } + fun `test PermissionAsked maps rich fields to meta`() { + val (m, _, _) = prompted() + val req = PermissionRequestDto( + id = "perm_rich", + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + command = "git diff", + fileDiffs = listOf(PermissionFileDiffDto("src/A.kt", patch = "@@ @@", additions = 1, deletions = 0)), + ) + + emit(ChatEventDto.PermissionAsked("ses_test", req)) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("git diff", perm.meta.command) + assertEquals(1, perm.meta.fileDiffs.size) + assertEquals("src/A.kt", perm.meta.fileDiffs[0].file) + } + + fun `test replyPermission without rules leaves rulesSaved empty`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + edt { m.replyPermission("perm1", PermissionReplyDto("once")) } + flush() + + assertTrue(rpc.permissionRulesSaved.isEmpty()) + assertEquals(1, rpc.permissionReplies.size) + } + + fun `test auto-approve live event replies once without showing prompt`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto( + ai.kilocode.rpc.dto.KiloAppStatusDto.READY, + config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"), + ) + projectRpc.state.value = workspaceReady() + val svc = KiloAutoApproveService() + svc.set(true) + val m = controller(flushMs = Long.MAX_VALUE, auto = svc) + edt { m.prompt("go") } + flush() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm_auto"))) + + assertFalse(m.model.state is SessionState.AwaitingPermission) + assertEquals(1, rpc.permissionReplies.size) + assertEquals("once", rpc.permissionReplies[0].third.reply) + + svc.set(false) + } + private fun permission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 249d51e7bc2..3afcb2d7753 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloAutoApproveService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent @@ -128,8 +129,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { id: String? = null, flushMs: Long = Long.MAX_VALUE, displayMs: Long = Long.MAX_VALUE, + auto: KiloAutoApproveService? = null, ): SessionController { - return controller(id, flushMs, true, displayMs = displayMs) + return controller(id, flushMs, true, displayMs = displayMs, auto = auto) } protected fun controller( @@ -149,6 +151,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { beforeUpdate: () -> Boolean = { false }, afterUpdate: (Boolean) -> Unit = {}, ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id), + auto: KiloAutoApproveService? = null, ): SessionController { val root = Root() val m = SessionController( @@ -164,6 +167,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { displayMs, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, + auto = auto, ) controllers.add(m) roots[m] = root diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index e31795516c5..a5a9cdfea54 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.app.KiloAutoApproveService import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto @@ -233,6 +234,31 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) } + fun `test auto-approve recovery replies once and does not show awaiting permission`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_auto", + sessionID = "ses_test", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + val svc = KiloAutoApproveService() + svc.set(true) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", auto = svc) + flush() + + assertFalse(m.model.state is SessionState.AwaitingPermission) + assertEquals(1, rpc.permissionReplies.size) + assertEquals("once", rpc.permissionReplies[0].third.reply) + + svc.set(false) + } + fun `test pending question overrides a seeded retry status`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L)) rpc.pendingQuestionList.add( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 1b210456b57..596ff29285e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -120,6 +120,54 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(shell, panel.mode.parent.parent) } + fun `test auto-approve button initializes with disabled tooltip`() { + val panel = PromptPanel(project, {}, {}, autoApprove = { false }, onAutoApproveToggle = { true }) + + assertEquals( + "Auto-approve disabled. Click to auto-approve permission requests.", + panel.autoApproveButtonForTest().toolTipText, + ) + } + + fun `test clicking auto-approve button toggles state`() { + var enabled = false + val panel = PromptPanel( + project, {}, {}, + autoApprove = { enabled }, + onAutoApproveToggle = { enabled = !enabled; enabled }, + ) + + panel.autoApproveButtonForTest().doClick() + + assertEquals( + "Auto-approve enabled. Permission requests will be approved once automatically.", + panel.autoApproveButtonForTest().toolTipText, + ) + } + + fun `test setAutoApprove updates tooltip to enabled`() { + val panel = PromptPanel(project, {}, {}) + + panel.setAutoApprove(true) + + assertEquals( + "Auto-approve enabled. Permission requests will be approved once automatically.", + panel.autoApproveButtonForTest().toolTipText, + ) + } + + fun `test setAutoApprove updates tooltip to disabled`() { + val panel = PromptPanel(project, {}, {}) + panel.setAutoApprove(true) + + panel.setAutoApprove(false) + + assertEquals( + "Auto-approve disabled. Click to auto-approve permission requests.", + panel.autoApproveButtonForTest().toolTipText, + ) + } + private class TestSink : DataSink { var send: Any? = null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt index 73dca0fc954..a721e3b5b8b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Container @@ -20,29 +22,40 @@ class PermissionViewTest : BasePlatformTestCase() { ) } - fun `test allow button uses bundle text and replies once`() { + fun `test run button replies once`() { view.show(permission()) - buttons(view).first { it.text == "Allow" }.doClick() + view.runButtonForTest().doClick() - assertFalse(view.isVisible) assertEquals(1, replies.size) assertEquals("perm1", replies.single().first) assertEquals("once", replies.single().second.reply) + assertFalse(view.runButtonForTest().isEnabled) + assertFalse(view.denyButtonForTest().isEnabled) } - fun `test deny button uses bundle text and rejects`() { + fun `test deny button rejects`() { view.show(permission()) - buttons(view).first { it.text == "Deny" }.doClick() + view.denyButtonForTest().doClick() - assertFalse(view.isVisible) assertEquals(1, replies.size) assertEquals("perm1", replies.single().first) assertEquals("reject", replies.single().second.reply) } - fun `test blank patterns display star`() { + fun `test view is visible after show`() { + view.show(permission()) + assertTrue(view.isVisible) + } + + fun `test hideView makes invisible`() { + view.show(permission()) + view.hideView() + assertFalse(view.isVisible) + } + + fun `test blank patterns display no-details fallback`() { view.show( Permission( id = "perm2", @@ -55,6 +68,142 @@ class PermissionViewTest : BasePlatformTestCase() { ) assertTrue(view.isVisible) + // Should have text saying edit requires permission + val text = allText(view) + assertTrue("Expected tool label in text, got: $text", text.contains("Edit")) + } + + fun `test star-only patterns use no-details fallback`() { + view.show( + Permission( + id = "perm3", + sessionId = "ses", + name = "read", + patterns = listOf("*"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + assertTrue(view.isVisible) + val text = allText(view) + assertTrue("Expected Read label in text, got: $text", text.contains("Read")) + } + + fun `test bash permission shows command`() { + view.show( + Permission( + id = "perm4", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "git status --short"), + ) + ) + + val text = allText(view) + assertTrue("Expected command in text, got: $text", text.contains("git status --short")) + } + + fun `test non-bash patterns show tool and path`() { + view.show( + Permission( + id = "perm5", + sessionId = "ses", + name = "read", + patterns = listOf("src/App.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + val text = allText(view) + assertTrue("Expected 'Read' in text, got: $text", text.contains("Read")) + assertTrue("Expected path in text, got: $text", text.contains("src/App.kt")) + } + + fun `test diff preview renders file and patch`() { + view.show( + Permission( + id = "perm6", + sessionId = "ses", + name = "edit", + patterns = listOf("src/A.kt"), + always = emptyList(), + meta = PermissionMeta( + fileDiffs = listOf( + PermissionFileDiff( + file = "src/A.kt", + patch = "@@ -1 +1 @@", + additions = 1, + deletions = 2, + ) + ), + ), + ) + ) + + val text = allText(view) + assertTrue("Expected file name in text, got: $text", text.contains("src/A.kt")) + assertTrue("Expected patch in text, got: $text", text.contains("@@")) + assertTrue("Expected additions in text, got: $text", text.contains("+1")) + assertTrue("Expected deletions in text, got: $text", text.contains("-2")) + } + + fun `test no rule controls rendered`() { + view.show( + Permission( + id = "perm7", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = listOf("src/**"), + meta = PermissionMeta(rules = listOf("rule1")), + ) + ) + + val text = allText(view) + assertFalse("Should not contain 'Manage Auto-Approve Rules'", text.contains("Manage Auto-Approve Rules")) + // Only Run and Deny buttons — not extra rule toggle buttons + val btns = buttons(view) + assertEquals("Expected exactly 2 buttons (Run and Deny)", 2, btns.size) + } + + fun `test responding state disables buttons`() { + view.show( + Permission( + id = "perm8", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + state = PermissionRequestState.RESPONDING, + ) + ) + + assertFalse(view.runButtonForTest().isEnabled) + assertFalse(view.denyButtonForTest().isEnabled) + } + + fun `test allow button uses bundle text and replies once`() { + view.show(permission()) + + // run button (previously "Allow") should trigger once reply + view.runButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("once", replies.single().second.reply) + } + + fun `test deny button uses bundle text and rejects`() { + view.show(permission()) + + view.denyButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("reject", replies.single().second.reply) } private fun permission() = Permission( @@ -71,4 +220,16 @@ class PermissionViewTest : BasePlatformTestCase() { val item = if (comp is AbstractButton) listOf(comp) else emptyList() if (comp is Container) item + buttons(comp) else item } + + private fun allText(root: Container): String = buildString { + fun collect(c: Container) { + for (comp in c.components) { + if (comp is javax.swing.text.JTextComponent) append(comp.text).append(" ") + if (comp is javax.swing.JLabel) append(comp.text).append(" ") + if (comp is AbstractButton) append(comp.text).append(" ") + if (comp is Container) collect(comp) + } + } + collect(root) + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 9d145f2fad6..01310419158 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -235,6 +235,16 @@ sealed class ChatEventDto { // --- Permission DTOs --- +@Serializable +data class PermissionFileDiffDto( + val file: String, + val patch: String? = null, + val before: String? = null, + val after: String? = null, + val additions: Int = 0, + val deletions: Int = 0, +) + @Serializable data class PermissionRequestDto( val id: String, @@ -244,6 +254,11 @@ data class PermissionRequestDto( val metadata: Map = emptyMap(), val always: List = emptyList(), val tool: ToolRefDto? = null, + val message: String? = null, + val command: String? = null, + val rules: List = emptyList(), + val filePath: String? = null, + val fileDiffs: List = emptyList(), ) @Serializable From 6afa90621949ab2b3c1634260762024e0c84b856 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 20 May 2026 19:49:34 -0400 Subject: [PATCH 02/21] feat(jetbrains): bubble subagent permission requests into root session UI Track child session IDs discovered from task tool part metadata and subscribe to their permission events so subagent permission prompts surface in the root session UI instead of hanging silently. Also makes runtime auto-approve non-persistent (no longer writes PropertiesComponent). --- .../client/app/KiloAutoApproveService.kt | 9 +- .../session/controller/SessionController.kt | 78 +++++++++- .../client/app/KiloAutoApproveServiceTest.kt | 26 ++++ .../session/controller/PromptLifecycleTest.kt | 135 +++++++++++++++++ .../session/controller/SessionRecoveryTest.kt | 143 ++++++++++++++++++ 5 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAutoApproveServiceTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt index 21e38ce7b8e..f89aa23a805 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAutoApproveService.kt @@ -1,6 +1,5 @@ package ai.kilocode.client.app -import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.components.Service import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,19 +14,13 @@ import kotlinx.coroutines.flow.asStateFlow */ @Service(Service.Level.APP) class KiloAutoApproveService { - companion object { - internal const val KEY = "kilo.permission.autoApprove.enabled" - } - - private val props = PropertiesComponent.getInstance() - private val state = MutableStateFlow(props.getBoolean(KEY, false)) + private val state = MutableStateFlow(false) val enabled: StateFlow = state.asStateFlow() fun active(): Boolean = state.value fun set(value: Boolean) { if (state.value == value) return - props.setValue(KEY, value.toString()) state.value = value } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 6f95ca8f60e..e038a4fe45a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -23,6 +23,7 @@ import ai.kilocode.client.session.SessionRef import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto @@ -113,6 +114,8 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private val childJobs: MutableMap = mutableMapOf() + private val childIds: MutableSet = mutableSetOf() private var sessionLoadState: SessionLoadState = SessionLoadState.Idle private var recentsState: RecentsState = RecentsState.Idle private var viewState: SessionControllerEvent.ViewChanged? = null @@ -384,9 +387,12 @@ class SessionController( private fun drainPermissions() { val id = sid ?: return + val tracked = childIds.toSet() cs.launch { try { - val pending = sessions.pendingPermissions(directory).filter { it.sessionID == id } + val all = sessions.pendingPermissions(directory) + val ids = setOf(id) + tracked + val pending = all.filter { it.sessionID in ids } for (req in pending) { sessions.replyPermission(req.id, directory, PermissionReplyDto("once")) } @@ -504,6 +510,7 @@ class SessionController( val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull() val items = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } + val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet() runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -513,6 +520,7 @@ class SessionController( } } recoverPending(id) + for (child in discovered) trackChild(child) runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -548,6 +556,7 @@ class SessionController( val session = sessions.importCloudSession(id, directory) val items = sessions.messages(session.id, directory) LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" } + val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet() runEdt { if (disposed) return@runEdt ref = SessionRef.Local(session) @@ -558,6 +567,7 @@ class SessionController( } } recoverPending(session.id) + for (child in discovered) trackChild(child) runEdt { if (disposed) return@runEdt subscribeEvents() @@ -597,6 +607,9 @@ class SessionController( val id = sid ?: return LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" } eventJob?.cancel() + childJobs.values.forEach { it.cancel() } + childJobs.clear() + childIds.clear() eventJob = cs.launch { try { sessions.events(id, directory).collect { event -> @@ -613,6 +626,52 @@ class SessionController( } } + private fun subscribeChild(child: String) { + if (childJobs.containsKey(child)) return + LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=true" } + val job = cs.launch { + try { + sessions.events(child, directory).collect { event -> + if (!isChildPermissionEvent(event, child)) return@collect + LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" } + updates.enqueue(event) + } + } finally { + LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=false" } + } + } + childJobs[child] = job + } + + private fun trackChild(child: String) { + if (!childIds.add(child)) return + subscribeChild(child) + cs.launch { recoverChildPermissions(child) } + } + + private suspend fun recoverChildPermissions(child: String) { + try { + val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } + if (permissions.isEmpty()) return + LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } + if (auto?.active() == true) { + for (req in permissions) { + sessions.replyPermission(req.id, directory, PermissionReplyDto("once")) + } + return + } + val last = toPermission(permissions.last()) + runEdt { + if (disposed) return@runEdt + // Do not overwrite an existing root or other child AwaitingPermission state + if (model.state is SessionState.AwaitingPermission) return@runEdt + updateModel { model.setState(SessionState.AwaitingPermission(last)) } + } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + } + } + /** Rehydrate pending permissions/questions and current session status after history load. */ private suspend fun recoverPending(id: String) { try { @@ -691,6 +750,7 @@ class SessionController( if (model.state is SessionState.Busy) { model.setState(SessionState.Busy(status())) } + childID(event.part)?.let { child -> trackChild(child) } } is ChatEventDto.PartDelta -> { @@ -1250,6 +1310,9 @@ class SessionController( disposed = true connectionDelay.dispose() eventJob?.cancel() + childJobs.values.forEach { it.cancel() } + childJobs.clear() + childIds.clear() cs.cancel() } @@ -1304,6 +1367,19 @@ class SessionController( } } +/** Extracts the child session ID from a task tool part's metadata, or null if not a task part. */ +private fun childID(part: PartDto): String? { + if (part.type != "tool" || part.tool != "task") return null + return part.metadata["sessionId"] +} + +/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */ +private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) { + is ChatEventDto.PermissionAsked -> event.sessionID == child + is ChatEventDto.PermissionReplied -> event.sessionID == child + else -> false +} + /** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) { is ChatEventDto.MessageUpdated -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAutoApproveServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAutoApproveServiceTest.kt new file mode 100644 index 00000000000..7960de9f2dc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAutoApproveServiceTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.app + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class KiloAutoApproveServiceTest : BasePlatformTestCase() { + + fun `test auto approve starts disabled even with stale persisted value`() { + PropertiesComponent.getInstance().setValue("kilo.permission.autoApprove.enabled", "true") + + val svc = KiloAutoApproveService() + + assertFalse(svc.active()) + PropertiesComponent.getInstance().unsetValue("kilo.permission.autoApprove.enabled") + } + + fun `test toggle is runtime only`() { + val svc = KiloAutoApproveService() + + assertTrue(svc.toggle()) + assertTrue(svc.active()) + + assertFalse(PropertiesComponent.getInstance().getBoolean("kilo.permission.autoApprove.enabled", false)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index d316ff139cd..e302c814b3d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto @@ -233,6 +234,140 @@ class PromptLifecycleTest : SessionControllerTestBase() { svc.set(false) } + // ------ Child session (subagent) permission bubbling ------ + + fun `test task part with child sessionId causes controller to track child`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("child_perm1", perm.id) + assertEquals("ses_child", perm.sessionId) + } + + fun `test child PermissionAsked moves root model to AwaitingPermission`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + assertSession( + """ + permission#child_perm1 + tool: + name: edit + patterns: *.kt + always: + file: + state: PENDING + metadata: + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) + } + + fun `test child PermissionReplied clears root awaiting permission`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")), flush = false) + emit(ChatEventDto.PermissionReplied("ses_child", "child_perm1")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test replyPermission for child request sends correct requestId`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + edt { m.replyPermission("child_perm1", PermissionReplyDto("once")) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("child_perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test child non-permission events do not change root state`() { + val (m, _, modelEvents) = prompted() + val initialState = m.model.state + + // Emit non-permission child events — they must not affect the root + emit(ChatEventDto.TurnOpen("ses_child"), flush = false) + emit(ChatEventDto.SessionStatusChanged("ses_child", ai.kilocode.rpc.dto.SessionStatusDto("busy")), flush = false) + emit(ChatEventDto.SessionIdle("ses_child")) + + assertEquals(initialState, m.model.state) + // No extra model state events from child non-permission events + val stateEvents = modelEvents.filterIsInstance() + assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty()) + } + + fun `test child permission with auto-approve replies once without showing prompt`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto( + ai.kilocode.rpc.dto.KiloAppStatusDto.READY, + config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"), + ) + projectRpc.state.value = workspaceReady() + val svc = KiloAutoApproveService() + svc.set(true) + val m = controller(flushMs = Long.MAX_VALUE, auto = svc) + edt { m.prompt("go") } + flush() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_auto"))) + + assertFalse(m.model.state is SessionState.AwaitingPermission) + assertEquals(1, rpc.permissionReplies.size) + assertEquals("once", rpc.permissionReplies[0].third.reply) + + svc.set(false) + } + + fun `test root permission event is not processed as child permission`() { + val (m, _, _) = prompted() + + // No task part emitted — root permission should still work + emit(ChatEventDto.PermissionAsked("ses_test", permission("root_perm"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("root_perm", perm.id) + } + + private fun taskPart(childSessionId: String) = ChatEventDto.PartUpdated( + sessionID = "ses_test", + part = PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to childSessionId), + ), + ) + + private fun childPermission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_child", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + ) + private fun permission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index a5a9cdfea54..625931ca2b4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.app.KiloAutoApproveService import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -259,6 +261,147 @@ class SessionRecoveryTest : SessionControllerTestBase() { svc.set(false) } + // ------ Child session permission recovery from history ------ + + fun `test history with task part and pending child permission recovers to AwaitingPermission`() { + rpc.history.add( + MessageWithPartsDto( + info = msg("msg1", "ses_test", "assistant"), + parts = listOf( + PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to "ses_child"), + ), + ), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "child_perm_1", + sessionID = "ses_child", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("child_perm_1", perm.id) + assertEquals("ses_child", perm.sessionId) + } + + fun `test auto-approve child permission recovery replies once without showing prompt`() { + rpc.history.add( + MessageWithPartsDto( + info = msg("msg1", "ses_test", "assistant"), + parts = listOf( + PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to "ses_child"), + ), + ), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "child_perm_auto", + sessionID = "ses_child", + permission = "edit", + patterns = listOf("*.kt"), + ) + ) + + val svc = KiloAutoApproveService() + svc.set(true) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", auto = svc) + flush() + + assertFalse(m.model.state is SessionState.AwaitingPermission) + assertEquals(1, rpc.permissionReplies.size) + assertEquals("once", rpc.permissionReplies[0].third.reply) + + svc.set(false) + } + + fun `test pending child permission from unrelated session is ignored`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_unrelated", + sessionID = "ses_other_child", + permission = "read", + patterns = emptyList(), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // No task part linking ses_other_child — its permissions must be ignored + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test root pending permission takes priority over child pending permission`() { + rpc.history.add( + MessageWithPartsDto( + info = msg("msg1", "ses_test", "assistant"), + parts = listOf( + PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to "ses_child"), + ), + ), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "root_perm", + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "child_perm", + sessionID = "ses_child", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // Root recovery runs first and sets AwaitingPermission for root perm + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("root_perm", perm.id) + } + fun `test pending question overrides a seeded retry status`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L)) rpc.pendingQuestionList.add( From 835781830e5a854b2e8dd2223f713f1d0b0fa56d Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 21 May 2026 09:33:59 -0400 Subject: [PATCH 03/21] refactor(jetbrains): align permission question cards --- .../ui/shared/BaseSessionQuestionPanel.kt | 15 +- .../client/session/ui/style/SessionUiStyle.kt | 5 + .../client/session/views/LoginRequiredView.kt | 3 - .../client/session/views/PermissionView.kt | 305 ++++++++---------- .../session/views/question/QuestionView.kt | 3 +- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 7 +- .../ui/shared/BaseSessionQuestionPanelTest.kt | 23 +- .../session/views/PermissionViewTest.kt | 175 +++++++++- .../ai/kilocode/client/ui/md/MdViewTest.kt | 1 + 9 files changed, 343 insertions(+), 194 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt index 2e087b3bf0c..a91a586634b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt @@ -11,6 +11,7 @@ import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Component import java.awt.Dimension +import javax.swing.Box import javax.swing.BoxLayout import javax.swing.JComponent import javax.swing.JPanel @@ -117,12 +118,22 @@ class BaseSessionQuestionPanel : RoundedContentPanel( top?.let { col.add(it) } col.add(headerText) col.add(descriptionText) - body?.let { col.add(it) } - footer?.let { col.add(it) } + body?.let { + col.add(gap()) + col.add(it) + } + footer?.let { + col.add(gap()) + col.add(it) + } col.revalidate() col.repaint() } + private fun gap(): Component = Box.createVerticalStrut(UiStyle.Gap.lg()).apply { + setAlignmentX(Component.LEFT_ALIGNMENT) + } + private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { val area = object : JBTextArea(value) { override fun getPreferredSize() = withWidth(super.getPreferredSize().height) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 6cfdc93961e..df7f84ec064 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -72,6 +72,11 @@ object SessionUiStyle { const val USER_BORDER_HORIZONTAL_PADDING = 12 } + /** Permission card command preview limits. */ + object Permission { + const val COMMAND_LINES = 3 + } + /** Tool card preview limits and state colors. */ object Tool { const val BODY_LINES = 15 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt index a616416843d..3e56e999632 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -7,9 +7,7 @@ import ai.kilocode.client.session.ui.shared.applyButton import ai.kilocode.client.session.ui.shared.dismissButton import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget -import ai.kilocode.client.ui.UiStyle import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout import java.awt.Component @@ -44,7 +42,6 @@ class LoginRequiredView( val footer = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) alignmentX = Component.LEFT_ALIGNMENT add(dismissButton, BorderLayout.WEST) add(openProfileButton, BorderLayout.EAST) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt index ff98addbea3..78df4d1d464 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt @@ -2,26 +2,26 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Permission -import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel +import ai.kilocode.client.session.ui.shared.SessionQuestionButton +import ai.kilocode.client.session.ui.shared.applyButton +import ai.kilocode.client.session.ui.shared.dismissButton import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.md.MdView import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons -import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout -import java.awt.FlowLayout +import java.awt.Component +import java.awt.Dimension import javax.swing.Box import javax.swing.BoxLayout -import javax.swing.JButton import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -40,114 +40,67 @@ class PermissionView( private var requestId: String? = null private var style = SessionEditorStyle.current() - private val card = BorderLayoutPanel() - private val header = JBLabel() - private val details = JPanel() - private val actions = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) - private val run = JButton(KiloBundle.message("session.permission.run")) - private val deny = JButton(KiloBundle.message("session.permission.deny")) + private val card = BaseSessionQuestionPanel() - // Track text areas so we can update their font on style change - private val textAreas = mutableListOf() + private val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + + private val footer = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + + private val actions = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.X_AXIS) + alignmentX = Component.LEFT_ALIGNMENT + } + + private val run = applyButton(KiloBundle.message("session.permission.run")) { decide("once") } + private val deny = dismissButton(KiloBundle.message("session.permission.deny")) { decide("reject") } + + // Track command MdView instances for style updates + private val cmdViews = mutableListOf() + private val cmdScrolls = mutableListOf() init { + isOpaque = false isVisible = false - card.background = SessionUiStyle.View.surface() - card.border = JBUI.Borders.compound( - SessionUiStyle.View.card(), - JBUI.Borders.empty( - UiStyle.Gap.sm(), - UiStyle.Gap.pad(), - UiStyle.Gap.sm(), - UiStyle.Gap.pad(), - ), - ) - - val top = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply { - isOpaque = false - val icon = JBLabel(AllIcons.General.Warning) - add(icon) - add(Box.createHorizontalStrut(UiStyle.Gap.xs())) - header.font = style.boldUiFont - add(header) - } - - details.layout = BoxLayout(details, BoxLayout.Y_AXIS) - details.isOpaque = false - - actions.isOpaque = false - actions.add(run) actions.add(deny) + actions.add(Box.createHorizontalStrut(UiStyle.Gap.sm())) + actions.add(run) + footer.add(actions, BorderLayout.EAST) - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - add(top) - add(Box.createVerticalStrut(UiStyle.Gap.sm())) - add(details) - add(Box.createVerticalStrut(UiStyle.Gap.sm())) - add(actions) - } - - card.add(body, BorderLayout.CENTER) - add(card, BorderLayout.CENTER) - - run.addActionListener { decide("once") } - deny.addActionListener { decide("reject") } + card.setBody(body) + card.setFooter(footer) + addToCenter(card) } /** Populate the view for [permission] and make it visible. */ fun show(permission: Permission) { requestId = permission.id - header.text = KiloBundle.message("session.permission.title") - header.font = style.boldUiFont + card.headerText.text = KiloBundle.message("session.permission.title") - details.removeAll() - textAreas.clear() + body.removeAll() + cmdViews.clear() + cmdScrolls.clear() val toolName = permission.name val cmd = permission.meta.command - if (cmd != null || toolName == "bash") { - addCommandBlock(cmd ?: "") - } else { - addPatternBlock(toolName, permission.patterns) - } + val command = cmd != null || toolName == "bash" - val msg = permission.message - if (!msg.isNullOrBlank()) { - val msgLabel = JBLabel(msg).apply { - foreground = UIUtil.getContextHelpForeground() - font = style.smallUiFont - } - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - details.add(msgLabel) - } + card.descriptionText.text = "" + card.descriptionText.isVisible = false - val diffs = permission.meta.fileDiffs - if (diffs.isNotEmpty()) { - details.add(Box.createVerticalStrut(UiStyle.Gap.sm())) - val diffTitle = JBLabel(KiloBundle.message("session.permission.diff")).apply { - font = style.boldUiFont - } - details.add(diffTitle) - for (diff in diffs) { - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - addDiffBlock(diff) - } + if (command) { + addCodeBlock(cmd ?: "") } else { - val fallbackDiff = permission.meta.diff - val fallbackPath = permission.meta.filePath - if (fallbackDiff != null) { - details.add(Box.createVerticalStrut(UiStyle.Gap.sm())) - val diffTitle = JBLabel(KiloBundle.message("session.permission.diff")).apply { - font = style.boldUiFont - } - details.add(diffTitle) - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - addDiffBlock(PermissionFileDiff(file = fallbackPath ?: "patch", patch = fallbackDiff, additions = 0, deletions = 0)) - } + addCodeBlock(patternText(toolName, permission.patterns)) } val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED @@ -161,107 +114,67 @@ class PermissionView( /** Hide this view and clear the active request id. */ fun hideView() { requestId = null - details.removeAll() - textAreas.clear() + body.removeAll() + cmdViews.clear() + cmdScrolls.clear() isVisible = false refresh() } override fun applyStyle(style: SessionEditorStyle) { this.style = style - header.font = style.boldUiFont - for (area in textAreas) { - area.font = style.transcriptFont - area.background = style.editorScheme.defaultBackground + card.applyStyle(style) + for (md in cmdViews) { + applyMd(md) + } + for (scroll in cmdScrolls) { + applyScroll(scroll) } } - private fun addCommandBlock(cmd: String) { - val label = JBLabel(KiloBundle.message("session.permission.command")).apply { - font = style.boldUiFont + private fun addCodeBlock(text: String) { + val md = MdView.html().apply { + applyMd(this) + component.border = JBUI.Borders.empty() + set(fencedBlock(text)) } - details.add(label) - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - val area = JBTextArea(cmd).apply { - isEditable = false - lineWrap = false - font = style.transcriptFont - background = style.editorScheme.defaultBackground - foreground = UIUtil.getLabelForeground() + cmdViews.add(md) + + val scroll = object : JBScrollPane(md.component) { + override fun getPreferredSize(): Dimension { + val fm = getFontMetrics(style.transcriptFont) + val cap = fm.height * SessionUiStyle.View.Permission.COMMAND_LINES + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + val ps = super.getPreferredSize() + return Dimension(ps.width, minOf(ps.height, cap)) + } + + override fun getMaximumSize(): Dimension { + val fm = getFontMetrics(style.transcriptFont) + val cap = fm.height * SessionUiStyle.View.Permission.COMMAND_LINES + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + return Dimension(Int.MAX_VALUE, cap) + } + }.apply { + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + applyScroll(this) } - textAreas.add(area) - val scroll = JBScrollPane(area).apply { - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1) - } - details.add(scroll) + scroll.alignmentX = Component.LEFT_ALIGNMENT + cmdScrolls.add(scroll) + body.add(scroll) } - private fun addPatternBlock(tool: String, patterns: List) { + private fun patternText(tool: String, patterns: List): String { val lbl = toolLabel(tool) val filtered = patterns.filter { it != "*" } if (filtered.isEmpty()) { - val noDetails = JBLabel(KiloBundle.message("session.permission.no.details", lbl)).apply { - font = style.uiFont - } - details.add(noDetails) - return + return KiloBundle.message("session.permission.no.details", lbl) } if (filtered.size == 1) { - val row = JBLabel("$lbl ${filtered[0]}").apply { - font = style.uiFont - } - details.add(row) - return + return "$lbl ${filtered[0]}" } - val title = JBLabel(KiloBundle.message("session.permission.patterns", lbl)).apply { - font = style.boldUiFont - } - details.add(title) - for (p in filtered) { - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - val pathLabel = JBLabel(p).apply { - font = style.transcriptFont - foreground = UIUtil.getContextHelpForeground() - } - details.add(pathLabel) - } - } - - private fun addDiffBlock(diff: PermissionFileDiff) { - val fileRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply { - isOpaque = false - val fileLabel = JBLabel(diff.file).apply { - font = style.transcriptFont - } - add(fileLabel) - if (diff.additions > 0 || diff.deletions > 0) { - val summary = JBLabel(KiloBundle.message("session.permission.diff.summary", diff.additions, diff.deletions)).apply { - font = style.smallUiFont - foreground = UIUtil.getContextHelpForeground() - } - add(summary) - } - } - details.add(fileRow) - val patch = diff.patch - if (!patch.isNullOrBlank()) { - details.add(Box.createVerticalStrut(UiStyle.Gap.xs())) - val area = JBTextArea(patch).apply { - isEditable = false - lineWrap = false - font = style.transcriptFont - background = style.editorScheme.defaultBackground - foreground = UIUtil.getLabelForeground() - } - textAreas.add(area) - val scroll = JBScrollPane(area).apply { - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1) - } - details.add(scroll) + return buildString { + appendLine(KiloBundle.message("session.permission.patterns", lbl)) + append(filtered.joinToString("\n")) } } @@ -294,6 +207,28 @@ class PermissionView( reply(id, PermissionReplyDto(reply = value)) } + private fun applyMd(md: MdView) { + val bg = codeBackground() + md.opaque = true + md.font = style.transcriptFont + md.foreground = style.editorForeground + md.background = bg + md.preBg = bg + md.codeBg = bg + md.preFg = style.editorForeground + md.codeFont = style.editorFamily + md.component.background = bg + } + + private fun applyScroll(scroll: JBScrollPane) { + val bg = codeBackground() + scroll.border = JBUI.Borders.empty() + scroll.background = bg + scroll.viewport.background = bg + } + + private fun codeBackground() = SessionUiStyle.View.headerHover() + private fun refresh() { revalidate() repaint() @@ -305,3 +240,19 @@ class PermissionView( internal fun runButtonForTest() = run internal fun denyButtonForTest() = deny } + +/** + * Wrap [cmd] in a fenced Markdown code block. The fence uses at least 3 backticks, + * and is extended to be longer than any contiguous run of backticks inside [cmd] + * so the fence cannot be broken by content. + */ +private fun fencedBlock(cmd: String): String { + var max = 2 + var run = 0 + for (ch in cmd) { + run = if (ch == '`') run + 1 else 0 + if (run > max) max = run + } + val fence = "`".repeat(max + 1) + return "$fence\n$cmd\n$fence" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index cf78a87ef09..2711604742f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -82,7 +82,6 @@ class QuestionView( } private val footer = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) alignmentX = Component.LEFT_ALIGNMENT } private val dismiss = dismissButton(KiloBundle.message("session.question.dismiss")) { doReject() } @@ -157,7 +156,7 @@ class QuestionView( card.descriptionText.text = KiloBundle.message( if (item.multiple) "session.question.hint.multi" else "session.question.hint.single" ) - card.descriptionText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + card.descriptionText.border = JBUI.Borders.empty() card.descriptionText.isVisible = true addContent(item, selections[idx]) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 2f299325292..1135022c543 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.ui.UiStyle import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -355,7 +356,11 @@ abstract class MdView private constructor() { linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } - preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preBgOverride?.let { + val color = hex(it) + rules.append("div.code-block { background: $color; border-color: $color; padding: ${UiStyle.Gap.xs()}px ${UiStyle.Gap.lg()}px } ") + rules.append("pre { background: $color; border-color: $color } ") + } preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt index 7d57b03a7ce..c9b05745096 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt @@ -199,15 +199,32 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() { } } - fun `test col child count grows by one for each optional slot added`() { + fun `test col child count includes spacing before body and footer slots`() { edt { val panel = BaseSessionQuestionPanel() panel.setTopPanel(JLabel("top")) assertEquals(3, findCol(panel)!!.componentCount) panel.setBody(JLabel("body")) - assertEquals(4, findCol(panel)!!.componentCount) - panel.setFooter(JLabel("footer")) assertEquals(5, findCol(panel)!!.componentCount) + panel.setFooter(JLabel("footer")) + assertEquals(7, findCol(panel)!!.componentCount) + } + } + + fun `test body and footer spacing use matching standard insets`() { + edt { + val panel = BaseSessionQuestionPanel() + val body = JLabel("body") + val footer = JLabel("footer") + panel.setBody(body) + panel.setFooter(footer) + + val col = findCol(panel)!! + val comps = col.components.toList() + val bodyGap = comps[comps.indexOf(body) - 1] + val footerGap = comps[comps.indexOf(footer) - 1] + + assertEquals(bodyGap.preferredSize.height, footerGap.preferredSize.height) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt index a721e3b5b8b..24693425a03 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt @@ -4,10 +4,16 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.PermissionReplyDto +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane import java.awt.Container import javax.swing.AbstractButton +import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class PermissionViewTest : BasePlatformTestCase() { @@ -106,6 +112,26 @@ class PermissionViewTest : BasePlatformTestCase() { assertTrue("Expected command in text, got: $text", text.contains("git status --short")) } + fun `test bash permission shows only header and code block content`() { + view.show( + Permission( + id = "perm4b", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "git status --short"), + message = "Run this command?", + ) + ) + + val text = allText(view) + assertTrue("Expected permission header, got: $text", text.contains("Permission required")) + assertTrue("Expected command in text, got: $text", text.contains("git status --short")) + assertFalse("Should not show command label, got: $text", text.contains("Command")) + assertFalse("Should not show permission message, got: $text", text.contains("Run this command?")) + } + fun `test non-bash patterns show tool and path`() { view.show( Permission( @@ -120,10 +146,32 @@ class PermissionViewTest : BasePlatformTestCase() { val text = allText(view) assertTrue("Expected 'Read' in text, got: $text", text.contains("Read")) - assertTrue("Expected path in text, got: $text", text.contains("src/App.kt")) + assertTrue("Expected path in text, got: $text", text.contains("src/")) + assertTrue("Expected path in text, got: $text", text.contains("App")) + assertTrue("Expected path in text, got: $text", text.contains("kt")) } - fun `test diff preview renders file and patch`() { + fun `test non-bash patterns render as fenced code block via MdView`() { + view.show( + Permission( + id = "perm_pattern_md", + sessionId = "ses", + name = "glob", + patterns = listOf("packages/kilo-jetbrains/**/*.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + val panes = findAll(view) + assertTrue("Expected at least one JBHtmlPane for pattern details", panes.isNotEmpty()) + val html = panes.first().text + assertTrue("Expected
 tag in rendered HTML, got: $html", html.contains("(view)
+        assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
+    }
+
+    // ------ new: shared button types ------
+
+    fun `test run button is SessionQuestionButton with primary true`() {
+        view.show(permission())
+
+        val btn = view.runButtonForTest()
+        assertTrue("Run should be primary", btn.primary)
+    }
+
+    fun `test run button uses default style key`() {
+        view.show(permission())
+
+        val btn = view.runButtonForTest()
+        assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
+    }
+
+    fun `test deny button is SessionQuestionButton with primary false`() {
+        view.show(permission())
+
+        val btn = view.denyButtonForTest()
+        assertFalse("Deny should not be primary", btn.primary)
+    }
+
+    fun `test session question buttons use question surface background`() {
+        view.show(permission())
+
+        assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background)
+        assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background)
+    }
+
+    // ------ new: command rendered via MdView ------
+
+    fun `test bash command renders as fenced code block via MdView`() {
+        view.show(
+            Permission(
+                id = "perm_md",
+                sessionId = "ses",
+                name = "bash",
+                patterns = emptyList(),
+                always = emptyList(),
+                meta = PermissionMeta(command = "git status --short"),
+            )
+        )
+
+        // Find the JBHtmlPane that MdView uses — it should contain a 
 block
+        val panes = findAll(view)
+        assertTrue("Expected at least one JBHtmlPane for the command MdView", panes.isNotEmpty())
+        val html = panes.first().text
+        assertTrue("Expected 
 tag in rendered HTML, got: $html", html.contains("(view)
+        val cmdScroll = scrolls.firstOrNull { it.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED }
+        assertNotNull("Expected a JBScrollPane with VERTICAL_SCROLLBAR_AS_NEEDED for the command", cmdScroll)
+
+        val maxH = cmdScroll!!.maximumSize.height
+        assertTrue("Maximum height should be capped (> 0)", maxH > 0)
+        assertTrue("Maximum height should be finite (< Int.MAX_VALUE)", maxH < Int.MAX_VALUE)
+    }
+
+    fun `test code block scroll pane uses code background`() {
+        view.show(
+            Permission(
+                id = "perm_bg",
+                sessionId = "ses",
+                name = "bash",
+                patterns = emptyList(),
+                always = emptyList(),
+                meta = PermissionMeta(command = "pwd"),
+            )
+        )
+
+        val scroll = findAll(view).first { it.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED }
+        assertEquals(SessionUiStyle.View.headerHover(), scroll.background)
+        assertEquals(SessionUiStyle.View.headerHover(), scroll.viewport.background)
+    }
+
     private fun permission() = Permission(
         id = "perm1",
         sessionId = "ses_test",
@@ -232,4 +381,18 @@ class PermissionViewTest : BasePlatformTestCase() {
         }
         collect(root)
     }
+
+    private inline fun  findAll(root: Container): List = findAllCls(root, T::class.java)
+
+    private fun  findAllCls(root: Container, cls: Class): List {
+        val result = mutableListOf()
+        if (cls.isInstance(root)) result.add(cls.cast(root))
+        for (child in root.components) {
+            if (cls.isInstance(child)) result.add(cls.cast(child))
+            if (child is Container && child !is AbstractButton) {
+                result.addAll(findAllCls(child, cls))
+            }
+        }
+        return result
+    }
 }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt
index 8a40caf7337..27c6e901c4c 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt
@@ -243,6 +243,7 @@ class MdViewTest : BasePlatformTestCase() {
         view.set("```\ncode\n```")
         val sheet = view.overrideSheet()
         assertTrue(sheet.contains("#0a0b0c"))
+        assertTrue(sheet.contains("div.code-block"))
         assertTrue(sheet.contains("#d0e0f0"))
     }
 

From 20bae4f3d92c71129e49ff2f84a5d9a2393cd034 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 10:26:23 -0400
Subject: [PATCH 04/21] fix(jetbrains): align session question views

---
 .changeset/session-question-view-style.md     |  5 ++
 .../ui/shared/BaseSessionQuestionPanel.kt     | 43 ++++++++--
 .../client/session/views/LoginRequiredView.kt | 13 ++-
 .../client/session/views/PermissionView.kt    |  4 +
 .../views/question/QuestionResultView.kt      |  9 +-
 .../session/views/question/QuestionView.kt    | 10 ++-
 .../session/ui/SessionEditorStyleTest.kt      | 18 ++++
 .../ui/shared/BaseSessionQuestionPanelTest.kt | 82 ++++++++++++++++++-
 .../session/views/LoginRequiredViewTest.kt    | 49 +++++++++++
 .../session/views/PermissionViewTest.kt       | 54 ++++++++++++
 .../session/views/QuestionResultViewTest.kt   | 23 +++++-
 .../client/session/views/QuestionViewTest.kt  | 35 ++++++--
 12 files changed, 322 insertions(+), 23 deletions(-)
 create mode 100644 .changeset/session-question-view-style.md

diff --git a/.changeset/session-question-view-style.md b/.changeset/session-question-view-style.md
new file mode 100644
index 00000000000..ca25c743d45
--- /dev/null
+++ b/.changeset/session-question-view-style.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": patch
+---
+
+Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
index a91a586634b..ed36d799f32 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
@@ -5,14 +5,17 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.RoundedContentPanel
 import ai.kilocode.client.ui.UiStyle
+import com.intellij.ui.components.JBLabel
 import com.intellij.ui.components.JBTextArea
 import com.intellij.util.concurrency.annotations.RequiresEdt
 import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
 import java.awt.Color
 import java.awt.Component
 import java.awt.Dimension
 import javax.swing.Box
 import javax.swing.BoxLayout
+import javax.swing.Icon
 import javax.swing.JComponent
 import javax.swing.JPanel
 
@@ -27,9 +30,9 @@ import javax.swing.JPanel
  * outer card shell so they share the same background, padding, and text
  * styling without duplicating the setup.
  *
- * The column always contains (in order): optional top, [headerText],
+ * The column always contains (in order): optional top, header row with [headerText],
  * [descriptionText], optional body, optional footer. Call [setTopPanel],
- * [setBody], or [setFooter] to replace those slots at any time.
+ * [setHeaderIcon], [setBody], or [setFooter] to replace those slots at any time.
  */
 class BaseSessionQuestionPanel : RoundedContentPanel(
     UiStyle.Gap.lg(),
@@ -38,7 +41,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
 
     private var style = SessionEditorStyle.current()
 
-    // All JBTextArea instances that need editor-font updates, paired with bold flag
+    // All JBTextArea instances that need style updates, paired with bold flag
     private val tracked = mutableListOf>()
 
     // ---- header text ----
@@ -47,6 +50,23 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
     // ---- description text ----
     val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
 
+    private val icon = JBLabel().apply {
+        border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
+        isVisible = false
+    }
+
+    private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) {
+        override fun getMaximumSize(): Dimension {
+            val size = preferredSize
+            return Dimension(Int.MAX_VALUE, size.height)
+        }
+    }.apply {
+        isOpaque = false
+        alignmentX = Component.LEFT_ALIGNMENT
+        add(icon, BorderLayout.WEST)
+        add(headerText, BorderLayout.CENTER)
+    }
+
     // ---- slot fields ----
     private var top: JComponent? = null
     private var body: JComponent? = null
@@ -77,6 +97,19 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
         rebuildCol()
     }
 
+    /**
+     * Optional icon rendered at the left edge of the header row.
+     * Pass `null` to remove the icon while keeping header text alignment stable.
+     */
+    @RequiresEdt
+    fun setHeaderIcon(icon: Icon?, tooltip: String? = null) {
+        this.icon.icon = icon
+        this.icon.toolTipText = tooltip
+        this.icon.isVisible = icon != null
+        this.icon.revalidate()
+        this.icon.repaint()
+    }
+
     /**
      * Replace the body slot that comes after the header/description.
      * Pass `null` to remove the current body.
@@ -116,7 +149,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
     private fun rebuildCol() {
         col.removeAll()
         top?.let { col.add(it) }
-        col.add(headerText)
+        col.add(header)
         col.add(descriptionText)
         body?.let {
             col.add(gap())
@@ -182,7 +215,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
     }
 
     private fun applyFont(area: JBTextArea, bold: Boolean) {
-        val font = if (bold) style.boldEditorFont else style.transcriptFont
+        val font = if (bold) style.boldUiFont else style.uiFont
         if (area.font != font) area.font = font
     }
 }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
index 3e56e999632..a4d98ba2b01 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
@@ -7,10 +7,13 @@ import ai.kilocode.client.session.ui.shared.applyButton
 import ai.kilocode.client.session.ui.shared.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
+import ai.kilocode.client.ui.UiStyle
 import com.intellij.util.concurrency.annotations.RequiresEdt
 import com.intellij.util.ui.components.BorderLayoutPanel
 import java.awt.BorderLayout
 import java.awt.Component
+import javax.swing.Box
+import javax.swing.BoxLayout
 import javax.swing.JPanel
 
 /**
@@ -43,9 +46,15 @@ class LoginRequiredView(
         val footer = JPanel(BorderLayout()).apply {
             isOpaque = false
             alignmentX = Component.LEFT_ALIGNMENT
-            add(dismissButton, BorderLayout.WEST)
-            add(openProfileButton, BorderLayout.EAST)
         }
+        val actions = JPanel().apply {
+            isOpaque = false
+            layout = BoxLayout(this, BoxLayout.X_AXIS)
+            add(dismissButton)
+            add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
+            add(openProfileButton)
+        }
+        footer.add(actions, BorderLayout.EAST)
 
         card.setFooter(footer)
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index 78df4d1d464..119fe5f4ecb 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -14,6 +14,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.UiStyle
 import ai.kilocode.client.ui.md.MdView
 import ai.kilocode.rpc.dto.PermissionReplyDto
+import com.intellij.icons.AllIcons
 import com.intellij.ui.components.JBScrollPane
 import com.intellij.util.ui.JBUI
 import com.intellij.util.ui.components.BorderLayoutPanel
@@ -75,6 +76,7 @@ class PermissionView(
         actions.add(run)
         footer.add(actions, BorderLayout.EAST)
 
+        card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
         card.setBody(body)
         card.setFooter(footer)
         addToCenter(card)
@@ -239,6 +241,8 @@ class PermissionView(
     // Test helpers
     internal fun runButtonForTest() = run
     internal fun denyButtonForTest() = deny
+    internal fun firstCmdViewForTest() = cmdViews.firstOrNull()
+    internal fun headerFontForTest() = card.headerText.font
 }
 
 /**
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
index db1994ef8dd..d78c9bb7802 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
@@ -108,7 +108,9 @@ class QuestionResultView(tool: Tool) : PartView() {
 
     override fun applyStyle(style: SessionEditorStyle) {
         this.style = style
-        val label = setFont(title, style.boldEditorFont) || setFont(sub, style.smallEditorFont)
+        val t = setFont(title, style.boldUiFont)
+        val s = setFont(sub, style.smallUiFont)
+        val label = t || s
         val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
         if (!label && !body) return
         refresh()
@@ -137,6 +139,9 @@ class QuestionResultView(tool: Tool) : PartView() {
 
     fun bodyFonts(): List = texts.map { it.first.font }
 
+    fun titleFont(): Font = title.font
+    fun subFont(): Font = sub.font
+
     override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
 
     companion object {
@@ -268,7 +273,7 @@ class QuestionResultView(tool: Tool) : PartView() {
     }
 
     private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
-        val font = if (bold) style.boldEditorFont else style.transcriptFont
+        val font = if (bold) style.boldUiFont else style.uiFont
         if (area.font == font) return false
         area.font = font
         return true
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index 2711604742f..2ccb0b74d12 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -98,7 +98,6 @@ class QuestionView(
         nav.add(fwd)
         topPanel.add(summary, BorderLayout.WEST)
         topPanel.add(nav, BorderLayout.EAST)
-        footer.add(dismiss, BorderLayout.WEST)
         footer.add(right, BorderLayout.EAST)
 
         card.setTopPanel(topPanel)
@@ -176,9 +175,11 @@ class QuestionView(
 
     private fun syncFooter(q: Question) {
         right.removeAll()
+        right.add(dismiss)
         if (review(q)) {
             val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() }
             val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() }
+            right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
             right.add(back)
             right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
             right.add(submit)
@@ -200,6 +201,7 @@ class QuestionView(
                 }
             }
         }
+        right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
         right.add(button)
     }
 
@@ -207,8 +209,10 @@ class QuestionView(
         val ready = selections.getOrNull(idx)?.isNotEmpty() == true
         back.isEnabled = idx > 0
         fwd.isEnabled = idx < q.items.size && ready
+        val backLabel = KiloBundle.message("session.question.back")
+        val dismissLabel = KiloBundle.message("session.question.dismiss")
         for (node in right.components) {
-            if (node is SessionQuestionButton && node.text != KiloBundle.message("session.question.back")) {
+            if (node is SessionQuestionButton && node.text != backLabel && node.text != dismissLabel) {
                 node.isEnabled = review(q) || ready
             }
         }
@@ -436,7 +440,7 @@ class QuestionView(
     }
 
     private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
-        val font = if (bold) style.boldEditorFont else style.transcriptFont
+        val font = if (bold) style.boldUiFont else style.uiFont
         if (area.font == font) return false
         area.font = font
         return true
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
index b11df3d3320..9d4c32ce42f 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
@@ -50,4 +50,22 @@ class SessionEditorStyleTest : BasePlatformTestCase() {
         assertTrue(style.smallEditorFont.size < style.editorSize)
         assertEquals(style.editorSize, style.uiFont.size)
     }
+
+    fun `test ui fonts use platform label family not editor family`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+
+        // uiFont / boldUiFont / smallUiFont must NOT use the editor font family
+        assertFalse("uiFont should not use editor font family", style.uiFont.name == "Courier New")
+        assertFalse("boldUiFont should not use editor font family", style.boldUiFont.name == "Courier New")
+        assertFalse("smallUiFont should not use editor font family", style.smallUiFont.name == "Courier New")
+    }
+
+    fun `test ui fonts inherit editor size`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+
+        assertEquals("uiFont size should match editor size", 22, style.uiFont.size)
+        assertEquals("boldUiFont size should match editor size", 22, style.boldUiFont.size)
+        assertTrue("boldUiFont should be bold", style.boldUiFont.isBold)
+        assertTrue("smallUiFont should be smaller than editor size", style.smallUiFont.size < style.editorSize)
+    }
 }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
index c9b05745096..b6acff41f82 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
@@ -1,8 +1,12 @@
 package ai.kilocode.client.session.ui.shared
 
+import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import com.intellij.icons.AllIcons
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.ui.components.JBLabel
 import com.intellij.ui.components.JBTextArea
+import java.awt.BorderLayout
 import java.awt.Container
 import javax.swing.JComponent
 import javax.swing.JLabel
@@ -40,7 +44,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
             val col = findCol(panel)!!
             val comps = col.components.toList()
             val topIdx = comps.indexOf(top)
-            val headerIdx = comps.indexOf(panel.headerText)
+            val headerIdx = comps.indexOf(panel.headerText.parent)
             assertTrue("top should appear before headerText", topIdx < headerIdx)
         }
     }
@@ -169,7 +173,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
             val col = findCol(panel)!!
             val comps = col.components.toList()
             val topIdx = comps.indexOf(top)
-            val headerIdx = comps.indexOf(panel.headerText)
+            val headerIdx = comps.indexOf(panel.headerText.parent)
             val descIdx = comps.indexOf(panel.descriptionText)
             val bodyIdx = comps.indexOf(body)
             val footerIdx = comps.indexOf(footer)
@@ -195,7 +199,38 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
         edt {
             val panel = BaseSessionQuestionPanel()
             val col = findCol(panel)!!
-            assertEquals("headerText + descriptionText only", 2, col.componentCount)
+            assertEquals("header row + descriptionText only", 2, col.componentCount)
+        }
+    }
+
+    // ------ header left icon ------
+
+    fun `test setHeaderIcon adds icon to the left side of header row`() {
+        edt {
+            val panel = BaseSessionQuestionPanel()
+            panel.setHeaderIcon(AllIcons.General.Warning, "warning")
+
+            val header = panel.headerText.parent as JPanel
+            val layout = header.layout as BorderLayout
+            val labels = findAll(header).filter { it.icon != null }
+            assertEquals("Expected one header icon", 1, labels.size)
+            assertSame(AllIcons.General.Warning, labels[0].icon)
+            assertEquals("warning", labels[0].toolTipText)
+            assertEquals(BorderLayout.WEST, layout.getConstraints(labels[0]))
+            assertEquals(BorderLayout.CENTER, layout.getConstraints(panel.headerText))
+        }
+    }
+
+    fun `test setHeaderIcon null hides header icon without removing header row`() {
+        edt {
+            val panel = BaseSessionQuestionPanel()
+            panel.setHeaderIcon(AllIcons.General.Warning)
+            panel.setHeaderIcon(null)
+
+            val header = panel.headerText.parent as Container
+            val labels = findAll(header).filter { it.icon != null && it.isVisible }
+            assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty())
+            assertSame(header, panel.headerText.parent)
         }
     }
 
@@ -243,6 +278,36 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
         }
     }
 
+    // ------ applyStyle: UI fonts ------
+
+    fun `test applyStyle applies boldUiFont to header and uiFont to description`() {
+        edt {
+            val panel = BaseSessionQuestionPanel()
+            val style = SessionEditorStyle.create(family = "Courier New", size = 20)
+            panel.applyStyle(style)
+
+            assertEquals("headerText should use boldUiFont", style.boldUiFont, panel.headerText.font)
+            assertEquals("descriptionText should use uiFont", style.uiFont, panel.descriptionText.font)
+        }
+    }
+
+    fun `test applyStyle does not apply editor font family to header or description`() {
+        edt {
+            val panel = BaseSessionQuestionPanel()
+            val style = SessionEditorStyle.create(family = "Courier New", size = 20)
+            panel.applyStyle(style)
+
+            assertFalse(
+                "headerText should not use editor font family",
+                panel.headerText.font.name == "Courier New",
+            )
+            assertFalse(
+                "descriptionText should not use editor font family",
+                panel.descriptionText.font.name == "Courier New",
+            )
+        }
+    }
+
     // ------ helpers ------
 
     private fun  edt(block: () -> T): T {
@@ -270,4 +335,15 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
         }
         return null
     }
+
+    private inline fun  findAll(root: Container): List = findAllCls(root, T::class.java)
+
+    private fun  findAllCls(root: Container, cls: Class): List {
+        val result = mutableListOf()
+        if (cls.isInstance(root)) result.add(cls.cast(root))
+        for (child in root.components) {
+            if (child is Container) result.addAll(findAllCls(child, cls))
+        }
+        return result
+    }
 }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
index 6b2bd18d909..b7972df3a08 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
@@ -1,6 +1,7 @@
 package ai.kilocode.client.session.views
 
 import ai.kilocode.client.session.ui.shared.SessionQuestionButton
+import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
 import com.intellij.openapi.application.ApplicationManager
@@ -105,6 +106,18 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         }
     }
 
+    fun `test login action buttons share right-aligned footer group`() {
+        edt {
+            val view = LoginRequiredView(openProfile = {}, dismiss = {})
+            view.show("Sign in required.")
+
+            val dismiss = view.dismissButton
+            val open = view.openProfileButton
+            assertSame("Dismiss and open profile should be in the same right-aligned group", dismiss.parent, open.parent)
+            assertTrue("Dismiss should appear before open profile", dismiss.parent.components.indexOf(dismiss) < open.parent.components.indexOf(open))
+        }
+    }
+
     // ------ callbacks ------
 
     fun `test open profile button click invokes openProfile callback`() {
@@ -161,6 +174,42 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         }
     }
 
+    // ------ fonts: UI family, editor size ------
+
+    fun `test header uses boldUiFont not editor font family`() {
+        edt {
+            val view = LoginRequiredView(openProfile = {}, dismiss = {})
+            view.show("Sign in required.")
+            val style = SessionEditorStyle.create(family = "Courier New", size = 20)
+            view.applyStyle(style)
+
+            val title = findAll(view).firstOrNull { it.font.isBold }
+            assertNotNull("Bold title text area should be present", title)
+            assertFalse(
+                "Title font should not use editor font family",
+                title!!.font.name == "Courier New",
+            )
+            assertEquals("Title font size should match editor size", 20, title.font.size)
+        }
+    }
+
+    fun `test description uses uiFont not editor font family`() {
+        edt {
+            val view = LoginRequiredView(openProfile = {}, dismiss = {})
+            view.show("Sign in required.")
+            val style = SessionEditorStyle.create(family = "Courier New", size = 20)
+            view.applyStyle(style)
+
+            val desc = findAll(view).firstOrNull { it.text == "Sign in required." }
+            assertNotNull("Description text area should be present", desc)
+            assertFalse(
+                "Description font should not use editor font family",
+                desc!!.font.name == "Courier New",
+            )
+            assertEquals("Description font size should match editor size", 20, desc.font.size)
+        }
+    }
+
     // ------ helpers ------
 
     private fun  edt(block: () -> T): T {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
index 24693425a03..d7153b0c90f 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
@@ -5,11 +5,14 @@ import ai.kilocode.client.session.model.PermissionFileDiff
 import ai.kilocode.client.session.model.PermissionMeta
 import ai.kilocode.client.session.model.PermissionRequestState
 import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
+import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.rpc.dto.PermissionReplyDto
+import com.intellij.icons.AllIcons
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
 import com.intellij.ui.components.JBHtmlPane
+import com.intellij.ui.components.JBLabel
 import com.intellij.ui.components.JBScrollPane
 import java.awt.Container
 import javax.swing.AbstractButton
@@ -262,6 +265,16 @@ class PermissionViewTest : BasePlatformTestCase() {
         assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
     }
 
+    fun `test permission icon is rendered in header`() {
+        view.show(permission())
+
+        val labels = findAll(view)
+        assertTrue(
+            "Expected permission warning icon in header",
+            labels.any { it.icon == AllIcons.General.Warning },
+        )
+    }
+
     // ------ new: shared button types ------
 
     fun `test run button is SessionQuestionButton with primary true`() {
@@ -355,6 +368,47 @@ class PermissionViewTest : BasePlatformTestCase() {
         assertEquals(SessionUiStyle.View.headerHover(), scroll.viewport.background)
     }
 
+    // ------ fonts: header UI family, command code block editor family ------
+
+    fun `test permission header uses boldUiFont not editor font family`() {
+        view.show(
+            Permission(
+                id = "perm_font",
+                sessionId = "ses",
+                name = "bash",
+                patterns = emptyList(),
+                always = emptyList(),
+                meta = PermissionMeta(command = "ls"),
+            )
+        )
+        val style = SessionEditorStyle.create(family = "Courier New", size = 18)
+        view.applyStyle(style)
+
+        val header = view.headerFontForTest()
+        assertFalse("Permission header should not use editor font family", header.name == "Courier New")
+        assertTrue("Permission header should be bold", header.isBold)
+        assertEquals("Permission header size should match editor size", 18, header.size)
+    }
+
+    fun `test command code block retains editor font family`() {
+        view.show(
+            Permission(
+                id = "perm_codefont",
+                sessionId = "ses",
+                name = "bash",
+                patterns = emptyList(),
+                always = emptyList(),
+                meta = PermissionMeta(command = "git log"),
+            )
+        )
+        val style = SessionEditorStyle.create(family = "Courier New", size = 18)
+        view.applyStyle(style)
+
+        val md = view.firstCmdViewForTest()
+        assertNotNull("Should have at least one command MdView", md)
+        assertEquals("Code block codeFont should use editor family", "Courier New", md!!.codeFont)
+    }
+
     private fun permission() = Permission(
         id = "perm1",
         sessionId = "ses_test",
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
index 9038cbffb3e..346ffb97d8d 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
@@ -166,7 +166,7 @@ class QuestionResultViewTest : BasePlatformTestCase() {
 
     // ------ applyStyle ------
 
-    fun `test applyStyle updates fonts`() {
+    fun `test applyStyle updates body fonts to UI font family`() {
         val tool = completedTool(
             input = mapOf("questions" to """[{"question":"Q1"}]"""),
             metadata = mapOf("answers" to """[["A1"]]"""),
@@ -177,8 +177,25 @@ class QuestionResultViewTest : BasePlatformTestCase() {
         view.applyStyle(style)
         view.toggle()
 
-        assertTrue(view.bodyFonts().contains(style.transcriptFont))
-        assertTrue(view.bodyFonts().contains(style.boldEditorFont))
+        assertTrue(view.bodyFonts().contains(style.uiFont))
+        assertTrue(view.bodyFonts().contains(style.boldUiFont))
+        assertFalse("Body should not use editor transcript font", view.bodyFonts().any { it.name == "Courier New" })
+    }
+
+    fun `test applyStyle updates header label fonts to UI font family`() {
+        val tool = completedTool(
+            input = mapOf("questions" to """[{"question":"Q1"}]"""),
+            metadata = mapOf("answers" to """[["A1"]]"""),
+        )
+        val view = QuestionResultView(tool)
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+
+        view.applyStyle(style)
+
+        assertEquals("Title should use boldUiFont", style.boldUiFont, view.titleFont())
+        assertEquals("Subtitle should use smallUiFont", style.smallUiFont, view.subFont())
+        assertFalse("Title should not use editor font family", view.titleFont().name == "Courier New")
+        assertFalse("Subtitle should not use editor font family", view.subFont().name == "Courier New")
     }
 
     // ------ update ------
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index dff95461e65..6932aad49e2 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -87,6 +87,31 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertTrue(replies.isEmpty())
     }
 
+    fun `test question action buttons share right-aligned footer group`() {
+        view.show(singleSelectQuestion("req_actions"))
+
+        val dismiss = button(view, "Dismiss")
+        val submit = button(view, "Submit")
+        assertSame("Dismiss and Submit should be in the same right-aligned group", dismiss.parent, submit.parent)
+        assertTrue("Dismiss should appear before Submit", dismiss.parent.components.indexOf(dismiss) < submit.parent.components.indexOf(submit))
+    }
+
+    fun `test review action buttons share right-aligned footer group`() {
+        view.show(twoItemQuestion("req_review_actions"))
+        option(view, "Minimal").doClick()
+        button(view, "Next").doClick()
+        option(view, "Unit").doClick()
+        button(view, "Review").doClick()
+
+        val dismiss = button(view, "Dismiss")
+        val back = button(view, "Back")
+        val submit = button(view, "Submit")
+        assertSame("Dismiss and Back should be in the same right-aligned group", dismiss.parent, back.parent)
+        assertSame("Back and Submit should be in the same right-aligned group", back.parent, submit.parent)
+        assertTrue("Dismiss should appear before Back", dismiss.parent.components.indexOf(dismiss) < back.parent.components.indexOf(back))
+        assertTrue("Back should appear before Submit", back.parent.components.indexOf(back) < submit.parent.components.indexOf(submit))
+    }
+
     // ------ radio options ------
 
     fun `test single question renders radio options`() {
@@ -159,19 +184,19 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals("description should align in the text renderer", label.parent, desc.parent)
 
         val style = SessionEditorStyle.current()
-        assertEquals("option label should use bold editor font", style.boldEditorFont, label.font)
-        assertEquals("description should use transcript font", style.transcriptFont, desc.font)
+        assertEquals("option label should use boldUiFont", style.boldUiFont, label.font)
+        assertEquals("description should use uiFont", style.uiFont, desc.font)
     }
 
-    fun `test question title and hint use editor fonts`() {
+    fun `test question title and hint use UI-family editor-sized fonts`() {
         view.show(singleSelectQuestion("q_fonts"))
 
         val style = SessionEditorStyle.current()
         val title = text(view, "Choose approach")
         val hint = text(view, "Select one answer")
 
-        assertEquals(style.boldEditorFont, title.font)
-        assertEquals(style.transcriptFont, hint.font)
+        assertEquals(style.boldUiFont, title.font)
+        assertEquals(style.uiFont, hint.font)
     }
 
     // ------ multi-question navigation ------

From 46b0ea1a17b8afedd52ce43b40fabb45b097d766 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 10:35:23 -0400
Subject: [PATCH 05/21] fix(jetbrains): tune question panel typography

---
 .../ui/shared/BaseSessionQuestionPanel.kt      | 10 ++++++++--
 .../session/views/question/QuestionView.kt     |  2 --
 .../ui/shared/BaseSessionQuestionPanelTest.kt  | 18 +++++++++++++++---
 .../session/views/LoginRequiredViewTest.kt     |  4 ++--
 .../client/session/views/PermissionViewTest.kt |  2 +-
 .../client/session/views/QuestionViewTest.kt   |  6 ++++--
 6 files changed, 30 insertions(+), 12 deletions(-)

diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
index ed36d799f32..e16d917dffa 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
@@ -13,6 +13,7 @@ import java.awt.BorderLayout
 import java.awt.Color
 import java.awt.Component
 import java.awt.Dimension
+import java.awt.Font
 import javax.swing.Box
 import javax.swing.BoxLayout
 import javax.swing.Icon
@@ -48,7 +49,9 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
     val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
 
     // ---- description text ----
-    val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
+    val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply {
+        border = JBUI.Borders.emptyTop(UiStyle.Gap.sm())
+    }
 
     private val icon = JBLabel().apply {
         border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
@@ -215,7 +218,10 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
     }
 
     private fun applyFont(area: JBTextArea, bold: Boolean) {
-        val font = if (bold) style.boldUiFont else style.uiFont
+        val base = if (bold) style.boldUiFont else style.uiFont
+        val font = larger(base)
         if (area.font != font) area.font = font
     }
+
+    private fun larger(font: Font): Font = font.deriveFont((font.size + 1).toFloat())
 }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index 2ccb0b74d12..db2bc0f423f 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -151,11 +151,9 @@ class QuestionView(
         } else {
             val item = q.items[idx]
             card.headerText.text = item.question
-            card.headerText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
             card.descriptionText.text = KiloBundle.message(
                 if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"
             )
-            card.descriptionText.border = JBUI.Borders.empty()
             card.descriptionText.isVisible = true
             addContent(item, selections[idx])
         }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
index b6acff41f82..58e56b2da45 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
@@ -1,6 +1,7 @@
 package ai.kilocode.client.session.ui.shared
 
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.ui.UiStyle
 import com.intellij.icons.AllIcons
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -280,14 +281,25 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     // ------ applyStyle: UI fonts ------
 
-    fun `test applyStyle applies boldUiFont to header and uiFont to description`() {
+    fun `test applyStyle applies enlarged boldUiFont to header and enlarged uiFont to description`() {
         edt {
             val panel = BaseSessionQuestionPanel()
             val style = SessionEditorStyle.create(family = "Courier New", size = 20)
             panel.applyStyle(style)
 
-            assertEquals("headerText should use boldUiFont", style.boldUiFont, panel.headerText.font)
-            assertEquals("descriptionText should use uiFont", style.uiFont, panel.descriptionText.font)
+            assertEquals("headerText should keep boldUiFont family", style.boldUiFont.name, panel.headerText.font.name)
+            assertEquals("descriptionText should keep uiFont family", style.uiFont.name, panel.descriptionText.font.name)
+            assertEquals("headerText should use next font size", style.boldUiFont.size + 1, panel.headerText.font.size)
+            assertEquals("descriptionText should use next font size", style.uiFont.size + 1, panel.descriptionText.font.size)
+        }
+    }
+
+    fun `test description uses next standard top padding`() {
+        edt {
+            val panel = BaseSessionQuestionPanel()
+            val ins = panel.descriptionText.border.getBorderInsets(panel.descriptionText)
+
+            assertEquals("description top padding should use next standard gap", UiStyle.Gap.sm(), ins.top)
         }
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
index b7972df3a08..b291b4bd25d 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
@@ -189,7 +189,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
                 "Title font should not use editor font family",
                 title!!.font.name == "Courier New",
             )
-            assertEquals("Title font size should match editor size", 20, title.font.size)
+            assertEquals("Title font size should use next size", 21, title.font.size)
         }
     }
 
@@ -206,7 +206,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
                 "Description font should not use editor font family",
                 desc!!.font.name == "Courier New",
             )
-            assertEquals("Description font size should match editor size", 20, desc.font.size)
+            assertEquals("Description font size should use next size", 21, desc.font.size)
         }
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
index d7153b0c90f..03fa4825d48 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
@@ -387,7 +387,7 @@ class PermissionViewTest : BasePlatformTestCase() {
         val header = view.headerFontForTest()
         assertFalse("Permission header should not use editor font family", header.name == "Courier New")
         assertTrue("Permission header should be bold", header.isBold)
-        assertEquals("Permission header size should match editor size", 18, header.size)
+        assertEquals("Permission header size should use next size", 19, header.size)
     }
 
     fun `test command code block retains editor font family`() {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index 6932aad49e2..61e8f7b1540 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -195,8 +195,10 @@ class QuestionViewTest : BasePlatformTestCase() {
         val title = text(view, "Choose approach")
         val hint = text(view, "Select one answer")
 
-        assertEquals(style.boldUiFont, title.font)
-        assertEquals(style.uiFont, hint.font)
+        assertEquals(style.boldUiFont.name, title.font.name)
+        assertEquals(style.uiFont.name, hint.font.name)
+        assertEquals(style.boldUiFont.size + 1, title.font.size)
+        assertEquals(style.uiFont.size + 1, hint.font.size)
     }
 
     // ------ multi-question navigation ------

From 525bfc9b32b5429f50e4082ead0caf4ad1526ecc Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 12:54:18 -0400
Subject: [PATCH 06/21] fix(jetbrains): collapse expandable session views

---
 .changeset/session-expandable-defaults.md     |  5 ++
 .../session/ui/header/SessionHeaderPanel.kt   |  2 +-
 .../client/session/views/ReasoningView.kt     | 11 +----
 .../ui/header/SessionHeaderPanelTest.kt       | 21 +++++---
 .../client/session/views/ReasoningViewTest.kt | 49 +++++++++----------
 5 files changed, 43 insertions(+), 45 deletions(-)
 create mode 100644 .changeset/session-expandable-defaults.md

diff --git a/.changeset/session-expandable-defaults.md b/.changeset/session-expandable-defaults.md
new file mode 100644
index 00000000000..905b3adb933
--- /dev/null
+++ b/.changeset/session-expandable-defaults.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": patch
+---
+
+Start expandable session sections collapsed by default.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
index 615f52df59e..c1ff05c96d5 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
@@ -391,7 +391,7 @@ class SessionHeaderPanel(
         expand.accessibleContext.accessibleName = KiloBundle.message(key)
     }
 
-    private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, true)
+    private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, false)
 
     private fun sizeTimeline() {
         val size = timeline.preferredSize
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
index bec8c6b6fad..709264e5ad5 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
@@ -109,7 +109,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
         body.add(md.component, BorderLayout.CENTER)
 
         add(header, BorderLayout.NORTH)
-        if (canExpand()) add(scroll, BorderLayout.CENTER)
         sync()
     }
 
@@ -122,7 +121,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
             md.set(source)
             changed = true
         }
-        changed = syncBody() || changed
         changed = sync() || changed
         if (changed) refresh()
     }
@@ -131,8 +129,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
         if (delta.isEmpty()) return
         source += delta
         md.append(delta)
-        var changed = syncBody()
-        changed = sync() || changed
+        val changed = sync()
         if (changed || bodyVisible()) refresh()
     }
 
@@ -209,12 +206,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
         return changed
     }
 
-    private fun syncBody(): Boolean {
-        if (!canExpand()) return collapse()
-        if (bodyVisible()) return false
-        return expand()
-    }
-
     private fun setVisible(component: JBLabel, visible: Boolean): Boolean {
         if (component.isVisible == visible) return false
         component.isVisible = visible
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt
index 217c8ca94d2..361d1846e1c 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt
@@ -54,7 +54,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
         val style = SessionEditorStyle.current()
 
         assertTrue(panel.isVisible)
-        assertTrue(panel.isExpanded())
+        assertFalse(panel.isExpanded())
         assertEquals("Generated title", panel.titleText())
         assertEquals("$0.07", panel.costText())
         assertEquals("1%", panel.contextText())
@@ -135,6 +135,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
         val timeline = panel.timelinePanel()
         val bar = panel.contextBar()
 
+        assertFalse(panel.isExpanded())
+        panel.expandButton().doClick()
+
         assertTrue(panel.isExpanded())
         assertSame(body, panel.bodyPanel())
         assertSame(timeline, panel.timelinePanel())
@@ -244,26 +247,27 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
         val c = promptedHeader()
         val panel = SessionHeaderPanel(c, parent)
 
-        assertTrue(panel.isExpanded())
-        assertEquals("Hide session metrics", panel.expandTip())
-
-        panel.expandButton().doClick()
-        emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title")))
-
         assertFalse(panel.isExpanded())
         assertEquals("Show session metrics", panel.expandTip())
 
         panel.expandButton().doClick()
-        emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2)))
+        emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title")))
 
         assertTrue(panel.isExpanded())
         assertEquals("Hide session metrics", panel.expandTip())
+
+        panel.expandButton().doClick()
+        emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2)))
+
+        assertFalse(panel.isExpanded())
+        assertEquals("Show session metrics", panel.expandTip())
     }
 
     fun `test collapse persists and new header starts collapsed`() {
         val c = promptedHeader()
         val panel = SessionHeaderPanel(c, parent)
 
+        panel.expandButton().doClick()
         panel.expandButton().doClick()
 
         assertFalse(panel.isExpanded())
@@ -294,6 +298,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
     }
 
     fun `test hidden empty header collapse keeps saved expansion preference`() {
+        PropertiesComponent.getInstance().setValue(SessionHeaderPanel.EXPANDED_KEY, "true")
         appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
         projectRpc.state.value = workspaceReady()
         val c = controller()
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt
index 6cae7834def..f0cf4d20169 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt
@@ -8,61 +8,58 @@ import javax.swing.ScrollPaneConstants
 @Suppress("UnstableApiUsage")
 class ReasoningViewTest : BasePlatformTestCase() {
 
-    fun `test completed reasoning is expanded by default`() {
+    fun `test completed reasoning is collapsed by default`() {
         val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour"))
 
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
         assertEquals("Reasoning", view.headerText())
         assertEquals("one\ntwo\nthree\nfour", view.markdown())
         assertTrue(view.hasToggle())
-        assertTrue(view.bodyVisible())
+        assertFalse(view.bodyVisible())
         assertTrue(view.bodyCreated())
     }
 
     fun `test short completed reasoning is collapsible`() {
         val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree"))
 
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
         assertTrue(view.hasToggle())
         view.toggle()
-        assertFalse(view.isExpanded())
-        assertFalse(view.bodyVisible())
+        assertTrue(view.isExpanded())
+        assertTrue(view.bodyVisible())
         assertTrue(view.bodyCreated())
     }
 
-    fun `test streaming reasoning is expanded by default`() {
+    fun `test streaming reasoning is collapsed by default`() {
         val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour"))
 
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
         assertTrue(view.hasToggle())
     }
 
-    fun `test update to done preserves visible reasoning`() {
+    fun `test update to done preserves collapsed reasoning`() {
         val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour"))
 
         view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour"))
 
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
         assertEquals("one\ntwo\nthree\nfour", view.markdown())
     }
 
     fun `test toggle opens and closes reasoning`() {
         val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour"))
 
+        view.toggle()
         assertTrue(view.isExpanded())
         view.toggle()
         assertFalse(view.isExpanded())
-        view.toggle()
-        assertTrue(view.isExpanded())
     }
 
-    fun `test collapsed reasoning expands on update`() {
+    fun `test collapsed reasoning stays collapsed on update`() {
         val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo"))
-
-        view.toggle()
         view.update(reasoning("p1", done = true, text = "one\ntwo\nthree"))
 
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
         assertEquals("one\ntwo\nthree", view.markdown())
     }
 
@@ -72,39 +69,38 @@ class ReasoningViewTest : BasePlatformTestCase() {
         view.appendDelta("b")
 
         assertEquals("ab", view.markdown())
-        assertTrue(view.isExpanded())
+        assertFalse(view.isExpanded())
     }
 
-    fun `test blank reasoning expands when delta arrives`() {
+    fun `test blank reasoning stays collapsed when delta arrives`() {
         val view = ReasoningView(reasoning("p1", done = false, text = ""))
 
         view.appendDelta("b")
 
         assertEquals("b", view.markdown())
         assertTrue(view.bodyCreated())
-        assertTrue(view.bodyVisible())
+        assertFalse(view.bodyVisible())
+        assertTrue(view.hasToggle())
     }
 
-    fun `test collapsed append reattaches eager reasoning body`() {
+    fun `test collapsed append keeps eager reasoning body detached`() {
         val view = ReasoningView(reasoning("p1", done = false, text = "a"))
-        view.toggle()
 
         view.appendDelta("b")
 
         assertEquals("ab", view.markdown())
         assertTrue(view.bodyCreated())
-        assertTrue(view.bodyVisible())
+        assertFalse(view.bodyVisible())
     }
 
-    fun `test collapsed update reattaches eager reasoning body`() {
+    fun `test collapsed update keeps eager reasoning body detached`() {
         val view = ReasoningView(reasoning("p1", done = false, text = "a"))
-        view.toggle()
 
         view.update(reasoning("p1", done = false, text = "abc"))
 
         assertEquals("abc", view.markdown())
         assertTrue(view.bodyCreated())
-        assertTrue(view.bodyVisible())
+        assertFalse(view.bodyVisible())
     }
 
     fun `test reasoning reuses eager markdown body`() {
@@ -116,7 +112,7 @@ class ReasoningViewTest : BasePlatformTestCase() {
         view.toggle()
 
         assertSame(component, view.md.component)
-        assertFalse(view.bodyVisible())
+        assertTrue(view.bodyVisible())
     }
 
     fun `test blank reasoning has no toggle`() {
@@ -158,6 +154,7 @@ class ReasoningViewTest : BasePlatformTestCase() {
 
     fun `test expanded reasoning body is capped to five rows`() {
         val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" }))
+        view.toggle()
 
         assertEquals(5, view.bodyMaxRows())
         assertTrue(view.preferredSize.height > 0)

From 930f2cf9bc18d42b024a9a37ce2ef091c532b181 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 13:23:14 -0400
Subject: [PATCH 07/21] refactor(jetbrains): move PartView, GenericView,
 BaseSessionQuestionPanel, SessionQuestionButton to views.base

---
 .../ai/kilocode/client/session/views/CompactionView.kt    | 1 +
 .../ai/kilocode/client/session/views/LoginRequiredView.kt | 6 +++---
 .../ai/kilocode/client/session/views/MessageView.kt       | 1 +
 .../ai/kilocode/client/session/views/PermissionView.kt    | 8 ++++----
 .../ai/kilocode/client/session/views/ReasoningView.kt     | 1 +
 .../kotlin/ai/kilocode/client/session/views/TextView.kt   | 1 +
 .../kotlin/ai/kilocode/client/session/views/ToolView.kt   | 1 +
 .../ai/kilocode/client/session/views/ViewFactory.kt       | 2 ++
 .../{ui/shared => views/base}/BaseSessionQuestionPanel.kt | 2 +-
 .../client/session/views/{ => base}/GenericView.kt        | 2 +-
 .../kilocode/client/session/views/{ => base}/PartView.kt  | 8 ++++----
 .../{ui/shared => views/base}/SessionQuestionButton.kt    | 2 +-
 .../client/session/views/question/QuestionResultView.kt   | 2 +-
 .../client/session/views/question/QuestionView.kt         | 8 ++++----
 .../ai/kilocode/client/session/ui/SessionUiUpdateTest.kt  | 4 ++--
 .../client/session/views/LoginRequiredViewTest.kt         | 2 +-
 .../kilocode/client/session/views/PermissionViewTest.kt   | 2 +-
 .../ai/kilocode/client/session/views/QuestionViewTest.kt  | 2 +-
 .../shared => views/base}/BaseSessionQuestionPanelTest.kt | 2 +-
 19 files changed, 32 insertions(+), 25 deletions(-)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ui/shared => views/base}/BaseSessionQuestionPanel.kt (99%)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/{ => base}/GenericView.kt (96%)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/{ => base}/PartView.kt (77%)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ui/shared => views/base}/SessionQuestionButton.kt (96%)
 rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ui/shared => views/base}/BaseSessionQuestionPanelTest.kt (99%)

diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
index df406de4762..26ea0a996c2 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Compaction
 import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.UiStyle
 import com.intellij.ui.components.JBLabel
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
index a4d98ba2b01..4ab01a480e2 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
@@ -2,9 +2,9 @@ package ai.kilocode.client.session.views
 
 import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
-import ai.kilocode.client.session.ui.shared.applyButton
-import ai.kilocode.client.session.ui.shared.dismissButton
+import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.applyButton
+import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.ui.UiStyle
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
index a01ca5838b6..e2db58c0c7e 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
@@ -9,6 +9,7 @@ import ai.kilocode.client.session.model.ToolExecState
 import ai.kilocode.client.session.ui.SessionView
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import com.intellij.ui.RoundedLineBorder
 import com.intellij.util.ui.JBUI
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index 119fe5f4ecb..ec74c7b7e36 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -4,10 +4,10 @@ import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.model.Permission
 import ai.kilocode.client.session.model.PermissionRequestState
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
-import ai.kilocode.client.session.ui.shared.SessionQuestionButton
-import ai.kilocode.client.session.ui.shared.applyButton
-import ai.kilocode.client.session.ui.shared.dismissButton
+import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.SessionQuestionButton
+import ai.kilocode.client.session.views.base.applyButton
+import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
index 709264e5ad5..7faf0e3ac9b 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt
@@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.session.model.Reasoning
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.UiStyle
 import ai.kilocode.client.ui.md.MdView
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt
index e08880b9017..86ab85cdbec 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
 import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.session.model.Text
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.ui.md.MdView
 import java.awt.BorderLayout
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt
index 39048dd38af..2cb3a4f30b4 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt
@@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.session.model.Tool
 import ai.kilocode.client.session.model.ToolExecState
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.UiStyle
 import com.intellij.icons.AllIcons
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt
index 4d9483bbcc6..59c9b360dc2 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt
@@ -1,5 +1,7 @@
 package ai.kilocode.client.session.views
 
+import ai.kilocode.client.session.views.base.GenericView
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.views.question.QuestionResultView
 import ai.kilocode.client.session.model.Compaction
 import ai.kilocode.client.session.model.Content
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt
similarity index 99%
rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt
index e16d917dffa..4b49ed5ca88 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt
@@ -1,4 +1,4 @@
-package ai.kilocode.client.session.ui.shared
+package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
similarity index 96%
rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt
rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
index 31db00f4ecd..7ecf9d71716 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
@@ -1,4 +1,4 @@
-package ai.kilocode.client.session.views
+package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.session.model.Generic
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt
similarity index 77%
rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt
rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt
index 1d32c764b7d..9cfacf9b41c 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt
@@ -1,4 +1,4 @@
-package ai.kilocode.client.session.views
+package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.model.Content
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -10,7 +10,7 @@ import javax.swing.JPanel
  *
  * Each subclass wraps one [Content] subtype and knows how to display
  * and update it. Subclasses extend [JPanel] so they can be added directly
- * to [MessageView] without an extra component wrapper.
+ * to [ai.kilocode.client.session.views.MessageView] without an extra component wrapper.
  *
  * All methods must be called on the EDT.
  */
@@ -27,8 +27,8 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget {
 
     /**
      * Append a streaming delta to the existing content.
-     * Only meaningful for text-bearing renderers ([TextView], [ReasoningView]);
-     * others ignore deltas by default.
+     * Only meaningful for text-bearing renderers ([ai.kilocode.client.session.views.TextView],
+     * [ai.kilocode.client.session.views.ReasoningView]); others ignore deltas by default.
      */
     open fun appendDelta(delta: String) {}
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
similarity index 96%
rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt
rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
index be821ffa30e..74662b4b485 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
@@ -1,4 +1,4 @@
-package ai.kilocode.client.session.ui.shared
+package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
index d78c9bb7802..a325d53104d 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
@@ -5,7 +5,7 @@ import ai.kilocode.client.session.model.Content
 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.PartView
+import ai.kilocode.client.session.views.base.PartView
 import ai.kilocode.client.session.views.ToolView
 import ai.kilocode.client.ui.UiStyle
 import com.intellij.icons.AllIcons
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index db2bc0f423f..f01d4217d53 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -5,10 +5,10 @@ import ai.kilocode.client.session.model.Question
 import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
-import ai.kilocode.client.session.ui.shared.SessionQuestionButton
-import ai.kilocode.client.session.ui.shared.applyButton
-import ai.kilocode.client.session.ui.shared.dismissButton
+import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.SessionQuestionButton
+import ai.kilocode.client.session.views.base.applyButton
+import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.ui.HoverIcon
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
index 375ae454afb..161fa553118 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
@@ -125,8 +125,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
         val mv = panel.findMessage("a1")!!
         val gv = mv.part("g1")
         assertNotNull(gv)
-        assertTrue(gv is ai.kilocode.client.session.views.GenericView)
-        assertTrue((gv as ai.kilocode.client.session.views.GenericView).labelText().contains("snapshot"))
+        assertTrue(gv is ai.kilocode.client.session.views.base.GenericView)
+        assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot"))
     }
 
     // ------ silent part types ------
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
index b291b4bd25d..9b2291978b6 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
@@ -1,6 +1,6 @@
 package ai.kilocode.client.session.views
 
-import ai.kilocode.client.session.ui.shared.SessionQuestionButton
+import ai.kilocode.client.session.views.base.SessionQuestionButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
index 03fa4825d48..ac678b2ef97 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
@@ -4,7 +4,7 @@ import ai.kilocode.client.session.model.Permission
 import ai.kilocode.client.session.model.PermissionFileDiff
 import ai.kilocode.client.session.model.PermissionMeta
 import ai.kilocode.client.session.model.PermissionRequestState
-import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.rpc.dto.PermissionReplyDto
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index 61e8f7b1540..3051e466aed 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -3,7 +3,7 @@ package ai.kilocode.client.session.views
 import ai.kilocode.client.session.model.Question
 import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
-import ai.kilocode.client.session.ui.shared.SessionQuestionButton
+import ai.kilocode.client.session.views.base.SessionQuestionButton
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.views.question.QuestionView
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt
similarity index 99%
rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt
index 58e56b2da45..13f7078a66e 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt
@@ -1,4 +1,4 @@
-package ai.kilocode.client.session.ui.shared
+package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.ui.UiStyle

From d4757e8f00243102a68b5b2aacee2b21005f5427 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 13:25:01 -0400
Subject: [PATCH 08/21] refactor(jetbrains): rename BaseSessionQuestionPanel to
 BaseQuestionView, drop SessionQuestionButton

---
 .../client/session/views/LoginRequiredView.kt |  4 +-
 .../client/session/views/PermissionView.kt    |  5 +-
 ...onQuestionPanel.kt => BaseQuestionView.kt} | 40 ++++++++++++++-
 .../views/base/SessionQuestionButton.kt       | 41 ----------------
 .../session/views/question/QuestionView.kt    |  4 +-
 .../session/views/LoginRequiredViewTest.kt    |  1 -
 .../session/views/PermissionViewTest.kt       |  4 +-
 ...onPanelTest.kt => BaseQuestionViewTest.kt} | 49 +++++++++----------
 8 files changed, 71 insertions(+), 77 deletions(-)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/{BaseSessionQuestionPanel.kt => BaseQuestionView.kt} (82%)
 delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
 rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/{BaseSessionQuestionPanelTest.kt => BaseQuestionViewTest.kt} (90%)

diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
index 4ab01a480e2..f6bdf9fa30e 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
@@ -2,7 +2,7 @@ package ai.kilocode.client.session.views
 
 import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.views.base.applyButton
 import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -31,7 +31,7 @@ class LoginRequiredView(
 
     override val sessionViewKind = SessionView.Kind.Default
 
-    private val card = BaseSessionQuestionPanel()
+    private val card = BaseQuestionView()
     val openProfileButton = applyButton(KiloBundle.message("session.login.required.button")) { openProfile() }
     val dismissButton = dismissButton(KiloBundle.message("session.login.required.dismiss")) { dismiss() }
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index ec74c7b7e36..f753e11a4fd 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -4,8 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.model.Permission
 import ai.kilocode.client.session.model.PermissionRequestState
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
-import ai.kilocode.client.session.views.base.SessionQuestionButton
+import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.views.base.applyButton
 import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -41,7 +40,7 @@ class PermissionView(
     private var requestId: String? = null
     private var style = SessionEditorStyle.current()
 
-    private val card = BaseSessionQuestionPanel()
+    private val card = BaseQuestionView()
 
     private val body = JPanel().apply {
         layout = BoxLayout(this, BoxLayout.Y_AXIS)
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
similarity index 82%
rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt
rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
index 4b49ed5ca88..da2593d25d8 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.RoundedContentPanel
 import ai.kilocode.client.ui.UiStyle
+import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
 import com.intellij.ui.components.JBLabel
 import com.intellij.ui.components.JBTextArea
 import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -17,6 +18,7 @@ import java.awt.Font
 import javax.swing.Box
 import javax.swing.BoxLayout
 import javax.swing.Icon
+import javax.swing.JButton
 import javax.swing.JComponent
 import javax.swing.JPanel
 
@@ -35,7 +37,7 @@ import javax.swing.JPanel
  * [descriptionText], optional body, optional footer. Call [setTopPanel],
  * [setHeaderIcon], [setBody], or [setFooter] to replace those slots at any time.
  */
-class BaseSessionQuestionPanel : RoundedContentPanel(
+class BaseQuestionView : RoundedContentPanel(
     UiStyle.Gap.lg(),
     UiStyle.Gap.pad(),
 ), SessionEditorStyleTarget {
@@ -225,3 +227,39 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
 
     private fun larger(font: Font): Font = font.deriveFont((font.size + 1).toFloat())
 }
+
+/**
+ * A [javax.swing.JButton] variant used inside session question/login-required panels.
+ *
+ * Primary buttons receive [com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI.DEFAULT_STYLE_KEY] so they use the
+ * platform's default-button accent. Buttons keep the standard Look-and-Feel
+ * border, padding, disabled state, and focus painting, while their component
+ * background follows the question card surface so border/focus chrome blends
+ * into the inline panel instead of the surrounding transcript.
+ */
+class SessionQuestionButton(text: String, val primary: Boolean) : JButton(text) {
+
+    init {
+        if (primary) {
+            putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
+        }
+        syncBackground()
+    }
+
+    override fun updateUI() {
+        super.updateUI()
+        syncBackground()
+    }
+
+    private fun syncBackground() {
+        background = SessionUiStyle.View.surface()
+    }
+}
+
+/** Create a non-primary (secondary) session question button. */
+fun dismissButton(text: String, action: () -> Unit): SessionQuestionButton =
+    SessionQuestionButton(text, primary = false).apply { addActionListener { action() } }
+
+/** Create a primary (default/accent) session question button. */
+fun applyButton(text: String, action: () -> Unit): SessionQuestionButton =
+    SessionQuestionButton(text, primary = true).apply { addActionListener { action() } }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
deleted file mode 100644
index 74662b4b485..00000000000
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SessionQuestionButton.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package ai.kilocode.client.session.views.base
-
-import ai.kilocode.client.session.ui.style.SessionUiStyle
-import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
-import javax.swing.JButton
-
-/**
- * A [JButton] variant used inside session question/login-required panels.
- *
- * Primary buttons receive [DarculaButtonUI.DEFAULT_STYLE_KEY] so they use the
- * platform's default-button accent. Buttons keep the standard Look-and-Feel
- * border, padding, disabled state, and focus painting, while their component
- * background follows the question card surface so border/focus chrome blends
- * into the inline panel instead of the surrounding transcript.
- */
-class SessionQuestionButton(text: String, val primary: Boolean) : JButton(text) {
-
-    init {
-        if (primary) {
-            putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
-        }
-        syncBackground()
-    }
-
-    override fun updateUI() {
-        super.updateUI()
-        syncBackground()
-    }
-
-    private fun syncBackground() {
-        background = SessionUiStyle.View.surface()
-    }
-}
-
-/** Create a non-primary (secondary) session question button. */
-fun dismissButton(text: String, action: () -> Unit): SessionQuestionButton =
-    SessionQuestionButton(text, primary = false).apply { addActionListener { action() } }
-
-/** Create a primary (default/accent) session question button. */
-fun applyButton(text: String, action: () -> Unit): SessionQuestionButton =
-    SessionQuestionButton(text, primary = true).apply { addActionListener { action() } }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index f01d4217d53..52589840168 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -5,7 +5,7 @@ import ai.kilocode.client.session.model.Question
 import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
 import ai.kilocode.client.session.ui.SessionView
-import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.views.base.SessionQuestionButton
 import ai.kilocode.client.session.views.base.applyButton
 import ai.kilocode.client.session.views.base.dismissButton
@@ -49,7 +49,7 @@ class QuestionView(
     private var style = SessionEditorStyle.current()
     private val texts = mutableListOf>()
 
-    private val card = BaseSessionQuestionPanel()
+    private val card = BaseQuestionView()
 
     private val summary = JBLabel()
     private val nav = JPanel().apply {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
index 9b2291978b6..279ff5f88a1 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
@@ -8,7 +8,6 @@ import com.intellij.openapi.application.ApplicationManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
 import com.intellij.ui.components.JBTextArea
 import java.awt.Container
-import javax.swing.JButton
 
 @Suppress("UnstableApiUsage")
 class LoginRequiredViewTest : BasePlatformTestCase() {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
index ac678b2ef97..59893ff6ee6 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
@@ -4,7 +4,7 @@ import ai.kilocode.client.session.model.Permission
 import ai.kilocode.client.session.model.PermissionFileDiff
 import ai.kilocode.client.session.model.PermissionMeta
 import ai.kilocode.client.session.model.PermissionRequestState
-import ai.kilocode.client.session.views.base.BaseSessionQuestionPanel
+import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.rpc.dto.PermissionReplyDto
@@ -261,7 +261,7 @@ class PermissionViewTest : BasePlatformTestCase() {
     fun `test view contains BaseSessionQuestionPanel after show`() {
         view.show(permission())
 
-        val panels = findAll(view)
+        val panels = findAll(view)
         assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
similarity index 90%
rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt
rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
index 13f7078a66e..41ecc212b50 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseSessionQuestionPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
@@ -6,7 +6,6 @@ import com.intellij.icons.AllIcons
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
 import com.intellij.ui.components.JBLabel
-import com.intellij.ui.components.JBTextArea
 import java.awt.BorderLayout
 import java.awt.Container
 import javax.swing.JComponent
@@ -14,13 +13,13 @@ import javax.swing.JLabel
 import javax.swing.JPanel
 
 @Suppress("UnstableApiUsage")
-class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
+class BaseQuestionViewTest : BasePlatformTestCase() {
 
     // ------ initial state ------
 
     fun `test headerText and descriptionText are in the component tree by default`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             assertNotNull("headerText should be present", find(panel, panel.headerText))
             assertNotNull("descriptionText should be present", find(panel, panel.descriptionText))
         }
@@ -28,7 +27,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test header and description have correct initial text`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             assertEquals("", panel.headerText.text)
             assertEquals("", panel.descriptionText.text)
         }
@@ -38,7 +37,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setTopPanel adds component before header`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val top = JLabel("top")
             panel.setTopPanel(top)
 
@@ -52,7 +51,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setTopPanel null removes top component`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val top = JLabel("top")
             panel.setTopPanel(top)
             panel.setTopPanel(null)
@@ -64,7 +63,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setTopPanel replaces previous top without duplicates`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val first = JLabel("first")
             val second = JLabel("second")
             panel.setTopPanel(first)
@@ -79,7 +78,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setBody adds component after descriptionText`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val body = JLabel("body")
             panel.setBody(body)
 
@@ -93,7 +92,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setBody null removes body`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val body = JLabel("body")
             panel.setBody(body)
             panel.setBody(null)
@@ -105,7 +104,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setBody replaces previous body without duplicates`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val first = JLabel("first body")
             val second = JLabel("second body")
             panel.setBody(first)
@@ -120,7 +119,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setFooter adds component after body`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val body = JLabel("body")
             val footer = JLabel("footer")
             panel.setBody(body)
@@ -136,7 +135,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setFooter null removes footer`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val footer = JLabel("footer")
             panel.setFooter(footer)
             panel.setFooter(null)
@@ -148,7 +147,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setFooter replaces existing footer without duplicates`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val first = JLabel("first footer")
             val second = JLabel("second footer")
             panel.setFooter(first)
@@ -163,7 +162,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test all slots appear in correct order top-header-desc-body-footer`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val top = JLabel("top")
             val body = JLabel("body")
             val footer = JLabel("footer")
@@ -187,7 +186,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test header and description survive multiple setBody calls`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             repeat(3) { i -> panel.setBody(JLabel("body $i")) }
             assertNotNull(find(panel, panel.headerText))
             assertNotNull(find(panel, panel.descriptionText))
@@ -198,7 +197,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test col has exactly two children with no optional slots`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val col = findCol(panel)!!
             assertEquals("header row + descriptionText only", 2, col.componentCount)
         }
@@ -208,7 +207,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setHeaderIcon adds icon to the left side of header row`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             panel.setHeaderIcon(AllIcons.General.Warning, "warning")
 
             val header = panel.headerText.parent as JPanel
@@ -224,7 +223,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test setHeaderIcon null hides header icon without removing header row`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             panel.setHeaderIcon(AllIcons.General.Warning)
             panel.setHeaderIcon(null)
 
@@ -237,7 +236,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test col child count includes spacing before body and footer slots`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             panel.setTopPanel(JLabel("top"))
             assertEquals(3, findCol(panel)!!.componentCount)
             panel.setBody(JLabel("body"))
@@ -249,7 +248,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test body and footer spacing use matching standard insets`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val body = JLabel("body")
             val footer = JLabel("footer")
             panel.setBody(body)
@@ -266,7 +265,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test col shrinks back after removing optional slots`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             panel.setTopPanel(JLabel("top"))
             panel.setBody(JLabel("body"))
             panel.setFooter(JLabel("footer"))
@@ -283,7 +282,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test applyStyle applies enlarged boldUiFont to header and enlarged uiFont to description`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val style = SessionEditorStyle.create(family = "Courier New", size = 20)
             panel.applyStyle(style)
 
@@ -296,7 +295,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test description uses next standard top padding`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val ins = panel.descriptionText.border.getBorderInsets(panel.descriptionText)
 
             assertEquals("description top padding should use next standard gap", UiStyle.Gap.sm(), ins.top)
@@ -305,7 +304,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
 
     fun `test applyStyle does not apply editor font family to header or description`() {
         edt {
-            val panel = BaseSessionQuestionPanel()
+            val panel = BaseQuestionView()
             val style = SessionEditorStyle.create(family = "Courier New", size = 20)
             panel.applyStyle(style)
 
@@ -329,7 +328,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
         return result as T
     }
 
-    private fun findCol(panel: BaseSessionQuestionPanel): JPanel? {
+    private fun findCol(panel: BaseQuestionView): JPanel? {
         for (child in panel.components) {
             if (child is JPanel) return child
         }

From cf1ffc00f0b98ed72fef3e95214c672354a46898 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 14:13:52 -0400
Subject: [PATCH 09/21] refactor(jetbrains): centralize question card setup

---
 .../client/session/ui/EmptySessionPanel.kt    |   4 +-
 .../client/session/ui/LoadingPanel.kt         |   2 +-
 .../client/session/ui/ProgressPanel.kt        |   2 +-
 .../client/session/ui/header/ContextBar.kt    |   4 +-
 .../session/ui/header/SessionHeaderPanel.kt   |  18 +-
 .../session/ui/style/SessionEditorStyle.kt    |  32 +-
 .../client/session/views/CompactionView.kt    |   4 +-
 .../client/session/views/LoginRequiredView.kt |  42 +-
 .../client/session/views/PermissionView.kt    |  52 +--
 .../session/views/base/BaseQuestionView.kt    | 211 ++++++----
 .../client/session/views/base/GenericView.kt  |   4 +-
 .../views/question/QuestionResultView.kt      |   6 +-
 .../session/views/question/QuestionView.kt    |  90 ++--
 .../kotlin/ai/kilocode/client/ui/UiStyle.kt   |  15 +
 .../session/ui/SessionEditorStyleTest.kt      |  46 ++-
 .../session/views/LoginRequiredViewTest.kt    |  57 +--
 .../session/views/PermissionViewTest.kt       |  16 +-
 .../session/views/QuestionResultViewTest.kt   |   8 +-
 .../client/session/views/QuestionViewTest.kt  |  42 +-
 .../views/base/BaseQuestionViewTest.kt        | 388 +++++++++---------
 20 files changed, 538 insertions(+), 505 deletions(-)

diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
index 3d079596616..5c726fc0ac1 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
@@ -303,8 +303,8 @@ class EmptySessionPanel(
 
     override fun applyStyle(style: SessionEditorStyle) {
         this.style = style
-        welcomeLabel.font = style.uiFont
-        recentTitle.font = style.smallUiFont
+        welcomeLabel.font = style.regularFont
+        recentTitle.font = style.smallFont
         revalidate()
         repaint()
     }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt
index 04acd9f6b44..10be5d2285e 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt
@@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget {
     }
 
     override fun applyStyle(style: SessionEditorStyle) {
-        label.font = style.uiFont
+        label.font = style.regularFont
         revalidate()
         repaint()
     }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt
index 376816612de..5454737bc6c 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt
@@ -62,7 +62,7 @@ class ProgressPanel(
     }
 
     override fun applyStyle(style: SessionEditorStyle) {
-        label.font = style.uiFont
+        label.font = style.regularFont
         revalidate()
         repaint()
     }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt
index e15a525fdd1..67939dacead 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt
@@ -44,9 +44,9 @@ internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) {
         background = style.editorBackground
         foreground = style.editorForeground
         meter.background = style.editorBackground
-        used.font = style.smallUiFont
+        used.font = style.smallFont
         used.foreground = style.editorForeground
-        limit.font = style.smallUiFont
+        limit.font = style.smallFont
         limit.foreground = style.editorForeground
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
index c1ff05c96d5..cc7f31d1079 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt
@@ -229,23 +229,23 @@ class SessionHeaderPanel(
         todoRow.background = style.editorBackground
         body.background = style.editorBackground
         viewport.background = style.editorBackground
-        title.font = style.boldUiFont
+        title.font = style.boldFont
         title.foreground = style.editorForeground
-        cost.font = style.uiFont
+        cost.font = style.regularFont
         cost.foreground = style.editorForeground
-        context.font = style.uiFont
+        context.font = style.regularFont
         context.foreground = style.editorForeground
-        todos.font = style.smallUiFont
+        todos.font = style.smallFont
         todos.foreground = style.editorForeground
-        tokenTitle.font = style.smallUiFont
+        tokenTitle.font = style.smallFont
         tokenTitle.foreground = style.editorForeground
-        input.font = style.smallUiFont
+        input.font = style.smallFont
         input.foreground = style.editorForeground
-        output.font = style.smallUiFont
+        output.font = style.smallFont
         output.foreground = style.editorForeground
-        cacheRead.font = style.smallUiFont
+        cacheRead.font = style.smallFont
         cacheRead.foreground = style.editorForeground
-        cacheWrite.font = style.smallUiFont
+        cacheWrite.font = style.smallFont
         cacheWrite.foreground = style.editorForeground
         bar.applyStyle(style)
         refresh()
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
index d510a0fdebe..02daa43ca22 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
@@ -1,10 +1,10 @@
 package ai.kilocode.client.session.ui.style
 
+import ai.kilocode.client.ui.UiStyle
 import com.intellij.openapi.editor.colors.EditorColorsManager
 import com.intellij.openapi.editor.colors.EditorColorsScheme
 import com.intellij.openapi.editor.ex.EditorEx
 import com.intellij.util.ui.JBFont
-import com.intellij.util.ui.JBUI
 import java.awt.Color
 import java.awt.Font
 import kotlin.math.roundToInt
@@ -14,6 +14,12 @@ import kotlin.math.roundToInt
  *
  * Session UI uses this instead of reading editor globals in every component so font and color changes can be applied
  * consistently through [SessionEditorStyleTarget].
+ *
+ * Editor-specific fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [editorForeground], [editorBackground])
+ * are derived from the active editor color scheme and are used for code/editor-rendered content.
+ *
+ * UI font fields ([headerFont], [hintFont], [regularFont], [boldFont], [smallFont]) come from [UiStyle.Fonts]
+ * and follow standard platform typography — they do not derive from the editor font size.
  */
 data class SessionEditorStyle(
     val editorScheme: EditorColorsScheme,
@@ -24,9 +30,11 @@ data class SessionEditorStyle(
     val transcriptFont: Font,
     val smallEditorFont: Font,
     val boldEditorFont: Font,
-    val uiFont: Font,
-    val smallUiFont: Font,
-    val boldUiFont: Font,
+    val headerFont: Font,
+    val hintFont: Font,
+    val regularFont: Font,
+    val boldFont: Font,
+    val smallFont: Font,
 ) {
     /** Apply this snapshot to embedded IntelliJ editor components used by session UI. */
     fun applyToEditor(editor: EditorEx) {
@@ -46,9 +54,7 @@ data class SessionEditorStyle(
             family: String = scheme.editorFontName,
             size: Int = scheme.editorFontSize,
         ): SessionEditorStyle {
-            val small = scaledSize(size, JBFont.small())
-            val ui = JBUI.Fonts.label().deriveFont(size.toFloat())
-            val smallUi = JBFont.small().deriveFont(small.toFloat())
+            val small = scaledEditorSize(size, JBFont.small())
             return SessionEditorStyle(
                 editorScheme = scheme,
                 editorFamily = family,
@@ -58,14 +64,16 @@ data class SessionEditorStyle(
                 transcriptFont = Font(family, Font.PLAIN, size),
                 smallEditorFont = Font(family, Font.PLAIN, small),
                 boldEditorFont = Font(family, Font.BOLD, size),
-                uiFont = ui,
-                smallUiFont = smallUi,
-                boldUiFont = ui.deriveFont(Font.BOLD),
+                headerFont = UiStyle.Fonts.header(),
+                hintFont = UiStyle.Fonts.hint(),
+                regularFont = UiStyle.Fonts.regular(),
+                boldFont = UiStyle.Fonts.bold(),
+                smallFont = UiStyle.Fonts.small(),
             )
         }
 
-        private fun scaledSize(size: Int, font: Font): Int {
-            val base = JBUI.Fonts.label().size.coerceAtLeast(1)
+        private fun scaledEditorSize(size: Int, font: Font): Int {
+            val base = com.intellij.util.ui.JBUI.Fonts.label().size.coerceAtLeast(1)
             val ratio = font.size.toFloat() / base
             return (size * ratio).roundToInt().coerceAtLeast(1)
         }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
index 26ea0a996c2..25435404d9b 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt
@@ -68,8 +68,8 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par
     override fun update(content: Content) {}  // compaction has no mutable state
 
     override fun applyStyle(style: SessionEditorStyle) {
-        if (text.font == style.smallUiFont) return
-        text.font = style.smallUiFont
+        if (text.font == style.smallFont) return
+        text.font = style.smallFont
         revalidate()
         repaint()
     }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
index f6bdf9fa30e..83a658f528d 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt
@@ -3,18 +3,10 @@ package ai.kilocode.client.session.views
 import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.ui.SessionView
 import ai.kilocode.client.session.views.base.BaseQuestionView
-import ai.kilocode.client.session.views.base.applyButton
-import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
-import ai.kilocode.client.ui.UiStyle
 import com.intellij.util.concurrency.annotations.RequiresEdt
 import com.intellij.util.ui.components.BorderLayoutPanel
-import java.awt.BorderLayout
-import java.awt.Component
-import javax.swing.Box
-import javax.swing.BoxLayout
-import javax.swing.JPanel
 
 /**
  * Retained inline view shown at the bottom of the transcript when a session
@@ -32,31 +24,19 @@ class LoginRequiredView(
     override val sessionViewKind = SessionView.Kind.Default
 
     private val card = BaseQuestionView()
-    val openProfileButton = applyButton(KiloBundle.message("session.login.required.button")) { openProfile() }
-    val dismissButton = dismissButton(KiloBundle.message("session.login.required.dismiss")) { dismiss() }
+
+    private val ID_DISMISS = "dismiss"
+    private val ID_OPEN = "open"
 
     init {
         isOpaque = false
         isVisible = false
 
-        card.headerText.text = KiloBundle.message("session.login.required.title")
-        card.headerText.alignmentX = Component.LEFT_ALIGNMENT
-        card.descriptionText.alignmentX = Component.LEFT_ALIGNMENT
-
-        val footer = JPanel(BorderLayout()).apply {
-            isOpaque = false
-            alignmentX = Component.LEFT_ALIGNMENT
-        }
-        val actions = JPanel().apply {
-            isOpaque = false
-            layout = BoxLayout(this, BoxLayout.X_AXIS)
-            add(dismissButton)
-            add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
-            add(openProfileButton)
-        }
-        footer.add(actions, BorderLayout.EAST)
-
-        card.setFooter(footer)
+        card.setHeader(KiloBundle.message("session.login.required.title"))
+        card.setActions(listOf(
+            BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.login.required.dismiss"), primary = false) { dismiss() },
+            BaseQuestionView.Action(ID_OPEN, KiloBundle.message("session.login.required.button"), primary = true) { openProfile() },
+        ))
 
         addToCenter(card)
     }
@@ -64,7 +44,7 @@ class LoginRequiredView(
     /** Make the view visible with [message] shown as the description. */
     @RequiresEdt
     fun show(message: String) {
-        card.descriptionText.text = message
+        card.setDescription(message)
         isVisible = true
         refresh()
     }
@@ -82,6 +62,10 @@ class LoginRequiredView(
         card.applyStyle(style)
     }
 
+    // Test helpers — return generic JButton to keep SessionQuestionButton internal
+    internal fun openProfileButton() = card.actionButtonsForTest()[ID_OPEN]!!
+    internal fun dismissButton() = card.actionButtonsForTest()[ID_DISMISS]!!
+
     private fun refresh() {
         revalidate()
         repaint()
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index f753e11a4fd..86670c5647f 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -5,8 +5,6 @@ import ai.kilocode.client.session.model.Permission
 import ai.kilocode.client.session.model.PermissionRequestState
 import ai.kilocode.client.session.ui.SessionView
 import ai.kilocode.client.session.views.base.BaseQuestionView
-import ai.kilocode.client.session.views.base.applyButton
-import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -17,10 +15,8 @@ import com.intellij.icons.AllIcons
 import com.intellij.ui.components.JBScrollPane
 import com.intellij.util.ui.JBUI
 import com.intellij.util.ui.components.BorderLayoutPanel
-import java.awt.BorderLayout
 import java.awt.Component
 import java.awt.Dimension
-import javax.swing.Box
 import javax.swing.BoxLayout
 import javax.swing.JPanel
 import javax.swing.ScrollPaneConstants
@@ -48,36 +44,23 @@ class PermissionView(
         alignmentX = Component.LEFT_ALIGNMENT
     }
 
-    private val footer = JPanel(BorderLayout()).apply {
-        isOpaque = false
-        alignmentX = Component.LEFT_ALIGNMENT
-    }
-
-    private val actions = JPanel().apply {
-        isOpaque = false
-        layout = BoxLayout(this, BoxLayout.X_AXIS)
-        alignmentX = Component.LEFT_ALIGNMENT
-    }
-
-    private val run = applyButton(KiloBundle.message("session.permission.run")) { decide("once") }
-    private val deny = dismissButton(KiloBundle.message("session.permission.deny")) { decide("reject") }
-
     // Track command MdView instances for style updates
     private val cmdViews = mutableListOf()
     private val cmdScrolls = mutableListOf()
 
+    private val ID_DENY = "deny"
+    private val ID_RUN = "run"
+
     init {
         isOpaque = false
         isVisible = false
 
-        actions.add(deny)
-        actions.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
-        actions.add(run)
-        footer.add(actions, BorderLayout.EAST)
-
         card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
-        card.setBody(body)
-        card.setFooter(footer)
+        card.setContent(body)
+        card.setActions(listOf(
+            BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") },
+            BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") },
+        ))
         addToCenter(card)
     }
 
@@ -85,7 +68,7 @@ class PermissionView(
     fun show(permission: Permission) {
         requestId = permission.id
 
-        card.headerText.text = KiloBundle.message("session.permission.title")
+        card.setHeader(KiloBundle.message("session.permission.title"))
 
         body.removeAll()
         cmdViews.clear()
@@ -95,9 +78,6 @@ class PermissionView(
         val cmd = permission.meta.command
         val command = cmd != null || toolName == "bash"
 
-        card.descriptionText.text = ""
-        card.descriptionText.isVisible = false
-
         if (command) {
             addCodeBlock(cmd ?: "")
         } else {
@@ -105,8 +85,8 @@ class PermissionView(
         }
 
         val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
-        run.isEnabled = !responding
-        deny.isEnabled = !responding
+        card.setActionEnabled(ID_RUN, !responding)
+        card.setActionEnabled(ID_DENY, !responding)
 
         isVisible = true
         refresh()
@@ -203,8 +183,8 @@ class PermissionView(
 
     private fun decide(value: String) {
         val id = requestId ?: return
-        run.isEnabled = false
-        deny.isEnabled = false
+        card.setActionEnabled(ID_RUN, false)
+        card.setActionEnabled(ID_DENY, false)
         reply(id, PermissionReplyDto(reply = value))
     }
 
@@ -238,10 +218,10 @@ class PermissionView(
     }
 
     // Test helpers
-    internal fun runButtonForTest() = run
-    internal fun denyButtonForTest() = deny
+    internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!!
+    internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!!
     internal fun firstCmdViewForTest() = cmdViews.firstOrNull()
-    internal fun headerFontForTest() = card.headerText.font
+    internal fun headerFontForTest() = card.headerFont()
 }
 
 /**
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
index da2593d25d8..58c379568d8 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt
@@ -14,7 +14,6 @@ import java.awt.BorderLayout
 import java.awt.Color
 import java.awt.Component
 import java.awt.Dimension
-import java.awt.Font
 import javax.swing.Box
 import javax.swing.BoxLayout
 import javax.swing.Icon
@@ -26,40 +25,48 @@ import javax.swing.JPanel
  * Shared rounded background panel for session inline views that follow the
  * question-view visual style: a card surface with a header text area, a
  * description text area, an optional component above the header, and slots
- * for view-specific body and footer content.
+ * for view-specific content and a base-owned action-button footer.
  *
  * Both [ai.kilocode.client.session.views.question.QuestionView] and
  * [ai.kilocode.client.session.views.LoginRequiredView] use this as their
  * outer card shell so they share the same background, padding, and text
  * styling without duplicating the setup.
  *
- * The column always contains (in order): optional top, header row with [headerText],
- * [descriptionText], optional body, optional footer. Call [setTopPanel],
- * [setHeaderIcon], [setBody], or [setFooter] to replace those slots at any time.
+ * The column always contains (in order): optional top, header row with the
+ * header text, description text, optional content, optional action footer.
+ * Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription],
+ * [setContent], [setActions], or [setActionEnabled] to configure the card.
  */
 class BaseQuestionView : RoundedContentPanel(
     UiStyle.Gap.lg(),
     UiStyle.Gap.pad(),
 ), SessionEditorStyleTarget {
 
+    // ---- Action descriptor ----
+
+    /**
+     * Describes a button to render in the card's action footer.
+     *
+     * @param id     Stable identifier so [setActionEnabled] can target a specific button.
+     * @param text   Button label shown to the user.
+     * @param primary True → rendered as the platform default (accent) button.
+     * @param enabled Initial enabled state.
+     * @param handler Called when the button is clicked.
+     */
+    data class Action(
+        val id: String,
+        val text: String,
+        val primary: Boolean,
+        val enabled: Boolean = true,
+        val handler: () -> Unit,
+    )
+
+    // ---- private state ----
+
     private var style = SessionEditorStyle.current()
 
-    // All JBTextArea instances that need style updates, paired with bold flag
     private val tracked = mutableListOf>()
 
-    // ---- header text ----
-    val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
-
-    // ---- description text ----
-    val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply {
-        border = JBUI.Borders.emptyTop(UiStyle.Gap.sm())
-    }
-
-    private val icon = JBLabel().apply {
-        border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
-        isVisible = false
-    }
-
     private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) {
         override fun getMaximumSize(): Dimension {
             val size = preferredSize
@@ -68,33 +75,64 @@ class BaseQuestionView : RoundedContentPanel(
     }.apply {
         isOpaque = false
         alignmentX = Component.LEFT_ALIGNMENT
-        add(icon, BorderLayout.WEST)
-        add(headerText, BorderLayout.CENTER)
     }
 
-    // ---- slot fields ----
+    private val icon = JBLabel().apply {
+        border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
+        isVisible = false
+    }
+
+    private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
+    private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
+
     private var top: JComponent? = null
-    private var body: JComponent? = null
-    private var footer: JComponent? = null
+    private var content: JComponent? = null
+
+    // action buttons keyed by id for enabled-state updates
+    private val actionButtons = mutableMapOf()
+    private var actionFooter: JComponent? = null
 
-    // ---- inner layout ----
     private val col = JPanel().apply {
         isOpaque = false
         layout = BoxLayout(this, BoxLayout.Y_AXIS)
     }
 
     init {
+        header.add(icon, BorderLayout.WEST)
+        header.add(headerText, BorderLayout.CENTER)
         addToCenter(col)
         rebuildCol()
     }
 
+    // ---- public text API ----
+
+    /**
+     * Set the header text and, optionally, the description text in one call.
+     * Pass `null` or an empty string for [description] to hide the description row.
+     */
+    @RequiresEdt
+    fun setHeader(text: String, description: String? = null) {
+        headerText.text = text
+        setDescription(description)
+    }
+
+    /**
+     * Set or clear the description text below the header.
+     * The description row is visible only when [text] is non-null and non-blank.
+     */
+    @RequiresEdt
+    fun setDescription(text: String?) {
+        descriptionText.text = text ?: ""
+        descriptionText.isVisible = !text.isNullOrBlank()
+    }
+
+    // ---- public slot API ----
+
     /**
      * Optional panel rendered above the header row (e.g. summary + nav in
      * [ai.kilocode.client.session.views.question.QuestionView]).  When set,
      * it is inserted as the first child of the column; calling with `null`
      * removes a previously set component.
-     *
-     * The header/description text areas follow immediately after.
      */
     @RequiresEdt
     fun setTopPanel(top: JComponent?) {
@@ -116,25 +154,61 @@ class BaseQuestionView : RoundedContentPanel(
     }
 
     /**
-     * Replace the body slot that comes after the header/description.
-     * Pass `null` to remove the current body.
+     * Replace the view-specific content slot that comes after the header/description.
+     * Pass `null` to remove the current content.
      */
     @RequiresEdt
-    fun setBody(body: JComponent?) {
-        this.body = body
+    fun setContent(content: JComponent?) {
+        this.content = content
         rebuildCol()
     }
 
     /**
-     * Replace the footer slot that comes after the body.
-     * Pass `null` to remove the current footer.
+     * Configure the action buttons shown in the card's right-aligned footer.
+     *
+     * All buttons are created fresh; stable button references across calls can be
+     * maintained by the caller through [setActionEnabled] using the [Action.id].
+     * Pass an empty list to remove the footer entirely.
      */
     @RequiresEdt
-    fun setFooter(footer: JComponent?) {
-        this.footer = footer
+    fun setActions(actions: List) {
+        actionButtons.clear()
+        actionFooter = if (actions.isEmpty()) {
+            null
+        } else {
+            val row = JPanel().apply {
+                isOpaque = false
+                layout = BoxLayout(this, BoxLayout.X_AXIS)
+                alignmentX = Component.LEFT_ALIGNMENT
+            }
+            for ((idx, action) in actions.withIndex()) {
+                if (idx > 0) row.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
+                val btn = makeButton(action.text, action.primary).apply {
+                    isEnabled = action.enabled
+                    addActionListener { action.handler() }
+                }
+                actionButtons[action.id] = btn
+                row.add(btn)
+            }
+            val footer = JPanel(BorderLayout()).apply {
+                isOpaque = false
+                alignmentX = Component.LEFT_ALIGNMENT
+            }
+            footer.add(row, BorderLayout.EAST)
+            footer
+        }
         rebuildCol()
     }
 
+    /**
+     * Enable or disable a specific action button identified by [id].
+     * No-ops if the id is not found (e.g. before [setActions] is called).
+     */
+    @RequiresEdt
+    fun setActionEnabled(id: String, enabled: Boolean) {
+        actionButtons[id]?.isEnabled = enabled
+    }
+
     // ---- SessionEditorStyleTarget ----
 
     @RequiresEdt
@@ -149,18 +223,29 @@ class BaseQuestionView : RoundedContentPanel(
 
     override fun outlineColor(): Color = SessionUiStyle.View.line()
 
-    // ---- helpers ----
+    // ---- internal test helpers ----
+
+    /** Returns the font currently applied to the header text area. For tests only. */
+    internal fun headerFont() = headerText.font
+
+    /** Returns the font currently applied to the description text area. For tests only. */
+    internal fun descriptionFont() = descriptionText.font
+
+    /** Returns all action buttons as generic JButton, keyed by their action id. For tests only. */
+    internal fun actionButtonsForTest(): Map = actionButtons.toMap()
+
+    // ---- private helpers ----
 
     private fun rebuildCol() {
         col.removeAll()
         top?.let { col.add(it) }
         col.add(header)
         col.add(descriptionText)
-        body?.let {
+        content?.let {
             col.add(gap())
             col.add(it)
         }
-        footer?.let {
+        actionFooter?.let {
             col.add(gap())
             col.add(it)
         }
@@ -220,46 +305,26 @@ class BaseQuestionView : RoundedContentPanel(
     }
 
     private fun applyFont(area: JBTextArea, bold: Boolean) {
-        val base = if (bold) style.boldUiFont else style.uiFont
-        val font = larger(base)
+        val font = if (bold) style.headerFont else style.hintFont
         if (area.font != font) area.font = font
     }
 
-    private fun larger(font: Font): Font = font.deriveFont((font.size + 1).toFloat())
-}
+    private fun makeButton(text: String, primary: Boolean): JButton {
+        val btn = object : JButton(text) {
+            init {
+                if (primary) putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
+                syncBackground()
+            }
 
-/**
- * A [javax.swing.JButton] variant used inside session question/login-required panels.
- *
- * Primary buttons receive [com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI.DEFAULT_STYLE_KEY] so they use the
- * platform's default-button accent. Buttons keep the standard Look-and-Feel
- * border, padding, disabled state, and focus painting, while their component
- * background follows the question card surface so border/focus chrome blends
- * into the inline panel instead of the surrounding transcript.
- */
-class SessionQuestionButton(text: String, val primary: Boolean) : JButton(text) {
+            override fun updateUI() {
+                super.updateUI()
+                syncBackground()
+            }
 
-    init {
-        if (primary) {
-            putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
+            private fun syncBackground() {
+                background = SessionUiStyle.View.surface()
+            }
         }
-        syncBackground()
-    }
-
-    override fun updateUI() {
-        super.updateUI()
-        syncBackground()
-    }
-
-    private fun syncBackground() {
-        background = SessionUiStyle.View.surface()
+        return btn
     }
 }
-
-/** Create a non-primary (secondary) session question button. */
-fun dismissButton(text: String, action: () -> Unit): SessionQuestionButton =
-    SessionQuestionButton(text, primary = false).apply { addActionListener { action() } }
-
-/** Create a primary (default/accent) session question button. */
-fun applyButton(text: String, action: () -> Unit): SessionQuestionButton =
-    SessionQuestionButton(text, primary = true).apply { addActionListener { action() } }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
index 7ecf9d71716..0dcfb05e209 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt
@@ -36,8 +36,8 @@ class GenericView(content: Generic) : PartView() {
     fun labelText(): String = label.text
 
     override fun applyStyle(style: SessionEditorStyle) {
-        if (label.font == style.smallUiFont) return
-        label.font = style.smallUiFont
+        if (label.font == style.smallFont) return
+        label.font = style.smallFont
         revalidate()
         repaint()
     }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
index a325d53104d..e33303ede14 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt
@@ -108,8 +108,8 @@ class QuestionResultView(tool: Tool) : PartView() {
 
     override fun applyStyle(style: SessionEditorStyle) {
         this.style = style
-        val t = setFont(title, style.boldUiFont)
-        val s = setFont(sub, style.smallUiFont)
+        val t = setFont(title, style.boldFont)
+        val s = setFont(sub, style.smallFont)
         val label = t || s
         val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
         if (!label && !body) return
@@ -273,7 +273,7 @@ class QuestionResultView(tool: Tool) : PartView() {
     }
 
     private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
-        val font = if (bold) style.boldUiFont else style.uiFont
+        val font = if (bold) style.boldFont else style.regularFont
         if (area.font == font) return false
         area.font = font
         return true
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index 52589840168..0ec432e2ead 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -6,9 +6,6 @@ import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
 import ai.kilocode.client.session.ui.SessionView
 import ai.kilocode.client.session.views.base.BaseQuestionView
-import ai.kilocode.client.session.views.base.SessionQuestionButton
-import ai.kilocode.client.session.views.base.applyButton
-import ai.kilocode.client.session.views.base.dismissButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.ui.HoverIcon
@@ -80,15 +77,11 @@ class QuestionView(
         layout = BoxLayout(this, BoxLayout.Y_AXIS)
         alignmentX = Component.LEFT_ALIGNMENT
     }
-    private val footer = JPanel(BorderLayout()).apply {
-        isOpaque = false
-        alignmentX = Component.LEFT_ALIGNMENT
-    }
-    private val dismiss = dismissButton(KiloBundle.message("session.question.dismiss")) { doReject() }
-    private val right = JPanel().apply {
-        isOpaque = false
-        layout = BoxLayout(this, BoxLayout.X_AXIS)
-    }
+
+    // Stable action ids for setActionEnabled calls
+    private val ID_DISMISS = "dismiss"
+    private val ID_BACK = "back"
+    private val ID_MAIN = "main"  // next / review / submit
 
     init {
         isOpaque = false
@@ -98,11 +91,9 @@ class QuestionView(
         nav.add(fwd)
         topPanel.add(summary, BorderLayout.WEST)
         topPanel.add(nav, BorderLayout.EAST)
-        footer.add(right, BorderLayout.EAST)
 
         card.setTopPanel(topPanel)
-        card.setBody(body)
-        card.setFooter(footer)
+        card.setContent(body)
         add(card, BorderLayout.CENTER)
     }
 
@@ -126,7 +117,7 @@ class QuestionView(
         selections = emptyList()
         texts.clear()
         body.removeAll()
-        right.removeAll()
+        card.setActions(emptyList())
         isVisible = false
         refresh()
     }
@@ -144,17 +135,14 @@ class QuestionView(
         texts.clear()
         body.removeAll()
         if (review(q)) {
-            card.headerText.text = KiloBundle.message("session.question.review.title")
-            card.descriptionText.text = ""
-            card.descriptionText.isVisible = false
+            card.setHeader(KiloBundle.message("session.question.review.title"))
             addReview(q)
         } else {
             val item = q.items[idx]
-            card.headerText.text = item.question
-            card.descriptionText.text = KiloBundle.message(
+            val hint = KiloBundle.message(
                 if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"
             )
-            card.descriptionText.isVisible = true
+            card.setHeader(item.question, hint)
             addContent(item, selections[idx])
         }
         syncHeader(q)
@@ -172,48 +160,35 @@ class QuestionView(
     }
 
     private fun syncFooter(q: Question) {
-        right.removeAll()
-        right.add(dismiss)
-        if (review(q)) {
-            val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() }
-            val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() }
-            right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
-            right.add(back)
-            right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
-            right.add(submit)
-            return
-        }
+        val actions = mutableListOf()
+        actions.add(BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.question.dismiss"), primary = false) { doReject() })
 
-        val label = when {
-            direct(q) -> KiloBundle.message("session.question.submit")
-            lastItem(q) -> KiloBundle.message("session.question.review")
-            else -> KiloBundle.message("session.question.next")
-        }
-        val isPrimary = direct(q) || lastItem(q)
-        val button = SessionQuestionButton(label, isPrimary).apply {
-            addActionListener {
+        if (review(q)) {
+            actions.add(BaseQuestionView.Action(ID_BACK, KiloBundle.message("session.question.back"), primary = false) { goBack() })
+            actions.add(BaseQuestionView.Action(ID_MAIN, KiloBundle.message("session.question.submit"), primary = true) { doReply() })
+        } else {
+            val label = when {
+                direct(q) -> KiloBundle.message("session.question.submit")
+                lastItem(q) -> KiloBundle.message("session.question.review")
+                else -> KiloBundle.message("session.question.next")
+            }
+            val isPrimary = direct(q) || lastItem(q)
+            actions.add(BaseQuestionView.Action(ID_MAIN, label, isPrimary) {
                 when {
                     direct(q) -> doReply()
                     lastItem(q) -> goReview()
                     else -> goForward()
                 }
-            }
+            })
         }
-        right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
-        right.add(button)
+        card.setActions(actions)
     }
 
     private fun syncControls(q: Question) {
         val ready = selections.getOrNull(idx)?.isNotEmpty() == true
         back.isEnabled = idx > 0
         fwd.isEnabled = idx < q.items.size && ready
-        val backLabel = KiloBundle.message("session.question.back")
-        val dismissLabel = KiloBundle.message("session.question.dismiss")
-        for (node in right.components) {
-            if (node is SessionQuestionButton && node.text != backLabel && node.text != dismissLabel) {
-                node.isEnabled = review(q) || ready
-            }
-        }
+        card.setActionEnabled(ID_MAIN, review(q) || ready)
     }
 
     private fun addContent(item: QuestionItem, set: MutableSet) {
@@ -228,6 +203,8 @@ class QuestionView(
             row.alignmentX = Component.LEFT_ALIGNMENT
             body.add(row)
         }
+        // Remove bottom padding on the last review row to match the top gap.
+        (body.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
     }
 
     private fun reviewRow(item: QuestionItem, i: Int): JPanel {
@@ -258,10 +235,13 @@ class QuestionView(
         }
         if (item.multiple) {
             for (opt in item.options) panel.add(checkboxRow(opt, set))
-            return panel
+        } else {
+            val group = ButtonGroup()
+            for (opt in item.options) panel.add(radioRow(opt, set, group))
         }
-        val group = ButtonGroup()
-        for (opt in item.options) panel.add(radioRow(opt, set, group))
+        // Remove bottom padding on the last option so the gap before the action
+        // footer matches the gap above the options (both use Gap.lg).
+        (panel.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
         return panel
     }
 
@@ -438,7 +418,7 @@ class QuestionView(
     }
 
     private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
-        val font = if (bold) style.boldUiFont else style.uiFont
+        val font = if (bold) style.boldFont else style.regularFont
         if (area.font == font) return false
         area.font = font
         return true
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
index 4d5f3265f7a..156d0760338 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
@@ -130,6 +130,21 @@ object UiStyle {
 
         /** Prominent short content, e.g. device auth code. Maps to [JBFont.h2] bold. */
         fun large(): JBFont = JBFont.h2().asBold()
+
+        /** Card/question header font — bold at heading level 4. */
+        fun header(): JBFont = JBFont.h4().asBold()
+
+        /** Hint or description font — plain regular size. */
+        fun hint(): JBFont = JBFont.regular()
+
+        /** Standard body/label text. */
+        fun regular(): JBFont = JBFont.regular()
+
+        /** Bold body/label text. */
+        fun bold(): JBFont = JBFont.regular().asBold()
+
+        /** Small secondary text, e.g. metadata labels. */
+        fun small(): JBFont = JBFont.small()
     }
 
     /** Small component helpers that keep repeated Swing setup in one place. */
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
index 9d4c32ce42f..cc5b1130da0 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt
@@ -1,6 +1,7 @@
 package ai.kilocode.client.session.ui
 
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.ui.UiStyle
 import com.intellij.openapi.editor.colors.EditorColorsManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
 import java.awt.Font
@@ -37,7 +38,7 @@ class SessionEditorStyleTest : BasePlatformTestCase() {
         assertTrue(font.size < style.editorSize)
     }
 
-    fun `test custom style derives fonts from supplied editor baseline`() {
+    fun `test custom style keeps editor fields from supplied baseline`() {
         val style = SessionEditorStyle.create(family = "Courier New", size = 22)
 
         assertEquals("Courier New", style.editorFamily)
@@ -48,24 +49,43 @@ class SessionEditorStyleTest : BasePlatformTestCase() {
         assertEquals(22, style.boldEditorFont.size)
         assertTrue(style.boldEditorFont.isBold)
         assertTrue(style.smallEditorFont.size < style.editorSize)
-        assertEquals(style.editorSize, style.uiFont.size)
     }
 
-    fun `test ui fonts use platform label family not editor family`() {
-        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+    // --- UI fonts come from UiStyle.Fonts, NOT from the editor ---
 
-        // uiFont / boldUiFont / smallUiFont must NOT use the editor font family
-        assertFalse("uiFont should not use editor font family", style.uiFont.name == "Courier New")
-        assertFalse("boldUiFont should not use editor font family", style.boldUiFont.name == "Courier New")
-        assertFalse("smallUiFont should not use editor font family", style.smallUiFont.name == "Courier New")
+    fun `test headerFont equals UiStyle Fonts header`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+        assertEquals(UiStyle.Fonts.header(), style.headerFont)
     }
 
-    fun `test ui fonts inherit editor size`() {
+    fun `test hintFont equals UiStyle Fonts hint`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+        assertEquals(UiStyle.Fonts.hint(), style.hintFont)
+    }
+
+    fun `test regularFont equals UiStyle Fonts regular`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+        assertEquals(UiStyle.Fonts.regular(), style.regularFont)
+    }
+
+    fun `test boldFont equals UiStyle Fonts bold`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+        assertEquals(UiStyle.Fonts.bold(), style.boldFont)
+        assertTrue(style.boldFont.isBold)
+    }
+
+    fun `test smallFont equals UiStyle Fonts small`() {
+        val style = SessionEditorStyle.create(family = "Courier New", size = 22)
+        assertEquals(UiStyle.Fonts.small(), style.smallFont)
+    }
+
+    fun `test ui fonts do not use editor font family`() {
         val style = SessionEditorStyle.create(family = "Courier New", size = 22)
 
-        assertEquals("uiFont size should match editor size", 22, style.uiFont.size)
-        assertEquals("boldUiFont size should match editor size", 22, style.boldUiFont.size)
-        assertTrue("boldUiFont should be bold", style.boldUiFont.isBold)
-        assertTrue("smallUiFont should be smaller than editor size", style.smallUiFont.size < style.editorSize)
+        assertFalse("headerFont should not use editor font family", style.headerFont.name == "Courier New")
+        assertFalse("hintFont should not use editor font family", style.hintFont.name == "Courier New")
+        assertFalse("regularFont should not use editor font family", style.regularFont.name == "Courier New")
+        assertFalse("boldFont should not use editor font family", style.boldFont.name == "Courier New")
+        assertFalse("smallFont should not use editor font family", style.smallFont.name == "Courier New")
     }
 }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
index 279ff5f88a1..63aa4cbaa87 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt
@@ -1,6 +1,5 @@
 package ai.kilocode.client.session.views
 
-import ai.kilocode.client.session.views.base.SessionQuestionButton
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
@@ -49,29 +48,11 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
 
     // ------ open profile button style ------
 
-    fun `test open profile button is SessionQuestionButton`() {
-        edt {
-            val view = LoginRequiredView(openProfile = {}, dismiss = {})
-            view.show("Sign in required.")
-            val btn = view.openProfileButton
-            assertTrue("Open profile button should be a SessionQuestionButton", btn is SessionQuestionButton)
-        }
-    }
-
     fun `test open profile button is primary`() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
-            val btn = view.openProfileButton as SessionQuestionButton
-            assertTrue("Open profile button should be primary", btn.primary)
-        }
-    }
-
-    fun `test open profile button has DarculaButtonUI default style key`() {
-        edt {
-            val view = LoginRequiredView(openProfile = {}, dismiss = {})
-            view.show("Sign in required.")
-            val btn = view.openProfileButton
+            val btn = view.openProfileButton()
             assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
         }
     }
@@ -80,28 +61,20 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
-            val btn = view.openProfileButton
+            val btn = view.openProfileButton()
             assertEquals(SessionUiStyle.View.surface(), btn.background)
         }
     }
 
     // ------ dismiss button style ------
 
-    fun `test dismiss button is SessionQuestionButton`() {
+    fun `test dismiss button does not have default style key`() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
-            val btn = view.dismissButton
-            assertTrue("Dismiss button should be a SessionQuestionButton", btn is SessionQuestionButton)
-        }
-    }
-
-    fun `test dismiss button is not primary`() {
-        edt {
-            val view = LoginRequiredView(openProfile = {}, dismiss = {})
-            view.show("Sign in required.")
-            val btn = view.dismissButton as SessionQuestionButton
-            assertFalse("Dismiss button should not be primary", btn.primary)
+            val btn = view.dismissButton()
+            val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+            assertTrue("Dismiss should not be primary", key == null || key == false)
         }
     }
 
@@ -110,8 +83,8 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
 
-            val dismiss = view.dismissButton
-            val open = view.openProfileButton
+            val dismiss = view.dismissButton()
+            val open = view.openProfileButton()
             assertSame("Dismiss and open profile should be in the same right-aligned group", dismiss.parent, open.parent)
             assertTrue("Dismiss should appear before open profile", dismiss.parent.components.indexOf(dismiss) < open.parent.components.indexOf(open))
         }
@@ -124,7 +97,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         edt {
             val view = LoginRequiredView(openProfile = { called = true }, dismiss = {})
             view.show("Sign in required.")
-            view.openProfileButton.doClick()
+            view.openProfileButton().doClick()
         }
         assertTrue("openProfile should have been called", called)
     }
@@ -134,7 +107,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = { called = true })
             view.show("Sign in required.")
-            view.dismissButton.doClick()
+            view.dismissButton().doClick()
         }
         assertTrue("dismiss should have been called", called)
     }
@@ -173,9 +146,9 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
         }
     }
 
-    // ------ fonts: UI family, editor size ------
+    // ------ fonts: standard UI family, not editor ------
 
-    fun `test header uses boldUiFont not editor font family`() {
+    fun `test header uses headerFont not editor font family`() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
@@ -188,11 +161,11 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
                 "Title font should not use editor font family",
                 title!!.font.name == "Courier New",
             )
-            assertEquals("Title font size should use next size", 21, title.font.size)
+            assertEquals("Title font should equal headerFont", style.headerFont, title.font)
         }
     }
 
-    fun `test description uses uiFont not editor font family`() {
+    fun `test description uses hintFont not editor font family`() {
         edt {
             val view = LoginRequiredView(openProfile = {}, dismiss = {})
             view.show("Sign in required.")
@@ -205,7 +178,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
                 "Description font should not use editor font family",
                 desc!!.font.name == "Courier New",
             )
-            assertEquals("Description font size should use next size", 21, desc.font.size)
+            assertEquals("Description font should equal hintFont", style.hintFont, desc.font)
         }
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
index 59893ff6ee6..f99b026b57d 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt
@@ -277,13 +277,6 @@ class PermissionViewTest : BasePlatformTestCase() {
 
     // ------ new: shared button types ------
 
-    fun `test run button is SessionQuestionButton with primary true`() {
-        view.show(permission())
-
-        val btn = view.runButtonForTest()
-        assertTrue("Run should be primary", btn.primary)
-    }
-
     fun `test run button uses default style key`() {
         view.show(permission())
 
@@ -291,11 +284,12 @@ class PermissionViewTest : BasePlatformTestCase() {
         assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
     }
 
-    fun `test deny button is SessionQuestionButton with primary false`() {
+    fun `test deny button does not have default style key`() {
         view.show(permission())
 
         val btn = view.denyButtonForTest()
-        assertFalse("Deny should not be primary", btn.primary)
+        val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+        assertTrue("Deny should not be primary", key == null || key == false)
     }
 
     fun `test session question buttons use question surface background`() {
@@ -370,7 +364,7 @@ class PermissionViewTest : BasePlatformTestCase() {
 
     // ------ fonts: header UI family, command code block editor family ------
 
-    fun `test permission header uses boldUiFont not editor font family`() {
+    fun `test permission header uses headerFont not editor font family`() {
         view.show(
             Permission(
                 id = "perm_font",
@@ -387,7 +381,7 @@ class PermissionViewTest : BasePlatformTestCase() {
         val header = view.headerFontForTest()
         assertFalse("Permission header should not use editor font family", header.name == "Courier New")
         assertTrue("Permission header should be bold", header.isBold)
-        assertEquals("Permission header size should use next size", 19, header.size)
+        assertEquals("Permission header should equal headerFont", style.headerFont, header)
     }
 
     fun `test command code block retains editor font family`() {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
index 346ffb97d8d..8d03559f920 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt
@@ -177,8 +177,8 @@ class QuestionResultViewTest : BasePlatformTestCase() {
         view.applyStyle(style)
         view.toggle()
 
-        assertTrue(view.bodyFonts().contains(style.uiFont))
-        assertTrue(view.bodyFonts().contains(style.boldUiFont))
+        assertTrue(view.bodyFonts().contains(style.regularFont))
+        assertTrue(view.bodyFonts().contains(style.boldFont))
         assertFalse("Body should not use editor transcript font", view.bodyFonts().any { it.name == "Courier New" })
     }
 
@@ -192,8 +192,8 @@ class QuestionResultViewTest : BasePlatformTestCase() {
 
         view.applyStyle(style)
 
-        assertEquals("Title should use boldUiFont", style.boldUiFont, view.titleFont())
-        assertEquals("Subtitle should use smallUiFont", style.smallUiFont, view.subFont())
+        assertEquals("Title should use boldFont", style.boldFont, view.titleFont())
+        assertEquals("Subtitle should use smallFont", style.smallFont, view.subFont())
         assertFalse("Title should not use editor font family", view.titleFont().name == "Courier New")
         assertFalse("Subtitle should not use editor font family", view.subFont().name == "Courier New")
     }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index 3051e466aed..fbfe719a1d8 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.views
 import ai.kilocode.client.session.model.Question
 import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
-import ai.kilocode.client.session.views.base.SessionQuestionButton
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.views.question.QuestionView
@@ -184,21 +183,19 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals("description should align in the text renderer", label.parent, desc.parent)
 
         val style = SessionEditorStyle.current()
-        assertEquals("option label should use boldUiFont", style.boldUiFont, label.font)
-        assertEquals("description should use uiFont", style.uiFont, desc.font)
+        assertEquals("option label should use boldFont", style.boldFont, label.font)
+        assertEquals("description should use regularFont", style.regularFont, desc.font)
     }
 
-    fun `test question title and hint use UI-family editor-sized fonts`() {
+    fun `test question title uses headerFont and hint uses hintFont`() {
         view.show(singleSelectQuestion("q_fonts"))
 
         val style = SessionEditorStyle.current()
         val title = text(view, "Choose approach")
         val hint = text(view, "Select one answer")
 
-        assertEquals(style.boldUiFont.name, title.font.name)
-        assertEquals(style.uiFont.name, hint.font.name)
-        assertEquals(style.boldUiFont.size + 1, title.font.size)
-        assertEquals(style.uiFont.size + 1, hint.font.size)
+        assertEquals("title should use headerFont", style.headerFont, title.font)
+        assertEquals("hint should use hintFont", style.hintFont, hint.font)
     }
 
     // ------ multi-question navigation ------
@@ -393,22 +390,19 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
     }
 
-    fun `test submit is SessionQuestionButton with primary true`() {
+    fun `test submit has DarculaButtonUI default style key`() {
         view.show(singleSelectQuestion("q_btn_type"))
 
         val submit = button(view, "Submit")
-
-        assertTrue("Submit should be SessionQuestionButton", submit is SessionQuestionButton)
-        assertTrue("Submit should be primary", (submit as SessionQuestionButton).primary)
+        assertEquals("Submit should be primary (default style key)", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
     }
 
-    fun `test dismiss is SessionQuestionButton with primary false`() {
+    fun `test dismiss does not have default style key`() {
         view.show(singleSelectQuestion("q_dismiss_type"))
 
         val dismiss = button(view, "Dismiss")
-
-        assertTrue("Dismiss should be SessionQuestionButton", dismiss is SessionQuestionButton)
-        assertFalse("Dismiss should not be primary", (dismiss as SessionQuestionButton).primary)
+        val key = dismiss.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+        assertTrue("Dismiss should not be primary", key == null || key == false)
     }
 
     fun `test session question buttons use question surface background`() {
@@ -421,7 +415,7 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals(SessionUiStyle.View.surface(), submit.background)
     }
 
-    fun `test review submit and back buttons are correct types on review page`() {
+    fun `test review submit and back buttons have correct primary state on review page`() {
         view.show(twoItemQuestion("q_review_types"))
 
         option(view, "Minimal").doClick()
@@ -432,10 +426,9 @@ class QuestionViewTest : BasePlatformTestCase() {
         val submit = button(view, "Submit")
         val back = button(view, "Back")
 
-        assertTrue("Submit on review page should be SessionQuestionButton", submit is SessionQuestionButton)
-        assertTrue("Submit on review page should be primary", (submit as SessionQuestionButton).primary)
-        assertTrue("Back on review page should be SessionQuestionButton", back is SessionQuestionButton)
-        assertFalse("Back on review page should not be primary", (back as SessionQuestionButton).primary)
+        assertEquals("Submit on review page should be primary", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
+        val backKey = back.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+        assertTrue("Back on review page should not be primary", backKey == null || backKey == false)
     }
 
     fun `test next button is not primary before last item`() {
@@ -443,8 +436,8 @@ class QuestionViewTest : BasePlatformTestCase() {
 
         val next = button(view, "Next")
 
-        assertTrue(next is SessionQuestionButton)
-        assertFalse("Next should not be primary on first question", (next as SessionQuestionButton).primary)
+        val key = next.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+        assertTrue("Next should not be primary on first question", key == null || key == false)
     }
 
     fun `test review button is primary on last item`() {
@@ -454,8 +447,7 @@ class QuestionViewTest : BasePlatformTestCase() {
 
         val review = button(view, "Review")
 
-        assertTrue(review is SessionQuestionButton)
-        assertTrue("Review should be primary on last question", (review as SessionQuestionButton).primary)
+        assertEquals("Review should be primary on last question", true, review.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
     }
 
     fun `test single question hides header nav`() {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
index 41ecc212b50..18d55133c12 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt
@@ -1,13 +1,17 @@
 package ai.kilocode.client.session.views.base
 
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
+import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.ui.UiStyle
 import com.intellij.icons.AllIcons
+import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
 import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBTextArea
 import java.awt.BorderLayout
 import java.awt.Container
+import javax.swing.JButton
 import javax.swing.JComponent
 import javax.swing.JLabel
 import javax.swing.JPanel
@@ -17,19 +21,63 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
 
     // ------ initial state ------
 
-    fun `test headerText and descriptionText are in the component tree by default`() {
+    fun `test header and description text areas are in the component tree by default`() {
         edt {
             val panel = BaseQuestionView()
-            assertNotNull("headerText should be present", find(panel, panel.headerText))
-            assertNotNull("descriptionText should be present", find(panel, panel.descriptionText))
+            val areas = findAll(panel)
+            assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2)
         }
     }
 
-    fun `test header and description have correct initial text`() {
+    fun `test setHeader sets the header text`() {
         edt {
             val panel = BaseQuestionView()
-            assertEquals("", panel.headerText.text)
-            assertEquals("", panel.descriptionText.text)
+            panel.setHeader("My Title")
+            val bold = findAll(panel).firstOrNull { it.font.isBold }
+            assertNotNull("Bold header text area should be present", bold)
+            assertEquals("My Title", bold!!.text)
+        }
+    }
+
+    fun `test setHeader with description shows description`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setHeader("Title", "Hint text")
+            val desc = findAll(panel).firstOrNull { it.text == "Hint text" }
+            assertNotNull("Description text area should be present", desc)
+        }
+    }
+
+    fun `test setHeader without description hides description`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setHeader("Title")
+            val areas = findAll(panel)
+            val nonBold = areas.filter { !it.font.isBold }
+            // description should either be hidden or blank
+            assertTrue("Non-bold text areas should be hidden or empty", nonBold.all { !it.isVisible || it.text.isBlank() })
+        }
+    }
+
+    fun `test setDescription with blank hides description`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setHeader("Title", "some text")
+            panel.setDescription("")
+            val areas = findAll(panel)
+            val desc = areas.firstOrNull { !it.font.isBold }
+            assertTrue("Description should be hidden when blank", desc == null || !desc.isVisible)
+        }
+    }
+
+    fun `test setDescription with null hides description`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setHeader("Title", "some text")
+            panel.setDescription(null)
+            val areas = findAll(panel)
+            val desc = areas.firstOrNull { !it.font.isBold }
+            assertTrue("Description should be hidden when null", desc == null || !desc.isVisible)
         }
     }
 
@@ -44,8 +92,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
             val col = findCol(panel)!!
             val comps = col.components.toList()
             val topIdx = comps.indexOf(top)
-            val headerIdx = comps.indexOf(panel.headerText.parent)
-            assertTrue("top should appear before headerText", topIdx < headerIdx)
+            // header row is the JPanel containing the header text area
+            val headerRow = findAll(panel).firstOrNull { it.font.isBold }?.parent as? JPanel
+            val headerIdx = if (headerRow != null) comps.indexOf(headerRow) else comps.indexOfFirst { it is JPanel }
+            assertTrue("top should appear before headerText row", topIdx >= 0 && topIdx < headerIdx)
         }
     }
 
@@ -57,7 +107,6 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
             panel.setTopPanel(null)
 
             assertNull("top should be removed after setTopPanel(null)", find(panel, top))
-            assertNotNull("headerText should still be present", find(panel, panel.headerText))
         }
     }
 
@@ -74,248 +123,210 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
         }
     }
 
-    // ------ setBody ------
+    // ------ setContent ------
 
-    fun `test setBody adds component after descriptionText`() {
+    fun `test setContent adds component after description`() {
         edt {
             val panel = BaseQuestionView()
             val body = JLabel("body")
-            panel.setBody(body)
-
-            val col = findCol(panel)!!
-            val comps = col.components.toList()
-            val descIdx = comps.indexOf(panel.descriptionText)
-            val bodyIdx = comps.indexOf(body)
-            assertTrue("body should appear after descriptionText", descIdx < bodyIdx)
+            panel.setContent(body)
+            assertNotNull("body should be in the tree", find(panel, body))
         }
     }
 
-    fun `test setBody null removes body`() {
+    fun `test setContent null removes content`() {
         edt {
             val panel = BaseQuestionView()
             val body = JLabel("body")
-            panel.setBody(body)
-            panel.setBody(null)
-
-            assertNull("body should be removed after setBody(null)", find(panel, body))
-            assertNotNull("headerText should still be present", find(panel, panel.headerText))
+            panel.setContent(body)
+            panel.setContent(null)
+            assertNull("body should be removed after setContent(null)", find(panel, body))
         }
     }
 
-    fun `test setBody replaces previous body without duplicates`() {
+    fun `test setContent replaces previous content without duplicates`() {
         edt {
             val panel = BaseQuestionView()
             val first = JLabel("first body")
             val second = JLabel("second body")
-            panel.setBody(first)
-            panel.setBody(second)
-
+            panel.setContent(first)
+            panel.setContent(second)
             assertNull("first body should be gone", find(panel, first))
             assertNotNull("second body should be present", find(panel, second))
         }
     }
 
-    // ------ setFooter ------
+    // ------ setActions ------
 
-    fun `test setFooter adds component after body`() {
+    fun `test setActions renders one button per action`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(
+                BaseQuestionView.Action("a", "Cancel", primary = false) {},
+                BaseQuestionView.Action("b", "OK", primary = true) {},
+            ))
+            val btns = panel.actionButtonsForTest()
+            assertEquals(2, btns.size)
+            assertNotNull(btns["a"])
+            assertNotNull(btns["b"])
+            assertEquals("Cancel", btns["a"]!!.text)
+            assertEquals("OK", btns["b"]!!.text)
+        }
+    }
+
+    fun `test primary action has DarculaButtonUI default style key`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
+            val btn = panel.actionButtonsForTest()["ok"]!!
+            assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
+        }
+    }
+
+    fun `test non-primary action does not have DarculaButtonUI default style key`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(BaseQuestionView.Action("cancel", "Cancel", primary = false) {}))
+            val btn = panel.actionButtonsForTest()["cancel"]!!
+            val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
+            assertTrue("Non-primary should not have default style key", key == null || key == false)
+        }
+    }
+
+    fun `test action button click invokes handler`() {
+        edt {
+            var clicked = false
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) { clicked = true }))
+            panel.actionButtonsForTest()["ok"]!!.doClick()
+            assertTrue("handler should have been invoked", clicked)
+        }
+    }
+
+    fun `test setActionEnabled disables and enables button`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true, enabled = true) {}))
+            panel.setActionEnabled("ok", false)
+            assertFalse(panel.actionButtonsForTest()["ok"]!!.isEnabled)
+            panel.setActionEnabled("ok", true)
+            assertTrue(panel.actionButtonsForTest()["ok"]!!.isEnabled)
+        }
+    }
+
+    fun `test setActions empty removes all action buttons`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
+            panel.setActions(emptyList())
+            assertTrue("actionButtonsForTest should be empty", panel.actionButtonsForTest().isEmpty())
+        }
+    }
+
+    fun `test action buttons use question card surface background`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setActions(listOf(
+                BaseQuestionView.Action("a", "A", primary = false) {},
+                BaseQuestionView.Action("b", "B", primary = true) {},
+            ))
+            val btns = panel.actionButtonsForTest()
+            assertEquals(SessionUiStyle.View.surface(), btns["a"]!!.background)
+            assertEquals(SessionUiStyle.View.surface(), btns["b"]!!.background)
+        }
+    }
+
+    // ------ ordering ------
+
+    fun `test content appears after description in col`() {
         edt {
             val panel = BaseQuestionView()
             val body = JLabel("body")
-            val footer = JLabel("footer")
-            panel.setBody(body)
-            panel.setFooter(footer)
+            panel.setContent(body)
+            val col = findCol(panel)!!
+            val comps = col.components.toList()
+            val descIdx = comps.indexOfFirst { it is JBTextArea && !(it).font.isBold }
+            val bodyIdx = comps.indexOf(body)
+            assertTrue("body should appear after description", descIdx < bodyIdx)
+        }
+    }
 
+    fun `test action footer appears after content`() {
+        edt {
+            val panel = BaseQuestionView()
+            val body = JLabel("body")
+            panel.setContent(body)
+            panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
             val col = findCol(panel)!!
             val comps = col.components.toList()
             val bodyIdx = comps.indexOf(body)
-            val footerIdx = comps.indexOf(footer)
+            val btn = panel.actionButtonsForTest()["ok"]!!
+            // find the footer panel that contains the button
+            val footerIdx = comps.indexOfFirst { it is JPanel && find(it, btn) != null }
             assertTrue("footer should appear after body", bodyIdx < footerIdx)
         }
     }
 
-    fun `test setFooter null removes footer`() {
-        edt {
-            val panel = BaseQuestionView()
-            val footer = JLabel("footer")
-            panel.setFooter(footer)
-            panel.setFooter(null)
-
-            assertNull("footer should be removed after setFooter(null)", find(panel, footer))
-            assertNotNull("headerText should still be present", find(panel, panel.headerText))
-        }
-    }
-
-    fun `test setFooter replaces existing footer without duplicates`() {
-        edt {
-            val panel = BaseQuestionView()
-            val first = JLabel("first footer")
-            val second = JLabel("second footer")
-            panel.setFooter(first)
-            panel.setFooter(second)
-
-            assertNull("first footer should be gone", find(panel, first))
-            assertNotNull("second footer should be present", find(panel, second))
-        }
-    }
-
-    // ------ ordering with all slots ------
-
-    fun `test all slots appear in correct order top-header-desc-body-footer`() {
-        edt {
-            val panel = BaseQuestionView()
-            val top = JLabel("top")
-            val body = JLabel("body")
-            val footer = JLabel("footer")
-            panel.setTopPanel(top)
-            panel.setBody(body)
-            panel.setFooter(footer)
-
-            val col = findCol(panel)!!
-            val comps = col.components.toList()
-            val topIdx = comps.indexOf(top)
-            val headerIdx = comps.indexOf(panel.headerText.parent)
-            val descIdx = comps.indexOf(panel.descriptionText)
-            val bodyIdx = comps.indexOf(body)
-            val footerIdx = comps.indexOf(footer)
-            assertTrue("top < header", topIdx < headerIdx)
-            assertTrue("header < desc", headerIdx < descIdx)
-            assertTrue("desc < body", descIdx < bodyIdx)
-            assertTrue("body < footer", bodyIdx < footerIdx)
-        }
-    }
-
-    fun `test header and description survive multiple setBody calls`() {
-        edt {
-            val panel = BaseQuestionView()
-            repeat(3) { i -> panel.setBody(JLabel("body $i")) }
-            assertNotNull(find(panel, panel.headerText))
-            assertNotNull(find(panel, panel.descriptionText))
-        }
-    }
-
-    // ------ column child count sanity ------
-
-    fun `test col has exactly two children with no optional slots`() {
-        edt {
-            val panel = BaseQuestionView()
-            val col = findCol(panel)!!
-            assertEquals("header row + descriptionText only", 2, col.componentCount)
-        }
-    }
-
-    // ------ header left icon ------
+    // ------ header icon ------
 
     fun `test setHeaderIcon adds icon to the left side of header row`() {
         edt {
             val panel = BaseQuestionView()
             panel.setHeaderIcon(AllIcons.General.Warning, "warning")
 
-            val header = panel.headerText.parent as JPanel
-            val layout = header.layout as BorderLayout
-            val labels = findAll(header).filter { it.icon != null }
+            val labels = findAll(panel).filter { it.icon != null && it.isVisible }
             assertEquals("Expected one header icon", 1, labels.size)
             assertSame(AllIcons.General.Warning, labels[0].icon)
             assertEquals("warning", labels[0].toolTipText)
-            assertEquals(BorderLayout.WEST, layout.getConstraints(labels[0]))
-            assertEquals(BorderLayout.CENTER, layout.getConstraints(panel.headerText))
         }
     }
 
-    fun `test setHeaderIcon null hides header icon without removing header row`() {
+    fun `test setHeaderIcon null hides header icon`() {
         edt {
             val panel = BaseQuestionView()
             panel.setHeaderIcon(AllIcons.General.Warning)
             panel.setHeaderIcon(null)
 
-            val header = panel.headerText.parent as Container
-            val labels = findAll(header).filter { it.icon != null && it.isVisible }
+            val labels = findAll(panel).filter { it.icon != null && it.isVisible }
             assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty())
-            assertSame(header, panel.headerText.parent)
         }
     }
 
-    fun `test col child count includes spacing before body and footer slots`() {
+    // ------ applyStyle: UI fonts ----
+
+    fun `test applyStyle applies headerFont to header and hintFont to description`() {
         edt {
             val panel = BaseQuestionView()
-            panel.setTopPanel(JLabel("top"))
-            assertEquals(3, findCol(panel)!!.componentCount)
-            panel.setBody(JLabel("body"))
-            assertEquals(5, findCol(panel)!!.componentCount)
-            panel.setFooter(JLabel("footer"))
-            assertEquals(7, findCol(panel)!!.componentCount)
-        }
-    }
-
-    fun `test body and footer spacing use matching standard insets`() {
-        edt {
-            val panel = BaseQuestionView()
-            val body = JLabel("body")
-            val footer = JLabel("footer")
-            panel.setBody(body)
-            panel.setFooter(footer)
-
-            val col = findCol(panel)!!
-            val comps = col.components.toList()
-            val bodyGap = comps[comps.indexOf(body) - 1]
-            val footerGap = comps[comps.indexOf(footer) - 1]
-
-            assertEquals(bodyGap.preferredSize.height, footerGap.preferredSize.height)
-        }
-    }
-
-    fun `test col shrinks back after removing optional slots`() {
-        edt {
-            val panel = BaseQuestionView()
-            panel.setTopPanel(JLabel("top"))
-            panel.setBody(JLabel("body"))
-            panel.setFooter(JLabel("footer"))
-
-            panel.setTopPanel(null)
-            panel.setBody(null)
-            panel.setFooter(null)
-
-            assertEquals(2, findCol(panel)!!.componentCount)
-        }
-    }
-
-    // ------ applyStyle: UI fonts ------
-
-    fun `test applyStyle applies enlarged boldUiFont to header and enlarged uiFont to description`() {
-        edt {
-            val panel = BaseQuestionView()
-            val style = SessionEditorStyle.create(family = "Courier New", size = 20)
+            panel.setHeader("Title", "Hint")
+            val style = SessionEditorStyle.current()
             panel.applyStyle(style)
 
-            assertEquals("headerText should keep boldUiFont family", style.boldUiFont.name, panel.headerText.font.name)
-            assertEquals("descriptionText should keep uiFont family", style.uiFont.name, panel.descriptionText.font.name)
-            assertEquals("headerText should use next font size", style.boldUiFont.size + 1, panel.headerText.font.size)
-            assertEquals("descriptionText should use next font size", style.uiFont.size + 1, panel.descriptionText.font.size)
-        }
-    }
-
-    fun `test description uses next standard top padding`() {
-        edt {
-            val panel = BaseQuestionView()
-            val ins = panel.descriptionText.border.getBorderInsets(panel.descriptionText)
-
-            assertEquals("description top padding should use next standard gap", UiStyle.Gap.sm(), ins.top)
+            assertEquals("headerText should use headerFont", style.headerFont, panel.headerFont())
+            assertEquals("descriptionText should use hintFont", style.hintFont, panel.descriptionFont())
         }
     }
 
     fun `test applyStyle does not apply editor font family to header or description`() {
         edt {
             val panel = BaseQuestionView()
+            panel.setHeader("Title", "Hint")
             val style = SessionEditorStyle.create(family = "Courier New", size = 20)
             panel.applyStyle(style)
 
-            assertFalse(
-                "headerText should not use editor font family",
-                panel.headerText.font.name == "Courier New",
-            )
-            assertFalse(
-                "descriptionText should not use editor font family",
-                panel.descriptionText.font.name == "Courier New",
-            )
+            assertFalse("headerText should not use editor font family", panel.headerFont().name == "Courier New")
+            assertFalse("descriptionText should not use editor font family", panel.descriptionFont().name == "Courier New")
+        }
+    }
+
+    fun `test description uses same vertical stacking as option descriptions`() {
+        edt {
+            val panel = BaseQuestionView()
+            panel.setHeader("Title", "Hint")
+            // The description text area is the non-bold one
+            val desc = findAll(panel).firstOrNull { !it.font.isBold }
+            assertNotNull(desc)
+            val ins = desc!!.border.getBorderInsets(desc)
+            assertEquals("description should not add extra top padding", 0, ins.top)
         }
     }
 
@@ -347,6 +358,17 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
         return null
     }
 
+    private fun find(root: JPanel, target: JButton): JButton? {
+        for (child in root.components) {
+            if (child === target) return target
+            if (child is JPanel) {
+                val found = find(child, target)
+                if (found != null) return found
+            }
+        }
+        return null
+    }
+
     private inline fun  findAll(root: Container): List = findAllCls(root, T::class.java)
 
     private fun  findAllCls(root: Container, cls: Class): List {

From 529183513912dc689597697b5d13f69101a81074 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 15:34:14 -0400
Subject: [PATCH 10/21] fix(jetbrains): support custom question responses

---
 .changeset/custom-question-jetbrains.md       |   5 +
 .../ai/kilocode/client/session/SessionUi.kt   |   1 +
 .../ui/editor/SessionEditorTextField.kt       |  32 ++
 .../ui/prompt/PromptEditorTextField.kt        |  13 +-
 .../session/views/question/QuestionView.kt    | 340 ++++++++++++++++-
 .../resources/messages/KiloBundle.properties  |   2 +
 .../client/session/views/QuestionViewTest.kt  | 341 ++++++++++++++++++
 7 files changed, 705 insertions(+), 29 deletions(-)
 create mode 100644 .changeset/custom-question-jetbrains.md
 create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt

diff --git a/.changeset/custom-question-jetbrains.md b/.changeset/custom-question-jetbrains.md
new file mode 100644
index 00000000000..97a63d7f16c
--- /dev/null
+++ b/.changeset/custom-question-jetbrains.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": patch
+---
+
+Support typed custom responses to question prompts in the JetBrains plugin.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
index 5d8c75951cb..d1a0ad99a5d 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
@@ -180,6 +180,7 @@ class SessionUi(
         load = LoadingPanel()
         progressBody = load
         question = QuestionView(
+            project = project,
             reply = { id, dto -> controller.replyQuestion(id, dto) },
             reject = { id -> controller.rejectQuestion(id) },
             scroll = { scroll.followBottom(true) },
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt
new file mode 100644
index 00000000000..366bc5b4801
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt
@@ -0,0 +1,32 @@
+package ai.kilocode.client.session.ui.editor
+
+import ai.kilocode.client.session.ui.prompt.PromptDataKeys
+import ai.kilocode.client.session.ui.prompt.SendPromptContext
+import com.intellij.openapi.actionSystem.DataSink
+import com.intellij.openapi.fileTypes.PlainTextFileType
+import com.intellij.openapi.project.Project
+import com.intellij.ui.EditorTextField
+
+/**
+ * A session-scoped [EditorTextField] for plain-text input.
+ *
+ * When [ctx] is non-null the component injects it into the data context so
+ * shortcut-based send/stop actions work (prompt use-case). When [ctx] is null
+ * the component does not expose [PromptDataKeys.SEND], preventing accidental
+ * `SendPromptAction` dispatch from question custom-answer editors.
+ *
+ * Both instances are created on the EDT. The underlying [EditorTextField]
+ * lazily initializes its IntelliJ editor the first time the component becomes
+ * visible; that initialization calls `EditorThreading.compute` internally,
+ * satisfying the platform's read-context requirement without additional
+ * wrapping here.
+ */
+internal open class SessionEditorTextField(
+    project: Project,
+    private val ctx: SendPromptContext? = null,
+) : EditorTextField(project, PlainTextFileType.INSTANCE) {
+    override fun uiDataSnapshot(sink: DataSink) {
+        super.uiDataSnapshot(sink)
+        ctx?.let { sink.set(PromptDataKeys.SEND, it) }
+    }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt
index e2462f96d86..c5aaa89b63f 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt
@@ -1,16 +1,9 @@
 package ai.kilocode.client.session.ui.prompt
 
-import com.intellij.openapi.actionSystem.DataSink
-import com.intellij.openapi.fileTypes.PlainTextFileType
+import ai.kilocode.client.session.ui.editor.SessionEditorTextField
 import com.intellij.openapi.project.Project
-import com.intellij.ui.EditorTextField
 
 internal class PromptEditorTextField(
     project: Project,
-    private val ctx: SendPromptContext,
-) : EditorTextField(project, PlainTextFileType.INSTANCE) {
-    override fun uiDataSnapshot(sink: DataSink) {
-        super.uiDataSnapshot(sink)
-        sink.set(PromptDataKeys.SEND, ctx)
-    }
-}
+    ctx: SendPromptContext,
+) : SessionEditorTextField(project, ctx)
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
index 0ec432e2ead..2613300e2df 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.Question
 import ai.kilocode.client.session.model.QuestionItem
 import ai.kilocode.client.session.model.QuestionOption
 import ai.kilocode.client.session.ui.SessionView
+import ai.kilocode.client.session.ui.editor.SessionEditorTextField
 import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
@@ -12,6 +13,7 @@ import ai.kilocode.client.ui.HoverIcon
 import ai.kilocode.client.ui.UiStyle
 import ai.kilocode.rpc.dto.QuestionReplyDto
 import com.intellij.icons.AllIcons
+import com.intellij.openapi.project.Project
 import com.intellij.openapi.util.IconLoader
 import com.intellij.ui.components.JBCheckBox
 import com.intellij.ui.components.JBLabel
@@ -19,10 +21,14 @@ import com.intellij.ui.components.JBRadioButton
 import com.intellij.ui.components.JBTextArea
 import com.intellij.util.ui.JBUI
 import com.intellij.util.ui.components.BorderLayoutPanel
+import javax.swing.ScrollPaneConstants
 import java.awt.BorderLayout
 import java.awt.Color
 import java.awt.Component
 import java.awt.Dimension
+import java.awt.GridBagLayout
+import java.awt.event.FocusAdapter
+import java.awt.event.FocusEvent
 import java.awt.event.MouseAdapter
 import java.awt.event.MouseEvent
 import javax.swing.AbstractButton
@@ -30,9 +36,12 @@ import javax.swing.Box
 import javax.swing.BoxLayout
 import javax.swing.ButtonGroup
 import javax.swing.JPanel
+import com.intellij.openapi.editor.event.DocumentEvent
+import com.intellij.openapi.editor.event.DocumentListener
 
 /** Question tool form rendered inside the session transcript. */
 class QuestionView(
+    private val project: Project,
     private val reply: (String, QuestionReplyDto) -> Unit,
     private val reject: (String) -> Unit,
     private val scroll: () -> Unit = {},
@@ -43,8 +52,15 @@ class QuestionView(
     private var question: Question? = null
     private var idx = 0
     private var selections = emptyList>()
+    // Per-question custom text state — survives navigation.
+    private var customTexts = emptyList()
+    // Per-question: whether the custom row is currently selected/open.
+    private var customOpen = emptyList()
     private var style = SessionEditorStyle.current()
     private val texts = mutableListOf>()
+    // The custom editor for the currently shown question; null when not shown.
+    private var customEditor: SessionEditorTextField? = null
+    private var customFocus: FocusAdapter? = null
 
     private val card = BaseQuestionView()
 
@@ -106,6 +122,8 @@ class QuestionView(
         question = q
         idx = 0
         selections = List(q.items.size) { mutableSetOf() }
+        customTexts = List(q.items.size) { "" }
+        customOpen = List(q.items.size) { false }
         isVisible = true
         syncPage()
     }
@@ -115,6 +133,10 @@ class QuestionView(
         question = null
         idx = 0
         selections = emptyList()
+        customTexts = emptyList()
+        customOpen = emptyList()
+        customEditor = null
+        customFocus = null
         texts.clear()
         body.removeAll()
         card.setActions(emptyList())
@@ -125,6 +147,11 @@ class QuestionView(
     override fun applyStyle(style: SessionEditorStyle) {
         this.style = style
         card.applyStyle(style)
+        customEditor?.let { ed ->
+            ed.font = style.transcriptFont
+            ed.getEditor(false)?.let(style::applyToEditor)
+            ed.background = style.editorScheme.defaultBackground
+        }
         val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
         if (!changed) return
         refresh()
@@ -133,6 +160,8 @@ class QuestionView(
     private fun syncPage() {
         val q = question ?: return
         texts.clear()
+        customEditor = null
+        customFocus = null
         body.removeAll()
         if (review(q)) {
             card.setHeader(KiloBundle.message("session.question.review.title"))
@@ -185,12 +214,47 @@ class QuestionView(
     }
 
     private fun syncControls(q: Question) {
-        val ready = selections.getOrNull(idx)?.isNotEmpty() == true
+        val ready = isReady(idx)
         back.isEnabled = idx > 0
         fwd.isEnabled = idx < q.items.size && ready
         card.setActionEnabled(ID_MAIN, review(q) || ready)
     }
 
+    /**
+     * Computes whether the question at [i] has an effective (non-blank) answer.
+     * For a question with custom=true and custom row selected, the custom text
+     * must be non-blank. For option-only answers the selection set must be non-empty.
+     */
+    private fun isReady(i: Int): Boolean {
+        val open = customOpen.getOrElse(i) { false }
+        val txt = customTexts.getOrElse(i) { "" }.trim()
+        val sel = selections.getOrNull(i)
+        return if (open) txt.isNotEmpty() else sel?.isNotEmpty() == true
+    }
+
+    /**
+     * Returns the effective answers for question at index [i] — what will be sent
+     * in the reply payload. Custom text is included when non-blank and the custom
+     * row is selected (single-select) or active (multi-select).
+     */
+    private fun effectiveAnswers(i: Int): List {
+        val q = question ?: return emptyList()
+        val item = q.items.getOrNull(i) ?: return emptyList()
+        val txt = customTexts.getOrElse(i) { "" }.trim()
+        val open = customOpen.getOrElse(i) { false }
+        val sel = selections.getOrNull(i) ?: emptySet()
+
+        return if (item.multiple) {
+            val result = sel.toMutableList()
+            if (open && txt.isNotEmpty() && txt !in result) result.add(txt)
+            result
+        } else {
+            // single-select: if custom is open, use custom text; otherwise use selection
+            if (open && txt.isNotEmpty()) listOf(txt)
+            else sel.toList()
+        }
+    }
+
     private fun addContent(item: QuestionItem, set: MutableSet) {
         val opts = optionList(item, set)
         opts.alignmentX = Component.LEFT_ALIGNMENT
@@ -213,11 +277,12 @@ class QuestionView(
             layout = BoxLayout(this, BoxLayout.Y_AXIS)
             border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
         }
-        val question = text(item.question, UiStyle.Colors.weak())
-        question.alignmentX = Component.LEFT_ALIGNMENT
-        row.add(question)
+        val qText = text(item.question, UiStyle.Colors.weak())
+        qText.alignmentX = Component.LEFT_ALIGNMENT
+        row.add(qText)
 
-        val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty()
+        val answers = effectiveAnswers(i)
+        val joined = answers.joinToString(", ")
         val answer = text(
             joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
             UiStyle.Colors.fg(),
@@ -239,12 +304,241 @@ class QuestionView(
             val group = ButtonGroup()
             for (opt in item.options) panel.add(radioRow(opt, set, group))
         }
-        // Remove bottom padding on the last option so the gap before the action
-        // footer matches the gap above the options (both use Gap.lg).
-        (panel.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
+
+        if (item.custom) {
+            panel.add(customRow(item, set))
+        } else {
+            // Remove bottom padding on the last option so the gap before the action
+            // footer matches the gap above the options (both use Gap.lg).
+            (panel.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
+        }
         return panel
     }
 
+    private fun customRow(item: QuestionItem, set: MutableSet): JPanel {
+        val open = customOpen.getOrElse(idx) { false }
+        val existing = customTexts.getOrElse(idx) { "" }.trim()
+        val showEditor = open || existing.isNotEmpty()
+        val row = JPanel().apply {
+            isOpaque = false
+            layout = BoxLayout(this, BoxLayout.Y_AXIS)
+            // No bottom padding — it's the last row
+            border = JBUI.Borders.empty()
+        }
+
+        val toggle: AbstractButton = if (item.multiple) {
+            JBCheckBox().apply {
+                actionCommand = ""
+                isSelected = open
+                isOpaque = false
+            }
+        } else {
+            JBRadioButton().apply {
+                actionCommand = ""
+                isSelected = open
+                isOpaque = false
+            }
+        }
+
+        val toggleListener = {
+            val wasOpen = customOpen.getOrElse(idx) { false }
+            if (!wasOpen) {
+                // Opening custom row
+                if (!item.multiple) {
+                    // Single-select: clear option selection
+                    set.clear()
+                }
+                customOpen = customOpen.toMutableList().also { it[idx] = true }
+            } else {
+                // Closing custom row
+                customOpen = customOpen.toMutableList().also { it[idx] = false }
+            }
+            refreshCustomRow()
+        }
+
+        if (item.multiple) {
+            (toggle as JBCheckBox).addActionListener { toggleListener() }
+        } else {
+            (toggle as JBRadioButton).addActionListener {
+                // When the custom radio is selected, deselect any option radio
+                set.clear()
+                customOpen = customOpen.toMutableList().also { it[idx] = true }
+                refreshCustomRow()
+            }
+        }
+
+        val press = object : MouseAdapter() {
+            override fun mouseClicked(e: MouseEvent) {
+                if (toggle.isEnabled) toggle.doClick()
+            }
+        }
+
+        val icon = JPanel(GridBagLayout()).apply {
+            isOpaque = false
+            border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
+            add(toggle)
+            addMouseListener(press)
+        }
+
+        val col = JPanel().apply {
+            isOpaque = false
+            layout = GridBagLayout()
+            addMouseListener(press)
+        }
+
+        val label = text(KiloBundle.message("session.question.custom.label"), UiStyle.Colors.fg(), true)
+        label.alignmentX = Component.LEFT_ALIGNMENT
+        label.addMouseListener(press)
+        col.add(label)
+
+        val header = JPanel(BorderLayout()).apply {
+            isOpaque = false
+            border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
+            toolTipText = null
+            alignmentX = Component.LEFT_ALIGNMENT
+        }
+        header.addMouseListener(press)
+        header.add(icon, BorderLayout.WEST)
+        header.add(col, BorderLayout.CENTER)
+        row.add(header)
+
+        if (showEditor) {
+            val ed = buildCustomEditor()
+            customEditor = ed
+            val focus = object : FocusAdapter() {
+                override fun focusGained(e: FocusEvent) = selectCustom(item, set)
+            }
+            customFocus = focus
+            ed.addFocusListener(focus)
+            ed.addSettingsProvider { ex ->
+                ex.contentComponent.addFocusListener(focus)
+                ex.component.addFocusListener(focus)
+            }
+            val edWrapper = JPanel(BorderLayout()).apply {
+                isOpaque = false
+                border = JBUI.Borders.empty(0, UiStyle.Gap.lg() + JBUI.scale(20), UiStyle.Gap.lg(), 0)
+                alignmentX = Component.LEFT_ALIGNMENT
+                add(ed, BorderLayout.CENTER)
+            }
+            row.add(edWrapper)
+        }
+
+        return row
+    }
+
+    internal fun testFocusCustomEditor() {
+        val ed = customEditor ?: return
+        val focus = customFocus ?: return
+        focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED))
+    }
+
+    private fun selectCustom(item: QuestionItem, set: MutableSet) {
+        if (customOpen.getOrElse(idx) { false }) return
+        if (!item.multiple) set.clear()
+        customOpen = customOpen.toMutableList().also { it[idx] = true }
+        refreshCustomRow()
+    }
+
+    /**
+     * Builds and wires a custom-answer [SessionEditorTextField].
+     *
+     * The component is created on the EDT (as required for all Swing components).
+     * [SessionEditorTextField] extends [com.intellij.ui.EditorTextField] which
+     * lazily initialises its IntelliJ editor via [com.intellij.openapi.editor.EditorThreading]
+     * the first time the component becomes visible, satisfying the platform's
+     * read-context requirement without any additional wrapping here.
+     */
+    private fun buildCustomEditor(): SessionEditorTextField {
+        val ed = SessionEditorTextField(project)
+        ed.border = JBUI.Borders.empty()
+        ed.setFontInheritedFromLAF(false)
+        ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder"))
+        ed.setShowPlaceholderWhenFocused(true)
+        ed.setOneLineMode(false)
+        ed.addSettingsProvider { ex ->
+            style.applyToEditor(ex)
+            ex.setBorder(JBUI.Borders.empty())
+            ex.scrollPane.border = JBUI.Borders.empty()
+            ex.scrollPane.viewportBorder = JBUI.Borders.empty()
+            ex.backgroundColor = style.editorScheme.defaultBackground
+            ex.scrollPane.background = style.editorScheme.defaultBackground
+            ex.scrollPane.viewport.background = style.editorScheme.defaultBackground
+            ex.settings.isUseSoftWraps = true
+            ex.settings.isAdditionalPageAtBottom = false
+            ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
+        }
+        ed.font = style.transcriptFont
+        ed.background = style.editorScheme.defaultBackground
+
+        // Pre-fill with saved text. This call also forces lazy document creation so
+        // that addDocumentListener can install on a non-null document immediately.
+        val saved = customTexts.getOrElse(idx) { "" }
+        ed.text = saved
+
+        // Sync preferred height to line count; update stored text on edits.
+        // EditorTextField.addDocumentListener is the preferred (non-deprecated) API.
+        // The document was already created above (ed.text = saved ensures getDocument()
+        // was called), so installDocumentListener succeeds.
+        ed.addDocumentListener(object : DocumentListener {
+            override fun documentChanged(e: DocumentEvent) {
+                val txt = ed.text
+                customTexts = customTexts.toMutableList().also { it[idx] = txt }
+                syncEditorHeight(ed)
+                question?.let(::syncControls)
+                refresh()
+                scroll()
+            }
+        })
+
+        syncEditorHeight(ed)
+        return ed
+    }
+
+    private fun syncEditorHeight(ed: SessionEditorTextField) {
+        val editor = ed.getEditor(false)
+        val estimated = estimatedLines(ed)
+        val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated)
+        val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height
+        val height = line * lines.coerceAtLeast(1) + JBUI.scale(16)
+        ed.preferredSize = Dimension(0, height)
+        ed.minimumSize = Dimension(0, height)
+    }
+
+    private fun estimatedLines(ed: SessionEditorTextField): Int {
+        val width = space(ed)
+        if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1)
+        val metrics = ed.getFontMetrics(ed.font)
+        val columns = (width / metrics.charWidth('m').coerceAtLeast(1)).coerceAtLeast(1)
+        return ed.text.lineSequence().sumOf { line ->
+            ((line.length + columns - 1) / columns).coerceAtLeast(1)
+        }.coerceAtLeast(1)
+    }
+
+    private fun space(component: Component): Int {
+        if (component.width > 0) return component.width
+        var node = component.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 0
+    }
+
+    /** Re-syncs the current page after the custom row toggle changes. */
+    private fun refreshCustomRow() {
+        val q = question ?: return
+        syncPage()
+        // Request focus on the editor when opening
+        if (customOpen.getOrElse(idx) { false }) {
+            customEditor?.requestFocusInWindow()
+        }
+        syncControls(q)
+        scroll()
+    }
+
     private fun radioRow(opt: QuestionOption, set: MutableSet, group: ButtonGroup): JPanel {
         val radio = JBRadioButton().apply {
             actionCommand = opt.label
@@ -255,7 +549,13 @@ class QuestionView(
         radio.addActionListener {
             set.clear()
             set.add(opt.label)
-            refreshSelection()
+            // Selecting a normal option closes the custom row
+            customOpen = customOpen.toMutableList().also { it[idx] = false }
+            if (customEditor == null) {
+                refreshSelection()
+                return@addActionListener
+            }
+            refreshCustomRow()
         }
         return optionRow(radio, opt)
     }
@@ -285,15 +585,16 @@ class QuestionView(
                 if (toggle.isEnabled) toggle.doClick()
             }
         }
-        val icon = JPanel(BorderLayout()).apply {
+        val center = opt.description.isBlank()
+        val icon = JPanel(if (center) GridBagLayout() else BorderLayout()).apply {
             isOpaque = false
             border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
-            add(toggle, BorderLayout.NORTH)
+            if (center) add(toggle) else add(toggle, BorderLayout.NORTH)
             addMouseListener(press)
         }
         val col = JPanel().apply {
             isOpaque = false
-            layout = BoxLayout(this, BoxLayout.Y_AXIS)
+            layout = if (center) GridBagLayout() else BoxLayout(this, BoxLayout.Y_AXIS)
             addMouseListener(press)
         }
         val label = text(opt.label, UiStyle.Colors.fg(), true)
@@ -377,12 +678,12 @@ class QuestionView(
 
     private fun goForward() {
         val q = question ?: return
-        if (idx >= q.items.size || selections.getOrNull(idx)?.isEmpty() != false) return
-        val review = idx == q.items.size - 1 && !direct(q)
-        if (review) {
+        if (idx >= q.items.size || !isReady(idx)) return
+        val toReview = idx == q.items.size - 1 && !direct(q)
+        if (toReview) {
             goReview()
         }
-        if (!review) {
+        if (!toReview) {
             idx++
             syncPage()
             scroll()
@@ -391,7 +692,7 @@ class QuestionView(
 
     private fun goReview() {
         val q = question ?: return
-        if (idx == q.items.size - 1 && selections[idx].isNotEmpty()) {
+        if (idx == q.items.size - 1 && isReady(idx)) {
             idx = q.items.size
             syncPage()
             scroll()
@@ -406,8 +707,9 @@ class QuestionView(
 
     private fun doReply() {
         val id = request ?: return
-        if (selections.any { it.isEmpty() }) return
-        reply(id, QuestionReplyDto(selections.map { it.toList() }))
+        if ((question?.items?.indices ?: return).any { !isReady(it) }) return
+        val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) }
+        reply(id, QuestionReplyDto(answers))
         hideView()
     }
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
index 9cf7af8c67b..631833ae75f 100644
--- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
+++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
@@ -62,6 +62,8 @@ session.question.review.title=Review your answers
 session.question.review.notAnswered=(not answered)
 session.question.result.title=Questions
 session.question.result.answered={0} answered
+session.question.custom.label=Add your own response
+session.question.custom.placeholder=Type your response...
 
 session.status.considering=Considering next steps…
 session.status.thinking=Thinking…
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index fbfe719a1d8..12d886574d5 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -10,13 +10,17 @@ import ai.kilocode.client.ui.HoverIcon
 import ai.kilocode.rpc.dto.QuestionReplyDto
 import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
 import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.ui.EditorTextField
 import com.intellij.ui.components.JBCheckBox
 import com.intellij.ui.components.JBLabel
 import com.intellij.ui.components.JBRadioButton
 import com.intellij.ui.components.JBTextArea
+import java.awt.Component
 import java.awt.Container
+import kotlin.math.abs
 import javax.swing.AbstractButton
 import javax.swing.JButton
+import javax.swing.SwingUtilities
 
 @Suppress("UnstableApiUsage")
 class QuestionViewTest : BasePlatformTestCase() {
@@ -29,6 +33,7 @@ class QuestionViewTest : BasePlatformTestCase() {
     override fun setUp() {
         super.setUp()
         view = QuestionView(
+            project = project,
             reply = { id, dto -> replies.add(id to dto) },
             reject = { id -> rejects.add(id) },
             scroll = { scrolls++ },
@@ -187,6 +192,51 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals("description should use regularFont", style.regularFont, desc.font)
     }
 
+    fun `test option row without description centers button beside label`() {
+        view.show(
+            Question(
+                id = "no_desc_center",
+                items = listOf(
+                    QuestionItem(
+                        question = "Pick one",
+                        header = "Pick",
+                        options = listOf(QuestionOption("Plain", "")),
+                        multiple = false,
+                        custom = false,
+                    )
+                ),
+            )
+        )
+        layout(view)
+
+        val radio = option(view, "Plain")
+        val label = text(view, "Plain")
+        val row = label.parent.parent as Container
+
+        val radioCenter = center(radio, row)
+        val labelCenter = center(label, row)
+        assertTrue(
+            "radio should be vertically centered with a single-line label: radio=$radioCenter label=$labelCenter row=${row.size}",
+            abs(radioCenter - labelCenter) <= 2,
+        )
+    }
+
+    fun `test custom row centers button beside label`() {
+        view.show(customSingleQuestion("custom_center"))
+        layout(view)
+
+        val radio = findAll(view).first { it.actionCommand == "" }
+        val label = text(view, "Add your own response")
+        val row = label.parent.parent as Container
+
+        val radioCenter = center(radio, row)
+        val labelCenter = center(label, row)
+        assertTrue(
+            "custom radio should be vertically centered with the label: radio=$radioCenter label=$labelCenter row=${row.size}",
+            abs(radioCenter - labelCenter) <= 2,
+        )
+    }
+
     fun `test question title uses headerFont and hint uses hintFont`() {
         view.show(singleSelectQuestion("q_fonts"))
 
@@ -514,6 +564,266 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals(listOf(listOf("A")), replies.single().second.answers)
     }
 
+    // ------ custom question row ------
+
+    fun `test custom row renders when custom is true`() {
+        view.show(customSingleQuestion("q_custom_present"))
+
+        assertLabelsContain(view, "Add your own response")
+    }
+
+    fun `test custom row is absent when custom is false`() {
+        view.show(singleSelectQuestion("q_custom_absent"))
+
+        assertLabelsDoNotContain(view, "Add your own response")
+    }
+
+    fun `test custom single select answer submits as typed text`() {
+        view.show(customSingleQuestion("q_custom_submit"))
+
+        // Click the custom radio button (actionCommand is "")
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+
+        // Find the editor that appeared and type text
+        val ed = findAll(view).first()
+        ed.text = "my custom answer"
+
+        button(view, "Submit").doClick()
+
+        assertFalse(view.isVisible)
+        assertEquals(1, replies.size)
+        assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers)
+    }
+
+    fun `test custom editor grows for wrapped input`() {
+        view.show(customSingleQuestion("q_custom_grow"))
+        layout(view, 240)
+
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+        layout(view, 240)
+
+        val ed = findAll(view).first()
+        val initial = ed.preferredSize.height
+        ed.text = "wrapped ".repeat(30)
+
+        assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial)
+    }
+
+    fun `test blank custom input does not enable submit`() {
+        view.show(customSingleQuestion("q_custom_blank"))
+
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+
+        val submit = button(view, "Submit")
+        assertFalse("Submit should remain disabled when custom text is blank", submit.isEnabled)
+    }
+
+    fun `test selecting normal option after custom input sends option not custom text`() {
+        view.show(customSingleQuestion("q_custom_revert"))
+
+        // Open custom and type something
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+        val ed = findAll(view).first()
+        ed.text = "stale custom"
+
+        // Now select a normal option
+        option(view, "Minimal").doClick()
+
+        button(view, "Submit").doClick()
+
+        assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
+    }
+
+    fun `test selecting normal option after custom input clears custom radio selection`() {
+        view.show(customSingleQuestion("q_custom_clear_radio"))
+
+        val radio = findAll(view).first { it.actionCommand == "" }
+        radio.doClick()
+        val ed = findAll(view).first()
+        ed.text = "stale custom"
+
+        option(view, "Minimal").doClick()
+
+        val custom = findAll(view).first { it.actionCommand == "" }
+        assertFalse("Custom radio should not stay selected after choosing a normal option", custom.isSelected)
+        assertTrue("Normal option should be selected", option(view, "Minimal").isSelected)
+        assertTrue("Custom editor should stay visible for non-empty text", findAll(view).any { it.parent != null && it.text == "stale custom" })
+        assertLabelsDoNotContain(view, "stale custom")
+    }
+
+    fun `test empty custom editor is removed after selecting normal option`() {
+        view.show(customSingleQuestion("q_custom_empty_editor"))
+
+        findAll(view).first { it.actionCommand == "" }.doClick()
+        assertNotNull(findAll(view).firstOrNull { it.parent != null })
+
+        option(view, "Minimal").doClick()
+
+        assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null })
+    }
+
+    fun `test focusing retained custom editor reselects custom response`() {
+        view.show(customSingleQuestion("q_custom_focus"))
+
+        findAll(view).first { it.actionCommand == "" }.doClick()
+        findAll(view).first().text = "stale custom"
+        option(view, "Minimal").doClick()
+
+        view.testFocusCustomEditor()
+
+        assertTrue("Custom radio should be selected when its editor takes focus", findAll(view).first { it.actionCommand == "" }.isSelected)
+        assertFalse("Normal option should be cleared when custom editor takes focus", option(view, "Minimal").isSelected)
+        assertEquals("Submit should send custom text after focusing retained editor", listOf(listOf("stale custom")), run {
+            button(view, "Submit").doClick()
+            replies.single().second.answers
+        })
+    }
+
+    fun `test multi select custom answer combines with selected options`() {
+        view.show(
+            Question(
+                id = "q_multi_custom",
+                items = listOf(
+                    QuestionItem(
+                        question = "Pick features",
+                        header = "Features",
+                        options = listOf(QuestionOption("A", ""), QuestionOption("B", "")),
+                        multiple = true,
+                        custom = true,
+                    )
+                ),
+            )
+        )
+
+        option(view, "A").doClick()
+        val customBox = findAll(view).first { it.actionCommand == "" }
+        customBox.doClick()
+        val ed = findAll(view).first()
+        ed.text = "extra"
+
+        button(view, "Review").doClick()
+        button(view, "Submit").doClick()
+
+        assertEquals(listOf(listOf("A", "extra")), replies.single().second.answers)
+    }
+
+    fun `test custom text appears in review`() {
+        view.show(
+            Question(
+                id = "q_custom_review",
+                items = listOf(
+                    QuestionItem(
+                        question = "How?",
+                        header = "H",
+                        options = listOf(QuestionOption("X", "")),
+                        multiple = false,
+                        custom = true,
+                    ),
+                    QuestionItem(
+                        question = "What?",
+                        header = "W",
+                        options = listOf(QuestionOption("Y", "")),
+                        multiple = false,
+                        custom = false,
+                    ),
+                ),
+            )
+        )
+
+        // Answer first with custom
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+        val ed = findAll(view).first()
+        ed.text = "typed answer"
+
+        button(view, "Next").doClick()
+        option(view, "Y").doClick()
+        button(view, "Review").doClick()
+
+        assertLabelsContain(view, "typed answer")
+    }
+
+    fun `test custom text preserved across navigation`() {
+        view.show(
+            Question(
+                id = "q_custom_nav",
+                items = listOf(
+                    QuestionItem(
+                        question = "How?",
+                        header = "H",
+                        options = listOf(QuestionOption("X", "")),
+                        multiple = false,
+                        custom = true,
+                    ),
+                    QuestionItem(
+                        question = "What?",
+                        header = "W",
+                        options = listOf(QuestionOption("Y", "")),
+                        multiple = false,
+                        custom = false,
+                    ),
+                ),
+            )
+        )
+
+        // Open custom on first question and type
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+        val ed = findAll(view).first()
+        ed.text = "preserved text"
+
+        // Navigate forward
+        button(view, "Next").doClick()
+        option(view, "Y").doClick()
+
+        // Navigate back
+        navButton(view, "Back").doClick()
+
+        // Custom row should still be open with the preserved text in the editor
+        val editorAfterBack = findAll(view).firstOrNull()
+        assertNotNull("Custom editor should still be visible after navigating back", editorAfterBack)
+        assertEquals("Custom editor should have preserved text", "preserved text", editorAfterBack!!.text)
+    }
+
+    fun `test optionless custom question is answerable`() {
+        view.show(
+            Question(
+                id = "q_optionless",
+                items = listOf(
+                    QuestionItem(
+                        question = "Free answer",
+                        header = "Free",
+                        options = emptyList(),
+                        multiple = false,
+                        custom = true,
+                    )
+                ),
+            )
+        )
+
+        // The custom row should be present
+        assertLabelsContain(view, "Add your own response")
+
+        // Open the custom row
+        val customRadio = findAll(view).first { it.actionCommand == "" }
+        customRadio.doClick()
+
+        val ed = findAll(view).first()
+        ed.text = "my answer"
+
+        val submit = button(view, "Submit")
+        assertTrue("Submit should be enabled after typing in optionless custom question", submit.isEnabled)
+
+        submit.doClick()
+
+        assertFalse(view.isVisible)
+        assertEquals(listOf(listOf("my answer")), replies.single().second.answers)
+    }
+
     // ------ helpers ------
 
     /**
@@ -533,6 +843,21 @@ class QuestionViewTest : BasePlatformTestCase() {
     private fun text(root: Container, value: String): JBTextArea =
         findAll(root).first { it.text == value }
 
+    private fun layout(root: Container, width: Int = 400) {
+        root.setSize(width, root.preferredSize.height)
+        layoutTree(root)
+    }
+
+    private fun layoutTree(root: Container) {
+        root.doLayout()
+        for (child in root.components) {
+            if (child is Container) layoutTree(child)
+        }
+    }
+
+    private fun center(component: Component, root: Component): Int =
+        SwingUtilities.convertPoint(component, 0, component.height / 2, root).y
+
     private fun singleSelectQuestion(id: String) = Question(
         id = id,
         items = listOf(
@@ -575,6 +900,22 @@ class QuestionViewTest : BasePlatformTestCase() {
         ),
     )
 
+    private fun customSingleQuestion(id: String) = Question(
+        id = id,
+        items = listOf(
+            QuestionItem(
+                question = "Choose approach",
+                header = "Approach",
+                options = listOf(
+                    QuestionOption("Minimal", "Smallest safe change"),
+                    QuestionOption("Balanced", "Focused implementation"),
+                ),
+                multiple = false,
+                custom = true,
+            )
+        ),
+    )
+
     private fun assertLabelsContain(root: Container, text: String) {
         val found = findAll(root).any { it.text == text } || findAll(root).any { it.text == text }
         assertTrue("Expected label '$text' to be present", found)

From 047e0c9172f1789765a056515a1765fd886e580c Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Thu, 21 May 2026 15:38:29 -0400
Subject: [PATCH 11/21] test(jetbrains): cover custom question edge cases

---
 .../client/session/views/QuestionViewTest.kt  | 75 +++++++++++++++----
 1 file changed, 61 insertions(+), 14 deletions(-)

diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
index 12d886574d5..b35c7e1538b 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt
@@ -684,20 +684,7 @@ class QuestionViewTest : BasePlatformTestCase() {
     }
 
     fun `test multi select custom answer combines with selected options`() {
-        view.show(
-            Question(
-                id = "q_multi_custom",
-                items = listOf(
-                    QuestionItem(
-                        question = "Pick features",
-                        header = "Features",
-                        options = listOf(QuestionOption("A", ""), QuestionOption("B", "")),
-                        multiple = true,
-                        custom = true,
-                    )
-                ),
-            )
-        )
+        view.show(customMultiQuestion("q_multi_custom"))
 
         option(view, "A").doClick()
         val customBox = findAll(view).first { it.actionCommand == "" }
@@ -711,6 +698,53 @@ class QuestionViewTest : BasePlatformTestCase() {
         assertEquals(listOf(listOf("A", "extra")), replies.single().second.answers)
     }
 
+    fun `test custom input is trimmed before submit`() {
+        view.show(customSingleQuestion("q_custom_trim"))
+
+        findAll(view).first { it.actionCommand == "" }.doClick()
+        findAll(view).first().text = "  trimmed answer  "
+
+        button(view, "Submit").doClick()
+
+        assertEquals(listOf(listOf("trimmed answer")), replies.single().second.answers)
+    }
+
+    fun `test multi select custom answer can be unchecked`() {
+        view.show(customMultiQuestion("q_multi_custom_unchecked"))
+
+        option(view, "A").doClick()
+        findAll(view).first { it.actionCommand == "" }.doClick()
+        findAll(view).first().text = "extra"
+
+        findAll(view).first { it.actionCommand == "" }.doClick()
+
+        assertFalse(
+            "Custom checkbox should be unchecked",
+            findAll(view).first { it.actionCommand == "" }.isSelected,
+        )
+        assertTrue("Review should stay enabled because a normal option is selected", button(view, "Review").isEnabled)
+        button(view, "Review").doClick()
+        assertLabelsContain(view, "A")
+        assertLabelsDoNotContain(view, "extra")
+
+        button(view, "Submit").doClick()
+
+        assertEquals(listOf(listOf("A")), replies.single().second.answers)
+    }
+
+    fun `test duplicate custom answer is submitted once`() {
+        view.show(customMultiQuestion("q_multi_custom_duplicate"))
+
+        option(view, "A").doClick()
+        findAll(view).first { it.actionCommand == "" }.doClick()
+        findAll(view).first().text = "A"
+
+        button(view, "Review").doClick()
+        button(view, "Submit").doClick()
+
+        assertEquals(listOf(listOf("A")), replies.single().second.answers)
+    }
+
     fun `test custom text appears in review`() {
         view.show(
             Question(
@@ -916,6 +950,19 @@ class QuestionViewTest : BasePlatformTestCase() {
         ),
     )
 
+    private fun customMultiQuestion(id: String) = Question(
+        id = id,
+        items = listOf(
+            QuestionItem(
+                question = "Pick features",
+                header = "Features",
+                options = listOf(QuestionOption("A", ""), QuestionOption("B", "")),
+                multiple = true,
+                custom = true,
+            )
+        ),
+    )
+
     private fun assertLabelsContain(root: Container, text: String) {
         val found = findAll(root).any { it.text == text } || findAll(root).any { it.text == text }
         assertTrue("Expected label '$text' to be present", found)

From fa4bc15117f1a6bac6ba08d05868940a7163ef92 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Fri, 22 May 2026 13:05:48 -0400
Subject: [PATCH 12/21] fix(jetbrains): compact permission rows

---
 .../jetbrains-permission-compact-rows.md      |   5 +
 .../session/views/PermissionDiffView.kt       |  47 +++
 .../client/session/views/PermissionView.kt    | 219 ++++++++------
 .../ai/kilocode/client/ui/DiffStatBadge.kt    |  63 ++++
 .../session/ui/SessionMessageListPanelTest.kt |   3 +-
 .../session/views/PermissionViewTest.kt       | 283 ++++++++++++------
 6 files changed, 429 insertions(+), 191 deletions(-)
 create mode 100644 .changeset/jetbrains-permission-compact-rows.md
 create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionDiffView.kt
 create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt

diff --git a/.changeset/jetbrains-permission-compact-rows.md b/.changeset/jetbrains-permission-compact-rows.md
new file mode 100644
index 00000000000..495d1ded6cd
--- /dev/null
+++ b/.changeset/jetbrains-permission-compact-rows.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": patch
+---
+
+Improve JetBrains permission prompts with compact action rows and diff badges.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionDiffView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionDiffView.kt
new file mode 100644
index 00000000000..9fcae60aceb
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionDiffView.kt
@@ -0,0 +1,47 @@
+package ai.kilocode.client.session.views
+
+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.ui.DiffStatBadge
+import ai.kilocode.client.ui.UiStyle
+import com.intellij.util.ui.JBUI
+import com.intellij.util.ui.components.BorderLayoutPanel
+import java.awt.FlowLayout
+
+/**
+ * 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.
+ */
+class PermissionDiffView(
+    private val diff: PermissionFileDiff,
+) : BorderLayoutPanel(), SessionEditorStyleTarget {
+
+    private val badge = DiffStatBadge(diff.additions, diff.deletions)
+
+    init {
+        isOpaque = false
+
+        val row = buildRow()
+        addToCenter(row)
+    }
+
+    override fun applyStyle(style: SessionEditorStyle) {
+        // Badge colors are theme-derived and update through Swing repainting.
+    }
+
+    private fun buildRow() = JBUI.Panels.simplePanel().apply {
+        isOpaque = false
+        border = JBUI.Borders.empty()
+
+        val inner = object : javax.swing.JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) {
+            init { isOpaque = false }
+        }
+        inner.add(badge)
+        addToCenter(inner)
+    }
+
+    // Test helpers
+    internal fun badgeForTest() = badge
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index 86670c5647f..db375b29b4c 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -2,31 +2,39 @@ package ai.kilocode.client.session.views
 
 import ai.kilocode.client.plugin.KiloBundle
 import ai.kilocode.client.session.model.Permission
+import ai.kilocode.client.session.model.PermissionFileDiff
 import ai.kilocode.client.session.model.PermissionRequestState
 import ai.kilocode.client.session.ui.SessionView
 import ai.kilocode.client.session.views.base.BaseQuestionView
 import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
+import ai.kilocode.client.session.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP
 import ai.kilocode.client.ui.UiStyle
-import ai.kilocode.client.ui.md.MdView
 import ai.kilocode.rpc.dto.PermissionReplyDto
 import com.intellij.icons.AllIcons
-import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.ColorUtil
+import com.intellij.ui.components.JBHtmlPane
+import com.intellij.ui.components.JBHtmlPaneConfiguration
+import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
+import com.intellij.ui.components.JBLabel
 import com.intellij.util.ui.JBUI
 import com.intellij.util.ui.components.BorderLayoutPanel
+import com.intellij.xml.util.XmlStringUtil
+import java.awt.BorderLayout
 import java.awt.Component
-import java.awt.Dimension
+import java.awt.FlowLayout
 import javax.swing.BoxLayout
+import javax.swing.JComponent
 import javax.swing.JPanel
-import javax.swing.ScrollPaneConstants
+import javax.swing.text.html.StyleSheet
 
 /**
  * Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
  * at the end of the transcript when the session is in
  * [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
  *
- * Shows a rich card with command/pattern/diff details and Run/Deny actions.
+ * Shows a compact row with action label and target as an inline code fragment, plus diff badges.
  */
 class PermissionView(
     private val reply: (String, PermissionReplyDto) -> Unit,
@@ -44,9 +52,9 @@ class PermissionView(
         alignmentX = Component.LEFT_ALIGNMENT
     }
 
-    // Track command MdView instances for style updates
-    private val cmdViews = mutableListOf()
-    private val cmdScrolls = mutableListOf()
+    // Track target panes for style updates
+    private val panes = mutableListOf()
+    private val diffViews = mutableListOf()
 
     private val ID_DENY = "deny"
     private val ID_RUN = "run"
@@ -71,18 +79,16 @@ class PermissionView(
         card.setHeader(KiloBundle.message("session.permission.title"))
 
         body.removeAll()
-        cmdViews.clear()
-        cmdScrolls.clear()
+        panes.clear()
+        diffViews.clear()
 
-        val toolName = permission.name
+        val tool = permission.name
         val cmd = permission.meta.command
-        val command = cmd != null || toolName == "bash"
 
-        if (command) {
-            addCodeBlock(cmd ?: "")
-        } else {
-            addCodeBlock(patternText(toolName, permission.patterns))
-        }
+        val action = toolLabel(tool)
+        val target = cmd ?: resolveTarget(permission)
+        addDetailRow(action, target, permission.meta.fileDiffs)
+        addStateMessage(permission)
 
         val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
         card.setActionEnabled(ID_RUN, !responding)
@@ -96,8 +102,8 @@ class PermissionView(
     fun hideView() {
         requestId = null
         body.removeAll()
-        cmdViews.clear()
-        cmdScrolls.clear()
+        panes.clear()
+        diffViews.clear()
         isVisible = false
         refresh()
     }
@@ -105,58 +111,110 @@ class PermissionView(
     override fun applyStyle(style: SessionEditorStyle) {
         this.style = style
         card.applyStyle(style)
-        for (md in cmdViews) {
-            applyMd(md)
+        for (pane in panes) {
+            applyTargetPane(pane)
         }
-        for (scroll in cmdScrolls) {
-            applyScroll(scroll)
+        for (dv in diffViews) {
+            dv.applyStyle(style)
         }
     }
 
-    private fun addCodeBlock(text: String) {
-        val md = MdView.html().apply {
-            applyMd(this)
-            component.border = JBUI.Borders.empty()
-            set(fencedBlock(text))
+    /** Adds a three-column permission detail row: tool, target, and changes. */
+    private fun addDetailRow(action: String, target: String?, diffs: List) {
+        val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply {
+            isOpaque = false
+            alignmentX = Component.LEFT_ALIGNMENT
         }
-        cmdViews.add(md)
 
-        val scroll = object : JBScrollPane(md.component) {
-            override fun getPreferredSize(): Dimension {
-                val fm = getFontMetrics(style.transcriptFont)
-                val cap = fm.height * SessionUiStyle.View.Permission.COMMAND_LINES + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
-                val ps = super.getPreferredSize()
-                return Dimension(ps.width, minOf(ps.height, cap))
-            }
-
-            override fun getMaximumSize(): Dimension {
-                val fm = getFontMetrics(style.transcriptFont)
-                val cap = fm.height * SessionUiStyle.View.Permission.COMMAND_LINES + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
-                return Dimension(Int.MAX_VALUE, cap)
-            }
-        }.apply {
-            verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
-            horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
-            applyScroll(this)
+        val actionLbl = JBLabel(action).apply {
+            font = UiStyle.Fonts.bold()
+            alignmentY = Component.CENTER_ALIGNMENT
         }
-        scroll.alignmentX = Component.LEFT_ALIGNMENT
-        cmdScrolls.add(scroll)
-        body.add(scroll)
+        row.add(actionLbl, BorderLayout.WEST)
+
+        if (!target.isNullOrBlank()) {
+            val pane = targetPane(target)
+            panes.add(pane)
+            row.add(pane, BorderLayout.CENTER)
+        }
+
+        if (diffs.isNotEmpty()) {
+            val changes = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply {
+                isOpaque = false
+                alignmentY = Component.CENTER_ALIGNMENT
+            }
+            for (diff in diffs) {
+                val dv = PermissionDiffView(diff)
+                diffViews.add(dv)
+                changes.add(dv)
+            }
+            row.add(changes, BorderLayout.EAST)
+        }
+
+        body.add(row)
     }
 
-    private fun patternText(tool: String, patterns: List): String {
-        val lbl = toolLabel(tool)
-        val filtered = patterns.filter { it != "*" }
-        if (filtered.isEmpty()) {
-            return KiloBundle.message("session.permission.no.details", lbl)
+    private fun JComponent.withGap(left: Int, right: Boolean) = JBUI.Panels.simplePanel(this).apply {
+        isOpaque = false
+        border = JBUI.Borders.empty(0, left, 0, if (right) UiStyle.Gap.sm() else 0)
+    }
+
+    private fun targetPane(text: String) = JBHtmlPane(
+        JBHtmlPaneStyleConfiguration {},
+        JBHtmlPaneConfiguration {
+            customStyleSheetProvider { targetSheet() }
+        },
+    ).apply {
+        isEditable = false
+        isOpaque = true
+        this.text = "
${XmlStringUtil.escapeString(text)}
" + applyTargetPane(this) + } + + private fun applyTargetPane(pane: JBHtmlPane) { + pane.font = style.transcriptFont + pane.foreground = style.editorForeground + pane.background = SessionUiStyle.View.headerHover() + pane.reloadCssStylesheets() + } + + private fun targetSheet(): StyleSheet { + val sheet = StyleSheet() + val font = style.transcriptFont + val fg = ColorUtil.toHtmlColor(style.editorForeground) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val family = font.name.replace("\\", "\\\\").replace("'", "\\'") + sheet.addRule("body { margin: 0; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") + sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") + return sheet + } + + private fun resolveTarget(permission: Permission): String? { + val path = permission.meta.filePath + if (!path.isNullOrBlank()) return path + + val filtered = permission.patterns.filter { it != "*" } + return when { + filtered.size == 1 -> filtered[0] + filtered.size > 1 -> filtered.joinToString(", ") + else -> null } - if (filtered.size == 1) { - return "$lbl ${filtered[0]}" - } - return buildString { - appendLine(KiloBundle.message("session.permission.patterns", lbl)) - append(filtered.joinToString("\n")) + } + + private fun addStateMessage(permission: Permission) { + val msg = when (permission.state) { + PermissionRequestState.ERROR -> + permission.message ?: KiloBundle.message("session.permission.error") + PermissionRequestState.RESPONDING -> + KiloBundle.message("session.permission.responding") + else -> null + } ?: return + + val label = JBLabel(msg).apply { + border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + alignmentX = Component.LEFT_ALIGNMENT } + body.add(label) } private fun toolLabel(tool: String): String = when (tool) { @@ -188,28 +246,6 @@ class PermissionView( reply(id, PermissionReplyDto(reply = value)) } - private fun applyMd(md: MdView) { - val bg = codeBackground() - md.opaque = true - md.font = style.transcriptFont - md.foreground = style.editorForeground - md.background = bg - md.preBg = bg - md.codeBg = bg - md.preFg = style.editorForeground - md.codeFont = style.editorFamily - md.component.background = bg - } - - private fun applyScroll(scroll: JBScrollPane) { - val bg = codeBackground() - scroll.border = JBUI.Borders.empty() - scroll.background = bg - scroll.viewport.background = bg - } - - private fun codeBackground() = SessionUiStyle.View.headerHover() - private fun refresh() { revalidate() repaint() @@ -220,22 +256,7 @@ class PermissionView( // Test helpers internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!! internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!! - internal fun firstCmdViewForTest() = cmdViews.firstOrNull() + internal fun codeLabelsForTest() = panes.toList() + internal fun diffViewsForTest() = diffViews.toList() internal fun headerFontForTest() = card.headerFont() } - -/** - * Wrap [cmd] in a fenced Markdown code block. The fence uses at least 3 backticks, - * and is extended to be longer than any contiguous run of backticks inside [cmd] - * so the fence cannot be broken by content. - */ -private fun fencedBlock(cmd: String): String { - var max = 2 - var run = 0 - for (ch in cmd) { - run = if (ch == '`') run + 1 else 0 - if (run > max) max = run - } - val fence = "`".repeat(max + 1) - return "$fence\n$cmd\n$fence" -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt new file mode 100644 index 00000000000..1882f0a9ee3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.ui + +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JPanel + +internal class DiffStatBadge( + additions: Int, + deletions: Int, +) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) { + private val removed = JBLabel("-$deletions").apply { + foreground = removedColor() + font = JBFont.small() + } + private val added = JBLabel("+$additions").apply { + foreground = addedColor() + font = JBFont.small() + } + + init { + isOpaque = false + add(removed) + add(added) + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = backgroundColor() + g2.fillRoundRect(0, 0, width, height, height, height) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + internal fun removedLabelForTest() = removed + + internal fun addedLabelForTest() = added +} + +private fun backgroundColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.background", + JBColor(Color(0x26, 0x26, 0x26), Color(0x26, 0x26, 0x26)), +) + +private fun removedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.removedForeground", + JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)), +) + +private fun addedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.addedForeground", + JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)), +) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a8ab4951f2f..43dfeb29ac8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -366,7 +366,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {}) lv.show("Sign in required.") - lv.openProfileButton.doClick() + lv.openProfileButton().doClick() assertTrue(called) } @@ -453,6 +453,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private fun panelWithPrompts(): SessionMessageListPanel { val q = QuestionView( + project = project, reply = { _, _ -> }, reject = { _ -> }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt index f99b026b57d..33bab897d25 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt @@ -11,12 +11,9 @@ import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane import java.awt.Container import javax.swing.AbstractButton -import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class PermissionViewTest : BasePlatformTestCase() { @@ -64,7 +61,7 @@ class PermissionViewTest : BasePlatformTestCase() { assertFalse(view.isVisible) } - fun `test blank patterns display no-details fallback`() { + fun `test blank patterns show only action label with no code fragment`() { view.show( Permission( id = "perm2", @@ -77,12 +74,13 @@ class PermissionViewTest : BasePlatformTestCase() { ) assertTrue(view.isVisible) - // Should have text saying edit requires permission val text = allText(view) assertTrue("Expected tool label in text, got: $text", text.contains("Edit")) + // No code label should be added when there is no target + assertTrue("Expected no code labels for empty patterns", view.codeLabelsForTest().isEmpty()) } - fun `test star-only patterns use no-details fallback`() { + fun `test star-only patterns show action label with no code fragment`() { view.show( Permission( id = "perm3", @@ -97,9 +95,10 @@ class PermissionViewTest : BasePlatformTestCase() { assertTrue(view.isVisible) val text = allText(view) assertTrue("Expected Read label in text, got: $text", text.contains("Read")) + assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty()) } - fun `test bash permission shows command`() { + fun `test bash permission shows action and command on same row`() { view.show( Permission( id = "perm4", @@ -112,10 +111,14 @@ class PermissionViewTest : BasePlatformTestCase() { ) val text = allText(view) + assertTrue("Expected Shell action label in text, got: $text", text.contains("Shell")) assertTrue("Expected command in text, got: $text", text.contains("git status --short")) + val labels = view.codeLabelsForTest() + assertEquals("Expected exactly one target pane for command", 1, labels.size) + assertTrue("Expected command in target pane, got: ${labels[0].text}", labels[0].text.contains("git status --short")) } - fun `test bash permission shows only header and code block content`() { + fun `test bash permission shows only header and compact detail`() { view.show( Permission( id = "perm4b", @@ -131,11 +134,11 @@ class PermissionViewTest : BasePlatformTestCase() { val text = allText(view) assertTrue("Expected permission header, got: $text", text.contains("Permission required")) assertTrue("Expected command in text, got: $text", text.contains("git status --short")) - assertFalse("Should not show command label, got: $text", text.contains("Command")) - assertFalse("Should not show permission message, got: $text", text.contains("Run this command?")) + // State message should not appear for PENDING state + assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?")) } - fun `test non-bash patterns show tool and path`() { + fun `test non-bash patterns show action and path as separate labels`() { view.show( Permission( id = "perm5", @@ -149,32 +152,32 @@ class PermissionViewTest : BasePlatformTestCase() { val text = allText(view) assertTrue("Expected 'Read' in text, got: $text", text.contains("Read")) - assertTrue("Expected path in text, got: $text", text.contains("src/")) - assertTrue("Expected path in text, got: $text", text.contains("App")) - assertTrue("Expected path in text, got: $text", text.contains("kt")) + assertTrue("Expected path in text, got: $text", text.containsPath("src/App.kt")) + + val labels = view.codeLabelsForTest() + assertEquals("Expected exactly one target pane for the pattern", 1, labels.size) + assertTrue("Expected path in target pane, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt")) } - fun `test non-bash patterns render as fenced code block via MdView`() { + fun `test multiple patterns joined in code label`() { view.show( Permission( - id = "perm_pattern_md", + id = "perm_multi", sessionId = "ses", name = "glob", - patterns = listOf("packages/kilo-jetbrains/**/*.kt"), + patterns = listOf("src/*.kt", "test/*.kt"), always = emptyList(), meta = PermissionMeta(), ) ) - val panes = findAll(view) - assertTrue("Expected at least one JBHtmlPane for pattern details", panes.isNotEmpty()) - val html = panes.first().text - assertTrue("Expected
 tag in rendered HTML, got: $html", html.contains(" block
-        val panes = findAll(view)
-        assertTrue("Expected at least one JBHtmlPane for the command MdView", panes.isNotEmpty())
-        val html = panes.first().text
-        assertTrue("Expected 
 tag in rendered HTML, got: $html", html.contains("(view)
-        val cmdScroll = scrolls.firstOrNull { it.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED }
-        assertNotNull("Expected a JBScrollPane with VERTICAL_SCROLLBAR_AS_NEEDED for the command", cmdScroll)
-
-        val maxH = cmdScroll!!.maximumSize.height
-        assertTrue("Maximum height should be capped (> 0)", maxH > 0)
-        assertTrue("Maximum height should be finite (< Int.MAX_VALUE)", maxH < Int.MAX_VALUE)
-    }
-
-    fun `test code block scroll pane uses code background`() {
-        view.show(
-            Permission(
-                id = "perm_bg",
-                sessionId = "ses",
-                name = "bash",
-                patterns = emptyList(),
-                always = emptyList(),
-                meta = PermissionMeta(command = "pwd"),
-            )
-        )
-
-        val scroll = findAll(view).first { it.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED }
-        assertEquals(SessionUiStyle.View.headerHover(), scroll.background)
-        assertEquals(SessionUiStyle.View.headerHover(), scroll.viewport.background)
-    }
-
-    // ------ fonts: header UI family, command code block editor family ------
-
     fun `test permission header uses headerFont not editor font family`() {
         view.show(
             Permission(
@@ -384,23 +478,21 @@ class PermissionViewTest : BasePlatformTestCase() {
         assertEquals("Permission header should equal headerFont", style.headerFont, header)
     }
 
-    fun `test command code block retains editor font family`() {
+    fun `test code label uses code background`() {
         view.show(
             Permission(
-                id = "perm_codefont",
+                id = "perm_bg",
                 sessionId = "ses",
                 name = "bash",
                 patterns = emptyList(),
                 always = emptyList(),
-                meta = PermissionMeta(command = "git log"),
+                meta = PermissionMeta(command = "pwd"),
             )
         )
-        val style = SessionEditorStyle.create(family = "Courier New", size = 18)
-        view.applyStyle(style)
 
-        val md = view.firstCmdViewForTest()
-        assertNotNull("Should have at least one command MdView", md)
-        assertEquals("Code block codeFont should use editor family", "Courier New", md!!.codeFont)
+        val labels = view.codeLabelsForTest()
+        assertFalse("Expected code labels", labels.isEmpty())
+        assertEquals(SessionUiStyle.View.headerHover(), labels[0].background)
     }
 
     private fun permission() = Permission(
@@ -430,6 +522,15 @@ class PermissionViewTest : BasePlatformTestCase() {
         collect(root)
     }
 
+    private fun occurrences(text: String, token: String): Int {
+        if (token.isEmpty()) return 0
+        return text.split(token).size - 1
+    }
+
+    private fun String.containsPath(path: String) = pathOccurrences(this, path) > 0
+
+    private fun pathOccurrences(text: String, path: String): Int = occurrences(text.replace("", ""), path)
+
     private inline fun  findAll(root: Container): List = findAllCls(root, T::class.java)
 
     private fun  findAllCls(root: Container, cls: Class): List {

From dcfa1029c31962d97f8c78298d937b241c629683 Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Fri, 22 May 2026 13:37:55 -0400
Subject: [PATCH 13/21] feat(jetbrains): add Align wrapper with TRACK mode,
 replace CenterShrinkPanel

---
 packages/kilo-jetbrains/AGENTS.md             |  38 ++
 .../client/session/ui/EmptySessionPanel.kt    |   9 +-
 .../kotlin/ai/kilocode/client/ui/Align.kt     | 152 +++++++
 .../kilocode/client/ui/CenterShrinkPanel.kt   |  38 --
 .../kotlin/ai/kilocode/client/ui/AlignTest.kt | 396 ++++++++++++++++++
 5 files changed, 591 insertions(+), 42 deletions(-)
 create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
 delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt
 create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt

diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md
index 13112339cd5..c3e6df981c7 100644
--- a/packages/kilo-jetbrains/AGENTS.md
+++ b/packages/kilo-jetbrains/AGENTS.md
@@ -366,6 +366,44 @@ For common spacing lookups, prefer `JBUI.CurrentTheme` area-specific insets (e.g
 | Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` |
 | Simple vertical custom Swing groups | `VerticalLayout` |
 | Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` |
+| Single-component alignment wrapper | `ai.kilocode.client.ui.Align` — see section below |
+
+### Align — Single-Component Alignment Wrapper
+
+Use `Align` (`ai.kilocode.client.ui.Align`) when a single Swing component must be positioned inside available space without adding visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out its one child according to independent horizontal (`HAlign`) and vertical (`VAlign`) modes. `CenterShrinkPanel` has been removed; use `child.alignCenter()` as a direct replacement.
+
+**Alignment modes:**
+
+| Mode | Axis | Layout behavior | Wrapper size contribution |
+|---|---|---|---|
+| `HAlign.TRACK` / `VAlign.TRACK` | either | Child always fills all available space; ignores child min/preferred/max | Zero (wrapper reports insets only on that axis) |
+| `HAlign.FIT` / `VAlign.FIT` | either | Child fills available space clamped to child's effective `[min, max]` range | Child min/preferred/max respected |
+| `HAlign.LEFT` / `VAlign.TOP` | H / V | Child placed at left/top edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
+| `HAlign.CENTER` / `VAlign.CENTER` | H / V | Child centered at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
+| `HAlign.RIGHT` / `VAlign.BOTTOM` | H / V | Child placed at right/bottom edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
+
+"Bounded preferred" means the child's preferred size coerced into the effective `[min, max]` range. If available space is smaller than the effective minimum, the layout shrinks the child to available space to avoid overflow.
+
+**Kotlin-style factory extensions** on `Component`:
+
+```kotlin
+child.align(HAlign.LEFT, VAlign.TOP)   // explicit modes
+child.alignCenter()                     // CENTER / CENTER (replaces CenterShrinkPanel)
+child.alignLeft(VAlign.CENTER)          // LEFT + custom V
+child.alignRight(VAlign.CENTER)         // RIGHT + custom V
+child.alignTop(HAlign.CENTER)           // TOP + custom H
+child.alignBottom()                     // BOTTOM + FIT horizontal
+child.track()                           // TRACK / TRACK — always fills all space
+child.trackX(VAlign.TOP)               // TRACK horizontal, TOP vertical
+child.trackY(HAlign.CENTER)            // CENTER horizontal, TRACK vertical
+```
+
+**Rules:**
+
+- Prefer the factory extensions over creating one-off `JPanel(FlowLayout(...))` or `BorderLayoutPanel` wrappers just to control alignment.
+- Use `TRACK` when the child must occupy all available space on an axis and must not reserve any space in the parent's size negotiation on that axis. Use `FIT` when you want to fill available space but still respect child min/max constraints.
+- All non-TRACK modes include the child's min, preferred, and max sizes in the wrapper's own min/preferred/max size. This means parent layout managers see the child constraints through the wrapper.
+- Do not use `Align` for spacing, padding, borders, colors, or multi-child layout — use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, or an appropriate layout manager for those concerns.
 
 ### IntelliJ UI Surfaces
 
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
index 5c726fc0ac1..6f83dc6bc51 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
@@ -10,8 +10,9 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.session.controller.SessionController
-import ai.kilocode.client.ui.CenterShrinkPanel
+import ai.kilocode.client.ui.Align
 import ai.kilocode.client.ui.UiStyle
+import ai.kilocode.client.ui.alignCenter
 import ai.kilocode.rpc.dto.SessionDto
 import com.intellij.icons.AllIcons
 import com.intellij.openapi.Disposable
@@ -44,7 +45,7 @@ import javax.swing.ListSelectionModel
  * Empty-session panel.
  *
  * The content is a BorderLayout panel, wrapped in a
- * [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering.
+ * [Align] (exposed as [view]) so callers need not know about centering.
  */
 class EmptySessionPanel(
     parent: Disposable,
@@ -52,7 +53,7 @@ class EmptySessionPanel(
     recents: List,
     private val history: () -> Unit = {},
 ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
-    val view: CenterShrinkPanel = CenterShrinkPanel(this)
+    val view: Align = alignCenter()
 
     private val model = DefaultListModel()
     private var hover = -1
@@ -133,7 +134,7 @@ class EmptySessionPanel(
         val header = BorderLayoutPanel(0, gap).apply {
             isOpaque = false
             add(logo, BorderLayout.NORTH)
-            add(CenterShrinkPanel(description), BorderLayout.CENTER)
+            add(description.alignCenter(), BorderLayout.CENTER)
         }
 
         val recent = BorderLayoutPanel().apply {
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
new file mode 100644
index 00000000000..c1669e8876f
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
@@ -0,0 +1,152 @@
+package ai.kilocode.client.ui
+
+import java.awt.Component
+import java.awt.Dimension
+import javax.swing.JPanel
+
+enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT }
+enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM }
+
+/**
+ * A transparent wrapper panel that positions its single child according to independent
+ * horizontal ([h]) and vertical ([v]) alignment modes.
+ *
+ * **TRACK**: child fills all available space on that axis, ignoring child min/preferred/max.
+ * The wrapper reports zero contribution from the child on that axis for its own min/preferred/max.
+ *
+ * **FIT**: child fills available space clamped to child's effective [min, max] range.
+ *
+ * **LEFT / CENTER / RIGHT** (horizontal) and **TOP / CENTER / BOTTOM** (vertical):
+ * child uses its bounded preferred size (coerced into [min, max]) and is placed at the
+ * corresponding edge or centered. Shrinks to available space when necessary.
+ *
+ * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution
+ * (zero for TRACK axes) with the panel insets.
+ *
+ * Use the Kotlin-style factory extensions for concise call sites:
+ * ```
+ * label.alignCenter()
+ * button.alignRight(VAlign.CENTER)
+ * panel.align(HAlign.LEFT, VAlign.TOP)
+ * content.track()
+ * scrollable.trackX(VAlign.TOP)
+ * ```
+ */
+class Align(
+    child: Component,
+    private val h: HAlign = HAlign.FIT,
+    private val v: VAlign = VAlign.FIT,
+) : JPanel(null) {
+
+    init {
+        isOpaque = false
+        add(child)
+    }
+
+    // -----------------------------------------------------------------------
+    // Layout
+    // -----------------------------------------------------------------------
+
+    override fun doLayout() {
+        if (componentCount == 0) return
+        val child = getComponent(0)
+        val ins = insets
+        val availW = maxOf(0, width - ins.left - ins.right)
+        val availH = maxOf(0, height - ins.top - ins.bottom)
+
+        val (w, cx) = placeAxis(h, availW, child.minimumSize.width, child.preferredSize.width, child.maximumSize.width)
+        val (ht, cy) = placeAxis(v, availH, child.minimumSize.height, child.preferredSize.height, child.maximumSize.height)
+
+        child.setBounds(ins.left + cx, ins.top + cy, w, ht)
+    }
+
+    // -----------------------------------------------------------------------
+    // Wrapper size negotiation
+    // -----------------------------------------------------------------------
+
+    override fun getMinimumSize(): Dimension {
+        if (componentCount == 0) return super.getMinimumSize()
+        val child = getComponent(0)
+        val ins = insets
+        val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width
+        val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height
+        return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
+    }
+
+    override fun getPreferredSize(): Dimension {
+        if (componentCount == 0) return super.getPreferredSize()
+        val child = getComponent(0)
+        val ins = insets
+        val cw = if (h == HAlign.TRACK) 0 else bounded(child.preferredSize.width, child.minimumSize.width, child.maximumSize.width)
+        val ch = if (v == VAlign.TRACK) 0 else bounded(child.preferredSize.height, child.minimumSize.height, child.maximumSize.height)
+        return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
+    }
+
+    override fun getMaximumSize(): Dimension {
+        if (componentCount == 0) return super.getMaximumSize()
+        val child = getComponent(0)
+        val ins = insets
+        val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right
+        val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom
+        return Dimension(cw, ch)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns (size, offset) for a single axis. Offset is relative to the inner origin (after insets).
+ * - TRACK: size = avail, offset = 0
+ * - FIT: size = clamp(avail, min, max), offset = 0
+ * - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment
+ */
+private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair {
+    val effMax = maxOf(min, max)
+    return when (mode) {
+        HAlign.TRACK, VAlign.TRACK -> avail to 0
+        HAlign.FIT, VAlign.FIT -> {
+            // fill available, capped at effMax; if avail < min we still shrink to avail
+            val size = minOf(avail, effMax)
+            size to 0
+        }
+        HAlign.LEFT, VAlign.TOP -> {
+            val size = minOf(bounded(pref, min, effMax), avail)
+            size to 0
+        }
+        HAlign.CENTER, VAlign.CENTER -> {
+            val size = minOf(bounded(pref, min, effMax), avail)
+            size to (avail - size) / 2
+        }
+        HAlign.RIGHT, VAlign.BOTTOM -> {
+            val size = minOf(bounded(pref, min, effMax), avail)
+            size to (avail - size)
+        }
+        else -> avail to 0
+    }
+}
+
+private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max))
+
+// ---------------------------------------------------------------------------
+// Kotlin-style factory extensions
+// ---------------------------------------------------------------------------
+
+fun Component.align(h: HAlign = HAlign.FIT, v: VAlign = VAlign.FIT) = Align(this, h, v)
+
+fun Component.alignCenter() = Align(this, HAlign.CENTER, VAlign.CENTER)
+
+fun Component.alignLeft(v: VAlign = VAlign.FIT) = Align(this, HAlign.LEFT, v)
+
+fun Component.alignRight(v: VAlign = VAlign.FIT) = Align(this, HAlign.RIGHT, v)
+
+fun Component.alignTop(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TOP)
+
+fun Component.alignBottom(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.BOTTOM)
+
+fun Component.track() = Align(this, HAlign.TRACK, VAlign.TRACK)
+
+fun Component.trackX(v: VAlign = VAlign.FIT) = Align(this, HAlign.TRACK, v)
+
+fun Component.trackY(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TRACK)
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt
deleted file mode 100644
index 57e7d95e5f4..00000000000
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package ai.kilocode.client.ui
-
-import java.awt.Component
-import java.awt.Dimension
-import javax.swing.JPanel
-
-/**
- * Centers its single child and shrinks it to available space when needed.
- * If available space is larger than the child's maximum size, the child is not expanded.
- */
-class CenterShrinkPanel(child: Component) : JPanel(null) {
-    init {
-        isOpaque = false
-        add(child)
-    }
-
-    override fun doLayout() {
-        if (componentCount == 0) return
-        val child = getComponent(0)
-        val insets = getInsets()
-        val availW = width - insets.left - insets.right
-        val availH = height - insets.top - insets.bottom
-        val pref = child.preferredSize
-        val max = child.maximumSize
-        val w = minOf(pref.width, max.width, availW)
-        val h = minOf(pref.height, max.height, availH)
-        val x = insets.left + (availW - w) / 2
-        val y = insets.top + (availH - h) / 2
-        child.setBounds(x, y, w, h)
-    }
-
-    override fun getPreferredSize(): Dimension {
-        if (componentCount == 0) return super.getPreferredSize()
-        val pref = getComponent(0).preferredSize
-        val insets = getInsets()
-        return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom)
-    }
-}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt
new file mode 100644
index 00000000000..841e9f237e2
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt
@@ -0,0 +1,396 @@
+package ai.kilocode.client.ui
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.ui.components.JBLabel
+import com.intellij.util.ui.JBUI
+import java.awt.Dimension
+
+@Suppress("UnstableApiUsage")
+class AlignTest : BasePlatformTestCase() {
+
+    // ------ structure ------
+
+    fun `test wrapper is non-opaque`() {
+        assertFalse(Align(JBLabel("x"), HAlign.FIT, VAlign.FIT).isOpaque)
+    }
+
+    fun `test wrapper contains exactly the wrapped child`() {
+        val child = JBLabel("x")
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        assertEquals(1, wrap.componentCount)
+        assertSame(child, wrap.getComponent(0))
+    }
+
+    // ------ FIT / FIT basic fill ------
+
+    fun `test FIT FIT fills assigned inner bounds`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 200, 100, child)
+    }
+
+    fun `test FIT FIT respects insets`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        wrap.border = JBUI.Borders.empty(5, 10, 5, 10)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(10, 5, 180, 90, child)
+    }
+
+    // ------ FIT respects max ------
+
+    fun `test FIT FIT caps at maximum size`() {
+        val child = child(pref = 40 x 20, max = 60 x 30)
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        // available > max → capped at max, placed at top-left
+        assertBounds(0, 0, 60, 30, child)
+    }
+
+    fun `test FIT FIT expands to minimum when available between min and pref`() {
+        val child = child(min = 30 x 15, pref = 80 x 40, max = 200 x 100)
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        wrap.setBounds(0, 0, 50, 25)
+        wrap.doLayout()
+        // available (50x25) is within [min, max], so child gets exactly available
+        assertBounds(0, 0, 50, 25, child)
+    }
+
+    fun `test FIT FIT shrinks to available when available below minimum`() {
+        val child = child(min = 80 x 40, pref = 80 x 40)
+        val wrap = Align(child, HAlign.FIT, VAlign.FIT)
+        wrap.setBounds(0, 0, 30, 10)
+        wrap.doLayout()
+        // cannot respect min when space is smaller
+        assertBounds(0, 0, 30, 10, child)
+    }
+
+    // ------ CENTER / CENTER ------
+
+    fun `test CENTER CENTER centers at preferred size when space sufficient`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(80, 40, 40, 20, child)
+    }
+
+    fun `test CENTER CENTER coerces preferred up to minimum`() {
+        val child = child(min = 60 x 30, pref = 40 x 20)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        // preferred < min → use min (60x30), centered
+        assertBounds(70, 35, 60, 30, child)
+    }
+
+    fun `test CENTER CENTER caps preferred at maximum`() {
+        val child = child(pref = 100 x 60, max = 40 x 20)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        // preferred > max → use max (40x20), centered
+        assertBounds(80, 40, 40, 20, child)
+    }
+
+    fun `test CENTER CENTER fits when bounded preferred exceeds available`() {
+        val child = child(pref = 300 x 200)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 100, 80)
+        wrap.doLayout()
+        assertBounds(0, 0, 100, 80, child)
+    }
+
+    fun `test CENTER CENTER shrinks to available when available below minimum`() {
+        val child = child(min = 150 x 90, pref = 150 x 90)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 100, 60)
+        wrap.doLayout()
+        assertBounds(0, 0, 100, 60, child)
+    }
+
+    // ------ LEFT / TOP ------
+
+    fun `test LEFT TOP positions at top-left with bounded preferred`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 40, 20, child)
+    }
+
+    fun `test LEFT TOP respects max`() {
+        val child = child(pref = 100 x 60, max = 40 x 20)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 40, 20, child)
+    }
+
+    fun `test LEFT TOP shrinks to available`() {
+        val child = child(pref = 300 x 200)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        wrap.setBounds(0, 0, 100, 80)
+        wrap.doLayout()
+        assertBounds(0, 0, 100, 80, child)
+    }
+
+    // ------ RIGHT / BOTTOM ------
+
+    fun `test RIGHT BOTTOM positions at bottom-right with bounded preferred`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(160, 80, 40, 20, child)
+    }
+
+    fun `test RIGHT BOTTOM respects max`() {
+        val child = child(pref = 100 x 60, max = 40 x 20)
+        val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(160, 80, 40, 20, child)
+    }
+
+    fun `test RIGHT BOTTOM shrinks to available`() {
+        val child = child(pref = 300 x 200)
+        val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM)
+        wrap.setBounds(0, 0, 100, 80)
+        wrap.doLayout()
+        assertBounds(0, 0, 100, 80, child)
+    }
+
+    // ------ insets with edge modes ------
+
+    fun `test CENTER CENTER insets honored`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.border = JBUI.Borders.empty(10, 20, 10, 20)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        val ins = wrap.insets  // 10,20,10,20
+        // inner: 160x80; child 40x20
+        assertBounds(ins.left + 60, ins.top + 30, 40, 20, child)
+    }
+
+    fun `test RIGHT BOTTOM insets honored`() {
+        val child = child(pref = 40 x 20)
+        val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM)
+        wrap.border = JBUI.Borders.empty(5, 5, 5, 5)
+        wrap.setBounds(0, 0, 100, 80)
+        wrap.doLayout()
+        val ins = wrap.insets
+        // inner: 90x70; child 40x20
+        assertBounds(ins.left + 50, ins.top + 50, 40, 20, child)
+    }
+
+    // ------ wrapper preferred/min/max sizes (non-TRACK) ------
+
+    fun `test preferredSize equals bounded child pref plus insets`() {
+        val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.border = JBUI.Borders.empty(4, 6, 4, 6)
+        val ins = wrap.insets
+        // pref(80) coerced into [30,60] = 60; pref(40) coerced into [15,30] = 30
+        val ps = wrap.preferredSize
+        assertEquals(60 + ins.left + ins.right, ps.width)
+        assertEquals(30 + ins.top + ins.bottom, ps.height)
+    }
+
+    fun `test minimumSize equals child min plus insets`() {
+        val child = child(min = 30 x 15, pref = 80 x 40)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        wrap.border = JBUI.Borders.empty(4, 6, 4, 6)
+        val ins = wrap.insets
+        val ms = wrap.minimumSize
+        assertEquals(30 + ins.left + ins.right, ms.width)
+        assertEquals(15 + ins.top + ins.bottom, ms.height)
+    }
+
+    fun `test maximumSize equals effective child max plus insets`() {
+        val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        wrap.border = JBUI.Borders.empty(4, 6, 4, 6)
+        val ins = wrap.insets
+        val xs = wrap.maximumSize
+        assertEquals(60 + ins.left + ins.right, xs.width)
+        assertEquals(30 + ins.top + ins.bottom, xs.height)
+    }
+
+    fun `test maximumSize uses min when max is smaller than min`() {
+        // max < min → effective max should be at least min
+        val child = child(min = 50 x 30, pref = 50 x 30, max = 10 x 5)
+        val wrap = Align(child, HAlign.LEFT, VAlign.TOP)
+        val ins = wrap.insets
+        val xs = wrap.maximumSize
+        assertEquals(50 + ins.left + ins.right, xs.width)
+        assertEquals(30 + ins.top + ins.bottom, xs.height)
+    }
+
+    // ------ CenterShrinkPanel parity ------
+
+    fun `test CENTER CENTER matches old CenterShrinkPanel center-and-shrink behavior`() {
+        // child pref is larger than max → should center at max size, not overflow
+        val child = child(pref = 100 x 60, max = 40 x 20)
+        val wrap = Align(child, HAlign.CENTER, VAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        // expected: max(40x20), centered → x=(200-40)/2=80, y=(100-20)/2=40
+        assertBounds(80, 40, 40, 20, child)
+    }
+
+    // ------ TRACK / TRACK ------
+
+    fun `test TRACK TRACK fills all available regardless of child constraints`() {
+        val child = child(min = 10 x 5, pref = 40 x 20, max = 60 x 30)
+        val wrap = Align(child, HAlign.TRACK, VAlign.TRACK)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 200, 100, child)
+    }
+
+    fun `test TRACK TRACK preferred and min size are just insets`() {
+        val child = child(min = 50 x 30, pref = 80 x 40, max = 100 x 60)
+        val wrap = Align(child, HAlign.TRACK, VAlign.TRACK)
+        wrap.border = JBUI.Borders.empty(4, 6, 4, 6)
+        val ins = wrap.insets
+        val ps = wrap.preferredSize
+        val ms = wrap.minimumSize
+        assertEquals(ins.left + ins.right, ps.width)
+        assertEquals(ins.top + ins.bottom, ps.height)
+        assertEquals(ins.left + ins.right, ms.width)
+        assertEquals(ins.top + ins.bottom, ms.height)
+    }
+
+    fun `test TRACK TRACK max size is not capped by child max`() {
+        val child = child(pref = 40 x 20, max = 60 x 30)
+        val wrap = Align(child, HAlign.TRACK, VAlign.TRACK)
+        val xs = wrap.maximumSize
+        // wrapper max must be larger than child max since TRACK should allow any size
+        assertTrue("wrapper maxW ${xs.width} should exceed child maxW 60", xs.width > 60)
+        assertTrue("wrapper maxH ${xs.height} should exceed child maxH 30", xs.height > 30)
+    }
+
+    // ------ mixed TRACK + non-TRACK ------
+
+    fun `test TRACK H FIT V fills width ignores child constraints on H only`() {
+        val child = child(min = 30 x 15, pref = 40 x 20, max = 60 x 30)
+        val wrap = Align(child, HAlign.TRACK, VAlign.FIT)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        // H=TRACK → width=200; V=FIT → height clamped to [15,30]=30
+        assertBounds(0, 0, 200, 30, child)
+    }
+
+    fun `test TRACK H preferred is inset-only on H axis with child bounded pref on V axis`() {
+        val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30)
+        val wrap = Align(child, HAlign.TRACK, VAlign.CENTER)
+        val ins = wrap.insets
+        val ps = wrap.preferredSize
+        // H=TRACK → horizontal contribution = 0
+        assertEquals(ins.left + ins.right, ps.width)
+        // V=CENTER → bounded pref height = clamp(40,[15,30]) = 30
+        assertEquals(30 + ins.top + ins.bottom, ps.height)
+    }
+
+    // ------ factory helpers ------
+
+    fun `test align extension returns Align wrapping child`() {
+        val child = JBLabel("x")
+        assertSame(child, child.align(HAlign.LEFT, VAlign.TOP).getComponent(0))
+    }
+
+    fun `test alignCenter produces CENTER CENTER`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.alignCenter()
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(80, 40, 40, 20, child)
+    }
+
+    fun `test alignRight produces RIGHT with given VAlign`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.alignRight(VAlign.TOP)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(160, 0, 40, 20, child)
+    }
+
+    fun `test alignLeft with default FIT vertical fills height`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.alignLeft()
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 40, 100, child)
+    }
+
+    fun `test alignTop with CENTER horizontal centers and pins to top`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.alignTop(HAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(80, 0, 40, 20, child)
+    }
+
+    fun `test alignBottom with default FIT horizontal fills width and pins to bottom`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.alignBottom()
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 80, 200, 20, child)
+    }
+
+    fun `test track fills all space and wrapper preferred is inset-only`() {
+        val child = child(pref = 40 x 20, max = 60 x 30)
+        val wrap = child.track()
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 200, 100, child)
+        val ins = wrap.insets
+        assertEquals(ins.left + ins.right, wrap.preferredSize.width)
+        assertEquals(ins.top + ins.bottom, wrap.preferredSize.height)
+    }
+
+    fun `test trackX fills width only, V respects preferred`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.trackX(VAlign.TOP)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(0, 0, 200, 20, child)
+    }
+
+    fun `test trackY fills height only, H respects preferred`() {
+        val child = child(pref = 40 x 20)
+        val wrap = child.trackY(HAlign.CENTER)
+        wrap.setBounds(0, 0, 200, 100)
+        wrap.doLayout()
+        assertBounds(80, 0, 40, 100, child)
+    }
+
+    // ------ helpers ------
+
+    private infix fun Int.x(h: Int) = Dimension(this, h)
+
+    private fun child(
+        min: Dimension = Dimension(0, 0),
+        pref: Dimension,
+        max: Dimension = Dimension(Int.MAX_VALUE, Int.MAX_VALUE),
+    ) = object : JBLabel("x") {
+        override fun getMinimumSize() = min
+        override fun getPreferredSize() = pref
+        override fun getMaximumSize() = max
+    }
+
+    private fun assertBounds(x: Int, y: Int, w: Int, h: Int, c: java.awt.Component) {
+        val b = c.bounds
+        assertEquals("x", x, b.x)
+        assertEquals("y", y, b.y)
+        assertEquals("width", w, b.width)
+        assertEquals("height", h, b.height)
+    }
+}

From 20214bbfe90bf7d13c529e4bc300c08a103d435e Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Fri, 22 May 2026 13:47:24 -0400
Subject: [PATCH 14/21] refactor(jetbrains): drop Align shorthand helpers, keep
 only align(h, v)

---
 packages/kilo-jetbrains/AGENTS.md             | 17 ++++------
 .../client/session/ui/EmptySessionPanel.kt    |  8 +++--
 .../client/session/views/PermissionView.kt    | 19 ++++-------
 .../kotlin/ai/kilocode/client/ui/Align.kt     | 29 ++++------------
 .../kotlin/ai/kilocode/client/ui/AlignTest.kt | 34 +++++++++----------
 5 files changed, 41 insertions(+), 66 deletions(-)

diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md
index c3e6df981c7..30548b1221e 100644
--- a/packages/kilo-jetbrains/AGENTS.md
+++ b/packages/kilo-jetbrains/AGENTS.md
@@ -384,23 +384,18 @@ Use `Align` (`ai.kilocode.client.ui.Align`) when a single Swing component must b
 
 "Bounded preferred" means the child's preferred size coerced into the effective `[min, max]` range. If available space is smaller than the effective minimum, the layout shrinks the child to available space to avoid overflow.
 
-**Kotlin-style factory extensions** on `Component`:
+**Factory extension** on `Component`:
 
 ```kotlin
-child.align(HAlign.LEFT, VAlign.TOP)   // explicit modes
-child.alignCenter()                     // CENTER / CENTER (replaces CenterShrinkPanel)
-child.alignLeft(VAlign.CENTER)          // LEFT + custom V
-child.alignRight(VAlign.CENTER)         // RIGHT + custom V
-child.alignTop(HAlign.CENTER)           // TOP + custom H
-child.alignBottom()                     // BOTTOM + FIT horizontal
-child.track()                           // TRACK / TRACK — always fills all space
-child.trackX(VAlign.TOP)               // TRACK horizontal, TOP vertical
-child.trackY(HAlign.CENTER)            // CENTER horizontal, TRACK vertical
+child.align(HAlign.LEFT, VAlign.TOP)      // left-aligned, top-pinned
+child.align(HAlign.CENTER, VAlign.CENTER) // centered (replaces CenterShrinkPanel)
+child.align(HAlign.TRACK, VAlign.CENTER)  // fill width, center vertically
+child.align(HAlign.TRACK, VAlign.TRACK)   // fill all available space
 ```
 
 **Rules:**
 
-- Prefer the factory extensions over creating one-off `JPanel(FlowLayout(...))` or `BorderLayoutPanel` wrappers just to control alignment.
+- Prefer `child.align(h, v)` over creating one-off `JPanel(FlowLayout(...))` or `BorderLayoutPanel` wrappers just to control alignment.
 - Use `TRACK` when the child must occupy all available space on an axis and must not reserve any space in the parent's size negotiation on that axis. Use `FIT` when you want to fill available space but still respect child min/max constraints.
 - All non-TRACK modes include the child's min, preferred, and max sizes in the wrapper's own min/preferred/max size. This means parent layout managers see the child constraints through the wrapper.
 - Do not use `Align` for spacing, padding, borders, colors, or multi-child layout — use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, or an appropriate layout manager for those concerns.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
index 6f83dc6bc51..aca701f9aa1 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt
@@ -11,8 +11,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.session.controller.SessionController
 import ai.kilocode.client.ui.Align
+import ai.kilocode.client.ui.HAlign
 import ai.kilocode.client.ui.UiStyle
-import ai.kilocode.client.ui.alignCenter
+import ai.kilocode.client.ui.VAlign
+import ai.kilocode.client.ui.align
 import ai.kilocode.rpc.dto.SessionDto
 import com.intellij.icons.AllIcons
 import com.intellij.openapi.Disposable
@@ -53,7 +55,7 @@ class EmptySessionPanel(
     recents: List,
     private val history: () -> Unit = {},
 ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
-    val view: Align = alignCenter()
+    val view: Align = align(HAlign.CENTER, VAlign.CENTER)
 
     private val model = DefaultListModel()
     private var hover = -1
@@ -134,7 +136,7 @@ class EmptySessionPanel(
         val header = BorderLayoutPanel(0, gap).apply {
             isOpaque = false
             add(logo, BorderLayout.NORTH)
-            add(description.alignCenter(), BorderLayout.CENTER)
+            add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
         }
 
         val recent = BorderLayoutPanel().apply {
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
index db375b29b4c..3f18126ed4b 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt
@@ -10,7 +10,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
 import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
 import ai.kilocode.client.session.ui.style.SessionUiStyle
 import ai.kilocode.client.session.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP
+import ai.kilocode.client.ui.HAlign
 import ai.kilocode.client.ui.UiStyle
+import ai.kilocode.client.ui.VAlign
+import ai.kilocode.client.ui.align
 import ai.kilocode.rpc.dto.PermissionReplyDto
 import com.intellij.icons.AllIcons
 import com.intellij.ui.ColorUtil
@@ -25,7 +28,6 @@ import java.awt.BorderLayout
 import java.awt.Component
 import java.awt.FlowLayout
 import javax.swing.BoxLayout
-import javax.swing.JComponent
 import javax.swing.JPanel
 import javax.swing.text.html.StyleSheet
 
@@ -128,37 +130,30 @@ class PermissionView(
 
         val actionLbl = JBLabel(action).apply {
             font = UiStyle.Fonts.bold()
-            alignmentY = Component.CENTER_ALIGNMENT
         }
-        row.add(actionLbl, BorderLayout.WEST)
+        row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST)
 
         if (!target.isNullOrBlank()) {
             val pane = targetPane(target)
             panes.add(pane)
-            row.add(pane, BorderLayout.CENTER)
+            row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
         }
 
         if (diffs.isNotEmpty()) {
-            val changes = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply {
+            val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
                 isOpaque = false
-                alignmentY = Component.CENTER_ALIGNMENT
             }
             for (diff in diffs) {
                 val dv = PermissionDiffView(diff)
                 diffViews.add(dv)
                 changes.add(dv)
             }
-            row.add(changes, BorderLayout.EAST)
+            row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
         }
 
         body.add(row)
     }
 
-    private fun JComponent.withGap(left: Int, right: Boolean) = JBUI.Panels.simplePanel(this).apply {
-        isOpaque = false
-        border = JBUI.Borders.empty(0, left, 0, if (right) UiStyle.Gap.sm() else 0)
-    }
-
     private fun targetPane(text: String) = JBHtmlPane(
         JBHtmlPaneStyleConfiguration {},
         JBHtmlPaneConfiguration {
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
index c1669e8876f..e54f94f97cd 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt
@@ -23,13 +23,12 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM }
  * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution
  * (zero for TRACK axes) with the panel insets.
  *
- * Use the Kotlin-style factory extensions for concise call sites:
+ * Use the factory extension for concise call sites:
  * ```
- * label.alignCenter()
- * button.alignRight(VAlign.CENTER)
+ * label.align(HAlign.CENTER, VAlign.CENTER)
+ * button.align(HAlign.RIGHT, VAlign.CENTER)
  * panel.align(HAlign.LEFT, VAlign.TOP)
- * content.track()
- * scrollable.trackX(VAlign.TOP)
+ * scrollable.align(HAlign.TRACK, VAlign.TOP)
  * ```
  */
 class Align(
@@ -130,23 +129,7 @@ private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pai
 private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max))
 
 // ---------------------------------------------------------------------------
-// Kotlin-style factory extensions
+// Factory extension
 // ---------------------------------------------------------------------------
 
-fun Component.align(h: HAlign = HAlign.FIT, v: VAlign = VAlign.FIT) = Align(this, h, v)
-
-fun Component.alignCenter() = Align(this, HAlign.CENTER, VAlign.CENTER)
-
-fun Component.alignLeft(v: VAlign = VAlign.FIT) = Align(this, HAlign.LEFT, v)
-
-fun Component.alignRight(v: VAlign = VAlign.FIT) = Align(this, HAlign.RIGHT, v)
-
-fun Component.alignTop(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TOP)
-
-fun Component.alignBottom(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.BOTTOM)
-
-fun Component.track() = Align(this, HAlign.TRACK, VAlign.TRACK)
-
-fun Component.trackX(v: VAlign = VAlign.FIT) = Align(this, HAlign.TRACK, v)
-
-fun Component.trackY(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TRACK)
+fun Component.align(h: HAlign, v: VAlign) = Align(this, h, v)
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt
index 841e9f237e2..de78c958739 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt
@@ -298,56 +298,56 @@ class AlignTest : BasePlatformTestCase() {
         assertEquals(30 + ins.top + ins.bottom, ps.height)
     }
 
-    // ------ factory helpers ------
+    // ------ align() factory ------
 
     fun `test align extension returns Align wrapping child`() {
         val child = JBLabel("x")
         assertSame(child, child.align(HAlign.LEFT, VAlign.TOP).getComponent(0))
     }
 
-    fun `test alignCenter produces CENTER CENTER`() {
+    fun `test align CENTER CENTER produces centered layout`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.alignCenter()
+        val wrap = child.align(HAlign.CENTER, VAlign.CENTER)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(80, 40, 40, 20, child)
     }
 
-    fun `test alignRight produces RIGHT with given VAlign`() {
+    fun `test align RIGHT TOP positions at top-right`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.alignRight(VAlign.TOP)
+        val wrap = child.align(HAlign.RIGHT, VAlign.TOP)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(160, 0, 40, 20, child)
     }
 
-    fun `test alignLeft with default FIT vertical fills height`() {
+    fun `test align LEFT FIT fills height`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.alignLeft()
+        val wrap = child.align(HAlign.LEFT, VAlign.FIT)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(0, 0, 40, 100, child)
     }
 
-    fun `test alignTop with CENTER horizontal centers and pins to top`() {
+    fun `test align CENTER TOP centers horizontally and pins to top`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.alignTop(HAlign.CENTER)
+        val wrap = child.align(HAlign.CENTER, VAlign.TOP)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(80, 0, 40, 20, child)
     }
 
-    fun `test alignBottom with default FIT horizontal fills width and pins to bottom`() {
+    fun `test align FIT BOTTOM fills width and pins to bottom`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.alignBottom()
+        val wrap = child.align(HAlign.FIT, VAlign.BOTTOM)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(0, 80, 200, 20, child)
     }
 
-    fun `test track fills all space and wrapper preferred is inset-only`() {
+    fun `test align TRACK TRACK fills all space and wrapper preferred is inset-only`() {
         val child = child(pref = 40 x 20, max = 60 x 30)
-        val wrap = child.track()
+        val wrap = child.align(HAlign.TRACK, VAlign.TRACK)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(0, 0, 200, 100, child)
@@ -356,17 +356,17 @@ class AlignTest : BasePlatformTestCase() {
         assertEquals(ins.top + ins.bottom, wrap.preferredSize.height)
     }
 
-    fun `test trackX fills width only, V respects preferred`() {
+    fun `test align TRACK TOP fills width only, V respects preferred`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.trackX(VAlign.TOP)
+        val wrap = child.align(HAlign.TRACK, VAlign.TOP)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(0, 0, 200, 20, child)
     }
 
-    fun `test trackY fills height only, H respects preferred`() {
+    fun `test align CENTER TRACK fills height only, H respects preferred`() {
         val child = child(pref = 40 x 20)
-        val wrap = child.trackY(HAlign.CENTER)
+        val wrap = child.align(HAlign.CENTER, VAlign.TRACK)
         wrap.setBounds(0, 0, 200, 100)
         wrap.doLayout()
         assertBounds(80, 0, 40, 100, child)

From c4d85ca5ff6faaf94d00158bbcc4f557a1211bfe Mon Sep 17 00:00:00 2001
From: kirillk 
Date: Fri, 22 May 2026 13:53:43 -0400
Subject: [PATCH 15/21] refactor(jetbrains): move Permission views to
 views.permission, Align to ui.layout

---
 .kilo/plans/1779201984530-curious-rocket.md   | 1167 +++++++++++++++++
 .kilo/plans/1779228770810-stellar-panda.md    |   88 ++
 .kilo/plans/1779321031193-witty-island.md     |   66 +
 .kilo/plans/1779385276828-misty-garden.md     |   91 ++
 .kilo/plans/1779387267038-sunny-pixel.md      |   88 ++
 .kilo/plans/1779392475600-misty-engine.md     |  557 ++++++++
 .kilo/plans/1779394430104-stellar-falcon.md   |   51 +
 .kilo/plans/1779467130119-kind-squid.md       |  163 +++
 .../ai/kilocode/client/session/SessionUi.kt   |    2 +-
 .../client/session/ui/EmptySessionPanel.kt    |    8 +-
 .../session/ui/SessionMessageListPanel.kt     |    2 +-
 .../{ => permission}/PermissionDiffView.kt    |    2 +-
 .../views/{ => permission}/PermissionView.kt  |    8 +-
 .../kilocode/client/ui/{ => layout}/Align.kt  |    2 +-
 .../client/session/SessionUiLayoutTest.kt     |    2 +-
 .../session/ui/SessionMessageListPanelTest.kt |    2 +-
 .../{ => permission}/PermissionViewTest.kt    |    2 +-
 .../client/ui/{ => layout}/AlignTest.kt       |    2 +-
 18 files changed, 2287 insertions(+), 16 deletions(-)
 create mode 100644 .kilo/plans/1779201984530-curious-rocket.md
 create mode 100644 .kilo/plans/1779228770810-stellar-panda.md
 create mode 100644 .kilo/plans/1779321031193-witty-island.md
 create mode 100644 .kilo/plans/1779385276828-misty-garden.md
 create mode 100644 .kilo/plans/1779387267038-sunny-pixel.md
 create mode 100644 .kilo/plans/1779392475600-misty-engine.md
 create mode 100644 .kilo/plans/1779394430104-stellar-falcon.md
 create mode 100644 .kilo/plans/1779467130119-kind-squid.md
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/{ => permission}/PermissionDiffView.kt (96%)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/{ => permission}/PermissionView.kt (98%)
 rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/{ => layout}/Align.kt (99%)
 rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/{ => permission}/PermissionViewTest.kt (99%)
 rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/{ => layout}/AlignTest.kt (99%)

diff --git a/.kilo/plans/1779201984530-curious-rocket.md b/.kilo/plans/1779201984530-curious-rocket.md
new file mode 100644
index 00000000000..022167a9b19
--- /dev/null
+++ b/.kilo/plans/1779201984530-curious-rocket.md
@@ -0,0 +1,1167 @@
+# Permission Views For JetBrains — VS Code Parity Implementation Plan
+
+## What To Build
+
+Implement JetBrains permission prompt parity with VS Code for the current request only:
+
+- Rich permission card in the JetBrains session transcript.
+- `Run` / allow once and `Deny` / reject actions.
+- Runtime auto-approve enabled toggle that replies `once` automatically while enabled.
+- Command and diff previews for permission requests.
+
+Explicitly do not implement persistent auto-approved rule editing in this pass:
+
+- No “Manage Auto-Approve Rules” section.
+- No per-rule allow/deny toggles in the permission prompt.
+- No permission settings tab/editor work.
+- No new `PermissionAlwaysRulesDto` calls from the prompt UI.
+- Do not surface full `config.permission` just to prefill rule states.
+
+## Required Context Before Editing
+
+Read first:
+
+- `packages/kilo-jetbrains/AGENTS.md`
+  - Use standard Swing/IntelliJ UI components only.
+  - Do not add Kotlin UI DSL, Compose, or JCEF.
+  - All Swing UI mutation must happen on EDT.
+  - Add user-facing strings to bundle files.
+- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt`
+- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt`
+- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt`
+- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt`
+- `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt`
+- `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt`
+
+VS Code reference files:
+
+- `packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx`
+- `packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts`
+- `packages/kilo-vscode/webview-ui/src/components/chat/permission-diff-utils.ts`
+- `packages/kilo-vscode/src/commands/toggle-auto-approve.ts`
+
+## Current JetBrains Architecture Summary
+
+The JetBrains plugin is split-mode:
+
+- `shared/`: serializable RPC contracts and DTOs.
+- `backend/`: CLI process, HTTP/SSE parsing, backend RPC implementations.
+- `frontend/`: Swing UI, session controller/model/view.
+
+Current permission path:
+
+1. CLI emits `permission.asked` over SSE.
+2. `KiloBackendChatManager` receives the SSE event and calls `KiloCliDataParser.parseChatEvent(...)`.
+3. `KiloCliDataParser.parsePermissionRequest(...)` creates `PermissionRequestDto`.
+4. `KiloSessionRpcApiImpl.events(...)` exposes filtered session events to frontend.
+5. `KiloSessionService.events(...)` collects those RPC events.
+6. `SessionController.handle(PermissionAsked)` calls `model.setState(SessionState.AwaitingPermission(toPermission(event.request)))`.
+7. `SessionMessageListPanel` shows `PermissionView` when state is `AwaitingPermission`.
+8. `PermissionView` currently shows minimal UI and sends `PermissionReplyDto("once")` or `PermissionReplyDto("reject")`.
+9. `SessionController.replyPermission(...)` calls RPC; backend posts to `/permission/{requestId}/reply?directory=...`.
+
+Current gaps:
+
+- `PermissionView` uses Kotlin UI DSL; new work should replace it with hand-built Swing.
+- Nested metadata (`filediff`, `files`, `rules`) is flattened or lost.
+- No command-specific card UI.
+- No diff preview.
+- No runtime auto-approve enabled toggle.
+- No responding/error state in the view.
+
+## VS Code Behavior To Match Now
+
+Match these behaviors:
+
+- Permission card appears above the prompt input, anchored at the end of the transcript.
+- `Run` approves the current request once by replying `"once"`.
+- `Deny` rejects the current request by replying `"reject"`.
+- Bash permission shows the full command.
+- Non-bash permission shows a readable tool/pattern summary.
+- Edit/patch/write permission shows diff preview when metadata includes diff information.
+- Prompt input is busy/blocked while permission is pending via existing `SessionState.isBusy()` behavior.
+- Runtime auto-approve toggle lives near prompt actions and, when enabled, automatically replies `"once"` to pending/future permissions.
+
+Do not match these VS Code behaviors yet:
+
+- Persistent rule controls inside `PermissionDock`.
+- Settings UI for permissions.
+- Saved rule preselection from `config.permission`.
+- Open diff in a new tab unless there is already an easy existing JetBrains utility.
+- Full subagent permission-family queuing unless it is trivial in existing JetBrains session model.
+
+## Important Concept Distinction
+
+There are two “auto approve” concepts:
+
+1. Persistent auto-approved rules:
+   - Stored in CLI config under `permission`.
+   - Can allow/ask/deny future matching requests.
+   - VS Code edits these from settings and optionally from the prompt.
+   - Out of scope.
+
+2. Runtime auto-approve enabled:
+   - Client-side toggle.
+   - Does not write CLI config.
+   - Replies `"once"` to each request while enabled.
+   - Should be implemented.
+
+Use copy that makes this distinction obvious. Do not describe runtime auto-approve as “always allow” or “save rule”.
+
+## Implementation Sequence
+
+Follow this order. It keeps each step buildable and testable.
+
+1. Add richer permission DTO fields in `shared`.
+2. Parse richer permission metadata in backend parser.
+3. Map DTO fields into frontend permission model.
+4. Rewrite `PermissionView` as Swing with command/pattern/diff display and allow/deny only.
+5. Wire responding/error state only if it is small and testable.
+6. Add runtime auto-approve service/state.
+7. Add prompt shield toggle.
+8. Integrate auto-approve into live permission and recovery flows.
+9. Add/adjust tests.
+10. Run targeted verification.
+
+## Step 1 — Extend Shared Permission DTOs
+
+File: `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt`
+
+Add a serializable DTO near existing permission DTOs:
+
+```kotlin
+@Serializable
+data class PermissionFileDiffDto(
+    val file: String,
+    val patch: String? = null,
+    val before: String? = null,
+    val after: String? = null,
+    val additions: Int = 0,
+    val deletions: Int = 0,
+)
+```
+
+Extend `PermissionRequestDto` with defaulted fields so old JSON remains compatible:
+
+```kotlin
+@Serializable
+data class PermissionRequestDto(
+    val id: String,
+    val sessionID: String,
+    val permission: String,
+    val patterns: List,
+    val metadata: Map = emptyMap(),
+    val always: List = emptyList(),
+    val tool: ToolRefDto? = null,
+    val message: String? = null,
+    val command: String? = null,
+    val rules: List = emptyList(),
+    val filePath: String? = null,
+    val fileDiffs: List = emptyList(),
+)
+```
+
+Notes:
+
+- Keep old constructor call sites compiling by adding new fields at the end with defaults.
+- `rules` is display-only in this pass. Do not build toggles from it.
+- `always` remains present but the UI should not use it for rule management in this pass.
+
+## Step 2 — Parse Rich Permission Metadata
+
+File: `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt`
+
+Update imports with `PermissionFileDiffDto`.
+
+Replace current `parsePermissionRequest(...)` logic with a version that:
+
+- Reads `id`, `sessionID`, `permission`, `patterns`, `always`, `tool` as today.
+- Keeps scalar metadata in `metadata: Map`.
+- Extracts optional `message`, `command`, `rules`, `filePath`, and `fileDiffs`.
+- Never throws for malformed optional metadata.
+
+Suggested helper functions to add near JSON helpers:
+
+```kotlin
+private fun JsonElement?.obj(): JsonObject? = runCatching { this?.jsonObject }.getOrNull()
+private fun JsonElement?.arr(): JsonArray? = runCatching { this?.jsonArray }.getOrNull()
+private fun JsonElement?.text(): String? = this?.scalar()
+```
+
+Suggested extraction behavior:
+
+- `message`: `obj.str("message") ?: meta.str("message")`
+- `command`: `meta.str("command") ?: obj.str("command")`
+- `filePath`: `meta.str("filepath") ?: meta.str("filePath") ?: meta.str("file") ?: meta.str("path")`
+- `rules`: support all of these:
+  - metadata `rules` as JSON array
+  - metadata `rules` as a single string
+  - metadata `rules` as JSON-encoded string array if encountered
+- `fileDiffs`: support VS Code order:
+  1. `metadata.filediff` object
+  2. `metadata.files` array
+  3. `metadata.diff` + `metadata.filepath`
+
+Important current parser behavior:
+
+- Existing `JsonElement.scalar()` already turns arrays/objects into `toString()` if not primitive.
+- For rich parsing, use the raw `JsonObject` before flattening to `Map`.
+
+Suggested `parsePermissionRequest(...)` outline:
+
+```kotlin
+internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? {
+    val id = obj.str("id") ?: return null
+    val sid = obj.str("sessionID") ?: return null
+    val permission = obj.str("permission") ?: return null
+    val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+    val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+    val metaObj = obj["metadata"].obj()
+    val meta = metaObj?.entries?.mapNotNull { (key, value) ->
+        val text = value.scalar() ?: return@mapNotNull null
+        key to text
+    }?.toMap() ?: emptyMap()
+    val path = metaObj.path()
+    val diffs = metaObj.permissionDiffs(path)
+    return PermissionRequestDto(
+        id = id,
+        sessionID = sid,
+        permission = permission,
+        patterns = patterns,
+        metadata = meta,
+        always = always,
+        tool = toolRef(obj),
+        message = obj.str("message") ?: metaObj?.str("message"),
+        command = metaObj?.str("command") ?: obj.str("command"),
+        rules = metaObj.rules(),
+        filePath = path,
+        fileDiffs = diffs,
+    )
+}
+```
+
+Helper details:
+
+- `path()` returns first nonblank value from `filepath`, `filePath`, `file`, `path`.
+- `rules()` returns `emptyList()` when absent; if the value is an array, map string items; if a primitive string starts with `[`, attempt JSON array parse with the existing `json` instance; otherwise return a one-item list.
+- `permissionDiffs(path)`:
+  - If `filediff` is an object, parse via `diffObj(...)` and return one item.
+  - If `files` is an array, parse each item with `relativePath ?: filePath ?: file` and `patch/additions/deletions`.
+  - If `diff` is a string, return `PermissionFileDiffDto(file = path ?: "patch", patch = diff)`.
+  - Else return `emptyList()`.
+
+Do not over-engineer counts:
+
+- If `additions` or `deletions` is missing/non-numeric, use `0`.
+- If both `before` and `after` exist but no patch exists, keep them for possible future display, but UI can ignore them for now.
+
+## Step 3 — Extend Frontend Permission Model
+
+File: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt`
+
+Update `PermissionMeta`:
+
+```kotlin
+data class PermissionMeta(
+    val command: String? = null,
+    val rules: List = emptyList(),
+    val diff: String? = null,
+    val filePath: String? = null,
+    val fileDiff: PermissionFileDiff? = null,
+    val fileDiffs: List = emptyList(),
+    val raw: Map = emptyMap(),
+)
+```
+
+Compatibility note:
+
+- Keep existing `diff`, `fileDiff`, and `raw` fields if tests or callers rely on them.
+- Add `fileDiffs`; optionally set `fileDiff = fileDiffs.firstOrNull()` in mapping for compatibility.
+
+File: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt`
+
+Update `toPermission(dto)`:
+
+- `file = dto.filePath ?: dto.metadata["filepath"] ?: dto.metadata["filePath"] ?: dto.metadata["file"] ?: dto.metadata["path"]`
+- `diffs = dto.fileDiffs.map { PermissionFileDiff(...) }`
+- `diff = dto.metadata["diff"]`
+- `command = dto.command ?: dto.metadata["command"]`
+- `rules = dto.rules.ifEmpty { parse legacy metadata rules if desired }`
+- `message = dto.message ?: dto.metadata["message"]`
+
+Suggested mapping:
+
+```kotlin
+val diffs = dto.fileDiffs.map {
+    PermissionFileDiff(
+        file = it.file,
+        patch = it.patch,
+        before = it.before,
+        after = it.after,
+        additions = it.additions,
+        deletions = it.deletions,
+    )
+}
+val file = dto.filePath ?: dto.metadata["filepath"] ?: dto.metadata["filePath"] ?: dto.metadata["file"] ?: dto.metadata["path"]
+return Permission(
+    id = dto.id,
+    sessionId = dto.sessionID,
+    name = dto.permission,
+    patterns = dto.patterns,
+    always = dto.always,
+    meta = PermissionMeta(
+        command = dto.command ?: dto.metadata["command"],
+        rules = dto.rules,
+        diff = dto.metadata["diff"],
+        filePath = file,
+        fileDiff = diffs.firstOrNull(),
+        fileDiffs = diffs,
+        raw = dto.metadata,
+    ),
+    message = dto.message ?: dto.metadata["message"],
+    tool = ref,
+    state = state,
+)
+```
+
+## Step 4 — Add Permission UI Strings
+
+File: `packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties`
+
+Add/adjust English strings:
+
+```properties
+session.permission.title=Permission required
+session.permission.title.subagent=Permission required (subagent)
+session.permission.meta=Tool: {0}   •   Patterns: {1}
+session.permission.run=Run
+session.permission.allow=Allow
+session.permission.deny=Deny
+session.permission.command=Command
+session.permission.patterns={0}:
+session.permission.diff=Changes
+session.permission.diff.summary=+{0} -{1}
+session.permission.no.details={0} requires permission.
+session.permission.responding=Sending response...
+session.permission.error=Failed to send permission response
+session.permission.tool.read=Read
+session.permission.tool.edit=Edit
+session.permission.tool.write=Write
+session.permission.tool.patch=Patch
+session.permission.tool.multiedit=Edit
+session.permission.tool.glob=Glob Search
+session.permission.tool.grep=Grep Search
+session.permission.tool.list=List
+session.permission.tool.bash=Shell
+session.permission.tool.external_directory=External Directory
+session.permission.tool.webfetch=Web Fetch
+session.permission.tool.websearch=Web Search
+session.permission.tool.codesearch=Code Search
+session.permission.tool.todoread=Read Todo List
+session.permission.tool.todowrite=Update Todo List
+session.permission.tool.task=Task
+session.permission.tool.skill=Skill
+session.permission.tool.lsp=Language Server
+prompt.autoApprove.enable=Enable auto-approve
+prompt.autoApprove.disable=Disable auto-approve
+prompt.autoApprove.enabled=Auto-approve enabled. Permission requests will be approved once automatically.
+prompt.autoApprove.disabled=Auto-approve disabled. Click to auto-approve permission requests.
+```
+
+Localization decision:
+
+- If this repo expects all locale files to contain every key, add English fallback values to all `KiloBundle_*.properties` files.
+- If missing locale keys fall back to root bundle, updating only `KiloBundle.properties` is acceptable for this pass.
+
+## Step 5 — Rewrite `PermissionView` As Swing
+
+File: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt`
+
+Replace UI DSL imports and usage:
+
+Remove:
+
+- `com.intellij.ui.dsl.builder.RightGap`
+- `com.intellij.ui.dsl.builder.RowLayout`
+- `com.intellij.ui.dsl.builder.panel`
+
+Use platform/Swing components:
+
+- `JBLabel`
+- `JBTextArea`
+- `JBScrollPane`
+- `BorderLayoutPanel`
+- `JPanel`
+- `JButton`
+- `BoxLayout`
+- `FlowLayout`
+- `JBUI`
+- `UIUtil` or `JBUI.CurrentTheme` for colors.
+
+Constructor should support replying with optional state updates. Recommended signature:
+
+```kotlin
+class PermissionView(
+    private val reply: (String, PermissionReplyDto) -> Unit,
+) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView
+```
+
+If implementing responding state from the view, keep local buttons disabled after click. A more complete approach is in Step 6.
+
+Suggested class fields:
+
+```kotlin
+private var requestId: String? = null
+private var style = SessionEditorStyle.current()
+private val card = BorderLayoutPanel()
+private val header = JBLabel()
+private val details = JPanel()
+private val actions = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0))
+private val run = JButton(KiloBundle.message("session.permission.run"))
+private val deny = JButton(KiloBundle.message("session.permission.deny"))
+```
+
+Build a stable tree in `init`:
+
+- `card` center in this view.
+- Header row with warning icon and title.
+- `details` vertical panel for current permission content.
+- Actions row with Run and Deny.
+
+`show(permission)` should:
+
+- Set `requestId`.
+- Update header text.
+- Clear and rebuild only `details` if easiest; this is acceptable because permission changes are infrequent. Avoid Kotlin UI DSL.
+- Add command block if `permission.meta.command` is present or `permission.name == "bash"` and metadata command exists.
+- Else add pattern summary.
+- Add message if present.
+- Add diff section if `permission.meta.fileDiffs` is non-empty, or fallback from `permission.meta.diff`.
+- Enable buttons unless `permission.state == RESPONDING` or `RESOLVED`.
+- Set visible and refresh.
+
+`hideView()` should:
+
+- Clear `requestId`.
+- Clear details if desired.
+- Set invisible.
+- Refresh.
+
+Action behavior:
+
+```kotlin
+private fun decide(value: String) {
+    val id = requestId ?: return
+    setResponding(true)
+    reply(id, PermissionReplyDto(reply = value))
+}
+```
+
+Do not hide immediately if implementing responding state. Let `permission.replied` hide via model state transition. If not implementing responding state, current immediate hide is acceptable but less VS Code-like.
+
+Tool label helper:
+
+```kotlin
+private fun label(tool: String): String = when (tool) {
+    "read" -> KiloBundle.message("session.permission.tool.read")
+    "edit" -> KiloBundle.message("session.permission.tool.edit")
+    "write" -> KiloBundle.message("session.permission.tool.write")
+    "patch" -> KiloBundle.message("session.permission.tool.patch")
+    "multiedit" -> KiloBundle.message("session.permission.tool.multiedit")
+    "glob" -> KiloBundle.message("session.permission.tool.glob")
+    "grep" -> KiloBundle.message("session.permission.tool.grep")
+    "list" -> KiloBundle.message("session.permission.tool.list")
+    "bash" -> KiloBundle.message("session.permission.tool.bash")
+    "external_directory" -> KiloBundle.message("session.permission.tool.external_directory")
+    "webfetch" -> KiloBundle.message("session.permission.tool.webfetch")
+    "websearch" -> KiloBundle.message("session.permission.tool.websearch")
+    "codesearch" -> KiloBundle.message("session.permission.tool.codesearch")
+    "todoread" -> KiloBundle.message("session.permission.tool.todoread")
+    "todowrite" -> KiloBundle.message("session.permission.tool.todowrite")
+    "task" -> KiloBundle.message("session.permission.tool.task")
+    "skill" -> KiloBundle.message("session.permission.tool.skill")
+    "lsp" -> KiloBundle.message("session.permission.tool.lsp")
+    else -> tool
+}
+```
+
+Pattern display:
+
+- Filter `patterns` to exclude `"*"`.
+- If none, show `session.permission.no.details` with label.
+- If one, show `"