Merge pull request #10398 from Kilo-Org/scythe-dust

feat(jetbrains): polish session permission prompts
This commit is contained in:
Kirill Kalishev
2026-05-22 15:32:00 -04:00
committed by GitHub
63 changed files with 3947 additions and 957 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support typed custom responses to question prompts in the JetBrains plugin.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains permission prompts with compact action rows and diff badges.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Start expandable session sections collapsed by default.
@@ -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.
+33
View File
@@ -366,6 +366,39 @@ 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.layout.Align` — see section below |
### Align — Single-Component Alignment Wrapper
Use `Align` (`ai.kilocode.client.ui.layout.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.align(HAlign.CENTER, VAlign.CENTER)` 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.
**Factory extension** on `Component`:
```kotlin
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 `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.
### IntelliJ UI Surfaces
@@ -18,6 +18,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
@@ -511,11 +512,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? {
@@ -702,6 +719,11 @@ object KiloCliDataParser {
/**
* Build the JSON body for `POST /permission/{requestID}/reply`.
*/
internal fun parseRulesJson(text: String): List<String> {
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)}""")
@@ -770,6 +792,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<String> {
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<PermissionFileDiffDto> {
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
@@ -1371,6 +1371,148 @@ class KiloCliDataParserTest {
}
}
// ================================================================
// 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
// ================================================================
@@ -24,7 +24,7 @@ import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.log.ChatLogSummary
@@ -39,9 +39,11 @@ import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import java.util.function.Predicate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JComponent
@@ -58,7 +60,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,
@@ -174,6 +176,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) },
@@ -11,6 +11,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
@@ -21,6 +22,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
@@ -110,6 +112,8 @@ class SessionController(
private var partType: String? = null
private var tool: String? = null
private var eventJob: Job? = null
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
private var recentsState: RecentsState = RecentsState.Idle
private var viewState: SessionControllerEvent.ViewChanged? = null
@@ -315,6 +319,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)
@@ -322,10 +327,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}" }
@@ -459,6 +483,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
@@ -471,6 +496,7 @@ class SessionController(
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
for (child in discovered) trackChild(child)
showSession()
loaded(!model.isEmpty())
}
@@ -503,6 +529,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)
@@ -515,6 +542,7 @@ class SessionController(
recoverPending(session.id)
runEdt {
if (disposed) return@runEdt
for (child in discovered) trackChild(child)
subscribeEvents()
showSession()
loaded(!model.isEmpty())
@@ -552,6 +580,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 ->
@@ -568,6 +599,46 @@ 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}" }
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 {
@@ -640,6 +711,7 @@ class SessionController(
if (model.state is SessionState.Busy) {
model.setState(SessionState.Busy(status()))
}
childID(event.part)?.let { child -> trackChild(child) }
}
is ChatEventDto.PartDelta -> {
@@ -690,7 +762,8 @@ class SessionController(
}
is ChatEventDto.PermissionAsked -> {
model.setState(SessionState.AwaitingPermission(toPermission(event.request)))
val perm = toPermission(event.request)
model.setState(SessionState.AwaitingPermission(perm))
}
is ChatEventDto.PermissionReplied -> {
@@ -1194,6 +1267,9 @@ class SessionController(
disposed = true
connectionDelay.dispose()
eventJob?.cancel()
childJobs.values.forEach { it.cancel() }
childJobs.clear()
childIds.clear()
cs.cancel()
}
@@ -1248,6 +1324,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
@@ -1372,17 +1461,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,
)
@@ -17,10 +17,12 @@ data class Permission(
)
data class PermissionMeta(
val command: String? = null,
val rules: List<String> = emptyList(),
val diff: String? = null,
val filePath: String? = null,
val fileDiff: PermissionFileDiff? = null,
val fileDiffs: List<PermissionFileDiff> = emptyList(),
val raw: Map<String, String> = emptyMap(),
)
@@ -10,8 +10,11 @@ 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.UiStyle
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
@@ -44,7 +47,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 +55,7 @@ class EmptySessionPanel(
recents: List<SessionDto>,
private val history: () -> Unit = {},
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: CenterShrinkPanel = CenterShrinkPanel(this)
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
@@ -133,7 +136,7 @@ class EmptySessionPanel(
val header = BorderLayoutPanel(0, gap).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
add(CenterShrinkPanel(description), BorderLayout.CENTER)
add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
val recent = BorderLayoutPanel().apply {
@@ -303,8 +306,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()
}
@@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget {
}
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
label.font = style.regularFont
revalidate()
repaint()
}
@@ -62,7 +62,7 @@ class ProgressPanel(
}
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
label.font = style.regularFont
revalidate()
repaint()
}
@@ -9,7 +9,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
import com.intellij.openapi.Disposable
@@ -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) }
}
}
@@ -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
}
@@ -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()
@@ -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
@@ -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)
@@ -1,177 +0,0 @@
package ai.kilocode.client.session.ui.shared
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.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import javax.swing.BoxLayout
import javax.swing.JComponent
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.
*
* 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, [headerText],
* [descriptionText], optional body, optional footer. Call [setTopPanel],
* [setBody], or [setFooter] to replace those slots at any time.
*/
class BaseSessionQuestionPanel : RoundedContentPanel(
UiStyle.Gap.lg(),
UiStyle.Gap.pad(),
), SessionEditorStyleTarget {
private var style = SessionEditorStyle.current()
// All JBTextArea instances that need editor-font updates, paired with bold flag
private val tracked = mutableListOf<Pair<JBTextArea, Boolean>>()
// ---- header text ----
val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
// ---- description text ----
val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
// ---- slot fields ----
private var top: JComponent? = null
private var body: JComponent? = null
private var footer: JComponent? = null
// ---- inner layout ----
private val col = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
}
init {
addToCenter(col)
rebuildCol()
}
/**
* 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?) {
this.top = top
rebuildCol()
}
/**
* Replace the body slot that comes after the header/description.
* Pass `null` to remove the current body.
*/
@RequiresEdt
fun setBody(body: JComponent?) {
this.body = body
rebuildCol()
}
/**
* Replace the footer slot that comes after the body.
* Pass `null` to remove the current footer.
*/
@RequiresEdt
fun setFooter(footer: JComponent?) {
this.footer = footer
rebuildCol()
}
// ---- SessionEditorStyleTarget ----
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for ((area, bold) in tracked) applyFont(area, bold)
}
// ---- contentColor override ----
override fun contentColor(): Color = SessionUiStyle.View.surface()
override fun outlineColor(): Color = SessionUiStyle.View.line()
// ---- helpers ----
private fun rebuildCol() {
col.removeAll()
top?.let { col.add(it) }
col.add(headerText)
col.add(descriptionText)
body?.let { col.add(it) }
footer?.let { col.add(it) }
col.revalidate()
col.repaint()
}
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
val area = object : JBTextArea(value) {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
private fun withWidth(fallback: Int): Dimension {
val w = availableWidth()
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
val old = size
setSize(w, Int.MAX_VALUE)
val ps = super.getPreferredSize()
setSize(old)
return Dimension(w, ps.height)
}
private fun availableWidth(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
}.apply {
isEditable = false
isOpaque = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = color
border = JBUI.Borders.empty()
alignmentX = Component.LEFT_ALIGNMENT
}
tracked.add(area to bold)
applyFont(area, bold)
return area
}
private fun applyFont(area: JBTextArea, bold: Boolean) {
val font = if (bold) style.boldEditorFont else style.transcriptFont
if (area.font != font) area.font = font
}
}
@@ -1,41 +0,0 @@
package ai.kilocode.client.session.ui.shared
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() } }
@@ -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)
}
@@ -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
@@ -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
@@ -67,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()
}
@@ -2,18 +2,11 @@ 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.BaseQuestionView
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
import javax.swing.JPanel
/**
* Retained inline view shown at the bottom of the transcript when a session
@@ -30,27 +23,20 @@ class LoginRequiredView(
override val sessionViewKind = SessionView.Kind.Default
private val card = BaseSessionQuestionPanel()
val openProfileButton = applyButton(KiloBundle.message("session.login.required.button")) { openProfile() }
val dismissButton = dismissButton(KiloBundle.message("session.login.required.dismiss")) { dismiss() }
private val card = BaseQuestionView()
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
border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
alignmentX = Component.LEFT_ALIGNMENT
add(dismissButton, BorderLayout.WEST)
add(openProfileButton, 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)
}
@@ -58,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()
}
@@ -76,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()
@@ -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
@@ -1,100 +0,0 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Permission
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.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.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* 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.
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
init {
isOpaque = false
isVisible = false
}
/** Populate the view for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" }
removeAll()
val card = BorderLayoutPanel()
card.isOpaque = true
card.background = SessionUiStyle.View.surface()
card.border = SessionUiStyle.View.card()
card.add(panel {
row {
icon(AllIcons.General.Warning).gap(RightGap.SMALL)
label(KiloBundle.message("session.permission.title")).bold()
}
row {
label(KiloBundle.message("session.permission.meta", permission.name, patterns))
}
val msg = permission.message
if (!msg.isNullOrBlank()) {
row {
comment(msg)
}
}
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)
isVisible = true
refresh()
}
/** Hide this view and clear the active request id. */
fun hideView() {
requestId = null
removeAll()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
}
private fun decide(value: String) {
val id = requestId ?: return
reply(id, PermissionReplyDto(reply = value))
hideView()
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -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
@@ -109,7 +110,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 +122,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
md.set(source)
changed = true
}
changed = syncBody() || changed
changed = sync() || changed
if (changed) refresh()
}
@@ -131,8 +130,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 +207,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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,330 @@
package ai.kilocode.client.session.views.base
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.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
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.JButton
import javax.swing.JComponent
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 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 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()
private val tracked = mutableListOf<Pair<JBTextArea, Boolean>>()
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
}
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 content: JComponent? = null
// action buttons keyed by id for enabled-state updates
private val actionButtons = mutableMapOf<String, JButton>()
private var actionFooter: JComponent? = null
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.
*/
@RequiresEdt
fun setTopPanel(top: JComponent?) {
this.top = top
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 view-specific content slot that comes after the header/description.
* Pass `null` to remove the current content.
*/
@RequiresEdt
fun setContent(content: JComponent?) {
this.content = content
rebuildCol()
}
/**
* 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 setActions(actions: List<Action>) {
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
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for ((area, bold) in tracked) applyFont(area, bold)
}
// ---- contentColor override ----
override fun contentColor(): Color = SessionUiStyle.View.surface()
override fun outlineColor(): Color = SessionUiStyle.View.line()
// ---- 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<String, JButton> = actionButtons.toMap()
// ---- private helpers ----
private fun rebuildCol() {
col.removeAll()
top?.let { col.add(it) }
col.add(header)
col.add(descriptionText)
content?.let {
col.add(gap())
col.add(it)
}
actionFooter?.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)
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
private fun withWidth(fallback: Int): Dimension {
val w = availableWidth()
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
val old = size
setSize(w, Int.MAX_VALUE)
val ps = super.getPreferredSize()
setSize(old)
return Dimension(w, ps.height)
}
private fun availableWidth(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
}.apply {
isEditable = false
isOpaque = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = color
border = JBUI.Borders.empty()
alignmentX = Component.LEFT_ALIGNMENT
}
tracked.add(area to bold)
applyFont(area, bold)
return area
}
private fun applyFont(area: JBTextArea, bold: Boolean) {
val font = if (bold) style.headerFont else style.hintFont
if (area.font != font) area.font = font
}
private fun makeButton(text: String, primary: Boolean): JButton {
val btn = object : 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()
}
}
return btn
}
}
@@ -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
@@ -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()
}
@@ -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) {}
@@ -0,0 +1,47 @@
package ai.kilocode.client.session.views.permission
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
}
@@ -0,0 +1,257 @@
package ai.kilocode.client.session.views.permission
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.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
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.FlowLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
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 compact row with action label and target as an inline code fragment, plus diff badges.
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
private val card = BaseQuestionView()
private val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
alignmentX = Component.LEFT_ALIGNMENT
}
// Track target panes for style updates
private val panes = mutableListOf<JBHtmlPane>()
private val diffViews = mutableListOf<PermissionDiffView>()
private val ID_DENY = "deny"
private val ID_RUN = "run"
init {
isOpaque = false
isVisible = false
card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
card.setContent(body)
card.setActions(listOf(
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") },
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") },
))
addToCenter(card)
}
/** Populate the view for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
card.setHeader(KiloBundle.message("session.permission.title"))
body.removeAll()
panes.clear()
diffViews.clear()
val tool = permission.name
val cmd = permission.meta.command
val action = toolLabel(tool)
val target = cmd ?: resolveTarget(permission)
addDetailRow(action, target, permission.meta.fileDiffs)
addStateMessage(permission)
val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
card.setActionEnabled(ID_RUN, !responding)
card.setActionEnabled(ID_DENY, !responding)
isVisible = true
refresh()
}
/** Hide this view and clear the active request id. */
fun hideView() {
requestId = null
body.removeAll()
panes.clear()
diffViews.clear()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
card.applyStyle(style)
for (pane in panes) {
applyTargetPane(pane)
}
for (dv in diffViews) {
dv.applyStyle(style)
}
}
/** Adds a three-column permission detail row: tool, target, and changes. */
private fun addDetailRow(action: String, target: String?, diffs: List<PermissionFileDiff>) {
val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply {
isOpaque = false
alignmentX = Component.LEFT_ALIGNMENT
}
val actionLbl = JBLabel(action).apply {
font = UiStyle.Fonts.bold()
}
row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST)
if (!target.isNullOrBlank()) {
val pane = targetPane(target)
panes.add(pane)
row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
}
if (diffs.isNotEmpty()) {
val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
}
for (diff in diffs) {
val dv = PermissionDiffView(diff)
diffViews.add(dv)
changes.add(dv)
}
row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
}
body.add(row)
}
private fun targetPane(text: String) = JBHtmlPane(
JBHtmlPaneStyleConfiguration {},
JBHtmlPaneConfiguration {
customStyleSheetProvider { targetSheet() }
},
).apply {
isEditable = false
isOpaque = true
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
applyTargetPane(this)
}
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; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }")
sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }")
return sheet
}
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
}
}
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) {
"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
card.setActionEnabled(ID_RUN, false)
card.setActionEnabled(ID_DENY, false)
reply(id, PermissionReplyDto(reply = value))
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
// Test helpers
internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!!
internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!!
internal fun codeLabelsForTest() = panes.toList()
internal fun diffViewsForTest() = diffViews.toList()
internal fun headerFontForTest() = card.headerFont()
}
@@ -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
@@ -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.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
refresh()
@@ -137,6 +139,9 @@ class QuestionResultView(tool: Tool) : PartView() {
fun bodyFonts(): List<Font> = 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.boldFont else style.regularFont
if (area.font == font) return false
area.font = font
return true
@@ -5,16 +5,15 @@ 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.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
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
@@ -22,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
@@ -33,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 = {},
@@ -46,10 +52,17 @@ class QuestionView(
private var question: Question? = null
private var idx = 0
private var selections = emptyList<MutableSet<String>>()
// Per-question custom text state — survives navigation.
private var customTexts = emptyList<String>()
// Per-question: whether the custom row is currently selected/open.
private var customOpen = emptyList<Boolean>()
private var style = SessionEditorStyle.current()
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
// 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 = BaseSessionQuestionPanel()
private val card = BaseQuestionView()
private val summary = JBLabel()
private val nav = JPanel().apply {
@@ -80,16 +93,11 @@ class QuestionView(
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = Component.LEFT_ALIGNMENT
}
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() }
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
@@ -99,12 +107,9 @@ 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)
card.setBody(body)
card.setFooter(footer)
card.setContent(body)
add(card, BorderLayout.CENTER)
}
@@ -117,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()
}
@@ -126,9 +133,13 @@ class QuestionView(
question = null
idx = 0
selections = emptyList()
customTexts = emptyList()
customOpen = emptyList()
customEditor = null
customFocus = null
texts.clear()
body.removeAll()
right.removeAll()
card.setActions(emptyList())
isVisible = false
refresh()
}
@@ -136,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()
@@ -144,21 +160,18 @@ class QuestionView(
private fun syncPage() {
val q = question ?: return
texts.clear()
customEditor = null
customFocus = null
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.headerText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
card.descriptionText.text = KiloBundle.message(
val hint = 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.isVisible = true
card.setHeader(item.question, hint)
addContent(item, selections[idx])
}
syncHeader(q)
@@ -176,42 +189,69 @@ class QuestionView(
}
private fun syncFooter(q: Question) {
right.removeAll()
if (review(q)) {
val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() }
val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() }
right.add(back)
right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
right.add(submit)
return
}
val actions = mutableListOf<BaseQuestionView.Action>()
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(button)
card.setActions(actions)
}
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
for (node in right.components) {
if (node is SessionQuestionButton && node.text != KiloBundle.message("session.question.back")) {
node.isEnabled = review(q) || 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<String> {
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()
}
}
@@ -227,6 +267,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 {
@@ -235,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(),
@@ -257,13 +300,245 @@ 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))
}
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()
}
val group = ButtonGroup()
for (opt in item.options) panel.add(radioRow(opt, set, group))
return panel
}
private fun customRow(item: QuestionItem, set: MutableSet<String>): 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<String>) {
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<String>, group: ButtonGroup): JPanel {
val radio = JBRadioButton().apply {
actionCommand = opt.label
@@ -274,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)
}
@@ -304,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)
@@ -396,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()
@@ -410,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()
@@ -425,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()
}
@@ -437,7 +720,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.boldFont else style.regularFont
if (area.font == font) return false
area.font = font
return true
@@ -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)
}
}
@@ -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)),
)
@@ -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. */
@@ -0,0 +1,135 @@
package ai.kilocode.client.ui.layout
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 factory extension for concise call sites:
* ```
* label.align(HAlign.CENTER, VAlign.CENTER)
* button.align(HAlign.RIGHT, VAlign.CENTER)
* panel.align(HAlign.LEFT, VAlign.TOP)
* scrollable.align(HAlign.TRACK, 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<Int, Int> {
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))
// ---------------------------------------------------------------------------
// Factory extension
// ---------------------------------------------------------------------------
fun Component.align(h: HAlign, v: VAlign) = Align(this, h, v)
@@ -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)} } ") }
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill="#AFB1B3" d="M8 1L2 3.5v4C2 11.1 4.7 14.1 8 15c3.3-.9 6-3.9 6-7.5v-4L8 1zm0 6.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 3c-1.7 0-2-.75-2-.75V10c0-.55 1-1 2-1s2 .45 2 1v-.25S9.7 10.5 8 10.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -15,10 +15,37 @@ 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
session.question.dismiss=Dismiss
session.question.submit=Submit
session.question.next=Next
@@ -31,6 +58,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…
@@ -22,7 +22,7 @@ import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ProfileDto
import com.intellij.util.ui.JBUI
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.rpc.dto.MessageWithPartsDto
import com.intellij.ui.components.JBScrollPane
@@ -1,8 +1,12 @@
package ai.kilocode.client.session.controller
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
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,150 @@ 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)
}
// ------ 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: <none>
name: edit
patterns: *.kt
always: <none>
file: <none>
state: PENDING
metadata: <none>
[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<ai.kilocode.client.session.model.SessionModelEvent.StateChanged>()
assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty())
}
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",
@@ -1,6 +1,8 @@
package ai.kilocode.client.session.controller
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
@@ -233,6 +235,107 @@ class SessionRecoveryTest : SessionControllerTestBase() {
)
}
// ------ 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 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(
@@ -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,6 +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)
}
// --- UI fonts come from UiStyle.Fonts, NOT from the editor ---
fun `test headerFont equals UiStyle Fonts header`() {
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
assertEquals(UiStyle.Fonts.header(), style.headerFont)
}
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)
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")
}
}
@@ -10,7 +10,7 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TextView
@@ -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 = { _ -> },
)
@@ -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 ------
@@ -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()
@@ -1,256 +0,0 @@
package ai.kilocode.client.session.ui.shared
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.JComponent
import javax.swing.JLabel
import javax.swing.JPanel
@Suppress("UnstableApiUsage")
class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
// ------ initial state ------
fun `test headerText and descriptionText are in the component tree by default`() {
edt {
val panel = BaseSessionQuestionPanel()
assertNotNull("headerText should be present", find(panel, panel.headerText))
assertNotNull("descriptionText should be present", find(panel, panel.descriptionText))
}
}
fun `test header and description have correct initial text`() {
edt {
val panel = BaseSessionQuestionPanel()
assertEquals("", panel.headerText.text)
assertEquals("", panel.descriptionText.text)
}
}
// ------ setTopPanel ------
fun `test setTopPanel adds component before header`() {
edt {
val panel = BaseSessionQuestionPanel()
val top = JLabel("top")
panel.setTopPanel(top)
val col = findCol(panel)!!
val comps = col.components.toList()
val topIdx = comps.indexOf(top)
val headerIdx = comps.indexOf(panel.headerText)
assertTrue("top should appear before headerText", topIdx < headerIdx)
}
}
fun `test setTopPanel null removes top component`() {
edt {
val panel = BaseSessionQuestionPanel()
val top = JLabel("top")
panel.setTopPanel(top)
panel.setTopPanel(null)
assertNull("top should be removed after setTopPanel(null)", find(panel, top))
assertNotNull("headerText should still be present", find(panel, panel.headerText))
}
}
fun `test setTopPanel replaces previous top without duplicates`() {
edt {
val panel = BaseSessionQuestionPanel()
val first = JLabel("first")
val second = JLabel("second")
panel.setTopPanel(first)
panel.setTopPanel(second)
assertNull("first top should be gone after replacement", find(panel, first))
assertNotNull("second top should be present", find(panel, second))
}
}
// ------ setBody ------
fun `test setBody adds component after descriptionText`() {
edt {
val panel = BaseSessionQuestionPanel()
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)
}
}
fun `test setBody null removes body`() {
edt {
val panel = BaseSessionQuestionPanel()
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))
}
}
fun `test setBody replaces previous body without duplicates`() {
edt {
val panel = BaseSessionQuestionPanel()
val first = JLabel("first body")
val second = JLabel("second body")
panel.setBody(first)
panel.setBody(second)
assertNull("first body should be gone", find(panel, first))
assertNotNull("second body should be present", find(panel, second))
}
}
// ------ setFooter ------
fun `test setFooter adds component after body`() {
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 bodyIdx = comps.indexOf(body)
val footerIdx = comps.indexOf(footer)
assertTrue("footer should appear after body", bodyIdx < footerIdx)
}
}
fun `test setFooter null removes footer`() {
edt {
val panel = BaseSessionQuestionPanel()
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 = BaseSessionQuestionPanel()
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 = BaseSessionQuestionPanel()
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)
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 = BaseSessionQuestionPanel()
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 = BaseSessionQuestionPanel()
val col = findCol(panel)!!
assertEquals("headerText + descriptionText only", 2, col.componentCount)
}
}
fun `test col child count grows by one for each optional slot added`() {
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)
}
}
fun `test col shrinks back after removing optional slots`() {
edt {
val panel = BaseSessionQuestionPanel()
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)
}
}
// ------ helpers ------
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
private fun findCol(panel: BaseSessionQuestionPanel): JPanel? {
for (child in panel.components) {
if (child is JPanel) return child
}
return null
}
private fun find(root: Container, target: JComponent): JComponent? {
if (root === target) return target
for (child in root.components) {
if (child === target) return target
if (child is Container) {
val found = find(child, target)
if (found != null) return found
}
}
return null
}
}
@@ -1,13 +1,12 @@
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
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() {
@@ -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,32 @@ 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)
val btn = view.dismissButton()
val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
assertTrue("Dismiss should not be primary", key == null || key == false)
}
}
fun `test dismiss button is not primary`() {
fun `test login action buttons share right-aligned footer group`() {
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 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))
}
}
@@ -112,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)
}
@@ -122,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)
}
@@ -161,6 +146,42 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
}
}
// ------ fonts: standard UI family, not editor ------
fun `test header uses headerFont 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<JBTextArea>(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 should equal headerFont", style.headerFont, title.font)
}
}
fun `test description uses hintFont 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<JBTextArea>(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 should equal hintFont", style.hintFont, desc.font)
}
}
// ------ helpers ------
private fun <T> edt(block: () -> T): T {
@@ -1,74 +0,0 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
private lateinit var view: PermissionView
override fun setUp() {
super.setUp()
view = PermissionView(
reply = { id, dto -> replies.add(id to dto) },
)
}
fun `test allow button uses bundle text and replies once`() {
view.show(permission())
buttons(view).first { it.text == "Allow" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("once", replies.single().second.reply)
}
fun `test deny button uses bundle text and rejects`() {
view.show(permission())
buttons(view).first { it.text == "Deny" }.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`() {
view.show(
Permission(
id = "perm2",
sessionId = "ses",
name = "edit",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(),
)
)
assertTrue(view.isVisible)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
}
@@ -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.regularFont))
assertTrue(view.bodyFonts().contains(style.boldFont))
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 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")
}
// ------ update ------
@@ -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.ui.shared.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
@@ -11,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() {
@@ -30,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++ },
@@ -87,6 +91,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<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(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 +188,64 @@ 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 boldFont", style.boldFont, label.font)
assertEquals("description should use regularFont", style.regularFont, desc.font)
}
fun `test question title and hint use editor fonts`() {
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<JBRadioButton>(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<JBRadioButton>(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"))
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("title should use headerFont", style.headerFont, title.font)
assertEquals("hint should use hintFont", style.hintFont, hint.font)
}
// ------ multi-question navigation ------
@@ -366,22 +440,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`() {
@@ -394,7 +465,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<JBRadioButton>(view, "Minimal").doClick()
@@ -405,10 +476,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`() {
@@ -416,8 +486,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`() {
@@ -427,8 +497,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`() {
@@ -495,6 +564,300 @@ 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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
// Find the editor that appeared and type text
val ed = findAll<EditorTextField>(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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
layout(view, 240)
val ed = findAll<EditorTextField>(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<JBRadioButton>(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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
val ed = findAll<EditorTextField>(view).first()
ed.text = "stale custom"
// Now select a normal option
option<JBRadioButton>(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<JBRadioButton>(view).first { it.actionCommand == "" }
radio.doClick()
val ed = findAll<EditorTextField>(view).first()
ed.text = "stale custom"
option<JBRadioButton>(view, "Minimal").doClick()
val custom = findAll<JBRadioButton>(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<JBRadioButton>(view, "Minimal").isSelected)
assertTrue("Custom editor should stay visible for non-empty text", findAll<EditorTextField>(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<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
assertNotNull(findAll<EditorTextField>(view).firstOrNull { it.parent != null })
option<JBRadioButton>(view, "Minimal").doClick()
assertNull("Empty custom editor should be removed after selecting a normal option", findAll<EditorTextField>(view).firstOrNull { it.parent != null })
}
fun `test focusing retained custom editor reselects custom response`() {
view.show(customSingleQuestion("q_custom_focus"))
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
findAll<EditorTextField>(view).first().text = "stale custom"
option<JBRadioButton>(view, "Minimal").doClick()
view.testFocusCustomEditor()
assertTrue("Custom radio should be selected when its editor takes focus", findAll<JBRadioButton>(view).first { it.actionCommand == "" }.isSelected)
assertFalse("Normal option should be cleared when custom editor takes focus", option<JBRadioButton>(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(customMultiQuestion("q_multi_custom"))
option<JBCheckBox>(view, "A").doClick()
val customBox = findAll<JBCheckBox>(view).first { it.actionCommand == "" }
customBox.doClick()
val ed = findAll<EditorTextField>(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 input is trimmed before submit`() {
view.show(customSingleQuestion("q_custom_trim"))
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
findAll<EditorTextField>(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<JBCheckBox>(view, "A").doClick()
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
findAll<EditorTextField>(view).first().text = "extra"
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
assertFalse(
"Custom checkbox should be unchecked",
findAll<JBCheckBox>(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<JBCheckBox>(view, "A").doClick()
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
findAll<EditorTextField>(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(
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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
val ed = findAll<EditorTextField>(view).first()
ed.text = "typed answer"
button(view, "Next").doClick()
option<JBRadioButton>(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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
val ed = findAll<EditorTextField>(view).first()
ed.text = "preserved text"
// Navigate forward
button(view, "Next").doClick()
option<JBRadioButton>(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<EditorTextField>(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<JBRadioButton>(view).first { it.actionCommand == "" }
customRadio.doClick()
val ed = findAll<EditorTextField>(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 ------
/**
@@ -514,6 +877,21 @@ class QuestionViewTest : BasePlatformTestCase() {
private fun text(root: Container, value: String): JBTextArea =
findAll<JBTextArea>(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(
@@ -556,6 +934,35 @@ 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 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<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
assertTrue("Expected label '$text' to be present", found)
@@ -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)
@@ -0,0 +1,381 @@
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
@Suppress("UnstableApiUsage")
class BaseQuestionViewTest : BasePlatformTestCase() {
// ------ initial state ------
fun `test header and description text areas are in the component tree by default`() {
edt {
val panel = BaseQuestionView()
val areas = findAll<JBTextArea>(panel)
assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2)
}
}
fun `test setHeader sets the header text`() {
edt {
val panel = BaseQuestionView()
panel.setHeader("My Title")
val bold = findAll<JBTextArea>(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<JBTextArea>(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<JBTextArea>(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<JBTextArea>(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<JBTextArea>(panel)
val desc = areas.firstOrNull { !it.font.isBold }
assertTrue("Description should be hidden when null", desc == null || !desc.isVisible)
}
}
// ------ setTopPanel ------
fun `test setTopPanel adds component before header`() {
edt {
val panel = BaseQuestionView()
val top = JLabel("top")
panel.setTopPanel(top)
val col = findCol(panel)!!
val comps = col.components.toList()
val topIdx = comps.indexOf(top)
// header row is the JPanel containing the header text area
val headerRow = findAll<JBTextArea>(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)
}
}
fun `test setTopPanel null removes top component`() {
edt {
val panel = BaseQuestionView()
val top = JLabel("top")
panel.setTopPanel(top)
panel.setTopPanel(null)
assertNull("top should be removed after setTopPanel(null)", find(panel, top))
}
}
fun `test setTopPanel replaces previous top without duplicates`() {
edt {
val panel = BaseQuestionView()
val first = JLabel("first")
val second = JLabel("second")
panel.setTopPanel(first)
panel.setTopPanel(second)
assertNull("first top should be gone after replacement", find(panel, first))
assertNotNull("second top should be present", find(panel, second))
}
}
// ------ setContent ------
fun `test setContent adds component after description`() {
edt {
val panel = BaseQuestionView()
val body = JLabel("body")
panel.setContent(body)
assertNotNull("body should be in the tree", find(panel, body))
}
}
fun `test setContent null removes content`() {
edt {
val panel = BaseQuestionView()
val body = JLabel("body")
panel.setContent(body)
panel.setContent(null)
assertNull("body should be removed after setContent(null)", find(panel, body))
}
}
fun `test setContent replaces previous content without duplicates`() {
edt {
val panel = BaseQuestionView()
val first = JLabel("first body")
val second = JLabel("second body")
panel.setContent(first)
panel.setContent(second)
assertNull("first body should be gone", find(panel, first))
assertNotNull("second body should be present", find(panel, second))
}
}
// ------ setActions ------
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")
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 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)
}
}
// ------ 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 labels = findAll<JBLabel>(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)
}
}
fun `test setHeaderIcon null hides header icon`() {
edt {
val panel = BaseQuestionView()
panel.setHeaderIcon(AllIcons.General.Warning)
panel.setHeaderIcon(null)
val labels = findAll<JBLabel>(panel).filter { it.icon != null && it.isVisible }
assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty())
}
}
// ------ applyStyle: UI fonts ----
fun `test applyStyle applies headerFont to header and hintFont to description`() {
edt {
val panel = BaseQuestionView()
panel.setHeader("Title", "Hint")
val style = SessionEditorStyle.current()
panel.applyStyle(style)
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.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")
val desc = findAll<JBTextArea>(panel).firstOrNull { it.text == "Hint" }
assertNotNull(desc)
val ins = desc!!.border.getBorderInsets(desc)
assertEquals("description should not add extra top padding", 0, ins.top)
}
}
// ------ helpers ------
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
private fun findCol(panel: BaseQuestionView): JPanel? {
for (child in panel.components) {
if (child is JPanel) return child
}
return null
}
private fun find(root: Container, target: JComponent): JComponent? {
if (root === target) return target
for (child in root.components) {
if (child === target) return target
if (child is Container) {
val found = find(child, target)
if (found != null) return found
}
}
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 <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
val result = mutableListOf<T>()
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
}
}
@@ -0,0 +1,547 @@
package ai.kilocode.client.session.views.permission
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.BaseQuestionView
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.JBLabel
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
private lateinit var view: PermissionView
override fun setUp() {
super.setUp()
view = PermissionView(
reply = { id, dto -> replies.add(id to dto) },
)
}
fun `test run button replies once`() {
view.show(permission())
view.runButtonForTest().doClick()
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 rejects`() {
view.show(permission())
view.denyButtonForTest().doClick()
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("reject", replies.single().second.reply)
}
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 show only action label with no code fragment`() {
view.show(
Permission(
id = "perm2",
sessionId = "ses",
name = "edit",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(),
)
)
assertTrue(view.isVisible)
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 show action label with no code fragment`() {
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"))
assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty())
}
fun `test bash permission shows action and command on same row`() {
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 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 compact detail`() {
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"))
// State message should not appear for PENDING state
assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?"))
}
fun `test non-bash patterns show action and path as separate labels`() {
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.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 multiple patterns joined in code label`() {
view.show(
Permission(
id = "perm_multi",
sessionId = "ses",
name = "glob",
patterns = listOf("src/*.kt", "test/*.kt"),
always = emptyList(),
meta = PermissionMeta(),
)
)
val labels = view.codeLabelsForTest()
assertEquals("Expected one combined code label for multiple patterns", 1, labels.size)
assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("src/*.kt"))
assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("test/*.kt"))
}
fun `test diff preview renders only stat badge without duplicate file path`() {
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 @@\n-old\n+new",
additions = 1,
deletions = 2,
)
),
),
)
)
val text = allText(view)
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
// Patch markers should NOT appear — no diff content is shown
assertFalse("Should not render patch content, got: $text", text.contains("@@"))
assertFalse("Should not render old line, got: $text", text.contains("-old"))
assertFalse("Should not render new line, got: $text", text.contains("+new"))
val diffs = view.diffViewsForTest()
assertEquals("Expected one diff view", 1, diffs.size)
val badge = diffs[0].badgeForTest()
assertEquals("-2", badge.removedLabelForTest().text)
assertEquals("+1", badge.addedLabelForTest().text)
assertNotSame("Removed and added labels should use different colors", badge.removedLabelForTest().foreground, badge.addedLabelForTest().foreground)
}
fun `test diff preview shows no unavailable fallback text`() {
view.show(
Permission(
id = "perm_no_patch",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = null,
additions = 3,
deletions = 1,
)
),
),
)
)
val text = allText(view)
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
// No "unavailable" fallback text expected in new design
assertFalse("Should not render unavailable fallback, got: $text", text.contains("unavailable"))
val badge = view.diffViewsForTest().single().badgeForTest()
assertEquals("-1", badge.removedLabelForTest().text)
assertEquals("+3", badge.addedLabelForTest().text)
}
fun `test multiple diffs render each file separately`() {
view.show(
Permission(
id = "perm_multi_diff",
sessionId = "ses",
name = "edit",
patterns = listOf("src/A.kt", "src/B.kt"),
always = emptyList(),
meta = PermissionMeta(
fileDiffs = listOf(
PermissionFileDiff(
file = "src/A.kt",
patch = "@@ -1 +1 @@\n-a\n+b",
additions = 1,
deletions = 1,
),
PermissionFileDiff(
file = "src/B.kt",
patch = "@@ -2 +2 @@\n-c\n+d",
additions = 2,
deletions = 3,
),
),
),
)
)
val diffs = view.diffViewsForTest()
assertEquals("Expected two diff views", 2, diffs.size)
assertEquals("-1", diffs[0].badgeForTest().removedLabelForTest().text)
assertEquals("+1", diffs[0].badgeForTest().addedLabelForTest().text)
assertEquals("-3", diffs[1].badgeForTest().removedLabelForTest().text)
assertEquals("+2", diffs[1].badgeForTest().addedLabelForTest().text)
// Patch content should not be in text
val text = allText(view)
assertFalse("Should not render patch markers, got: $text", text.contains("@@"))
}
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 responding state shows responding message`() {
view.show(
Permission(
id = "perm_responding",
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
state = PermissionRequestState.RESPONDING,
)
)
val text = allText(view)
assertTrue("Should show responding message, got: $text", text.contains("Sending response"))
}
fun `test error state shows error message`() {
view.show(
Permission(
id = "perm_error",
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Boom",
state = PermissionRequestState.ERROR,
)
)
val text = allText(view)
assertTrue("Should show error message, got: $text", text.contains("Boom"))
// ERROR state should keep buttons enabled so user can retry
assertTrue(view.runButtonForTest().isEnabled)
assertTrue(view.denyButtonForTest().isEnabled)
}
fun `test error state shows fallback error text when no message`() {
view.show(
Permission(
id = "perm_error_fallback",
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = null,
state = PermissionRequestState.ERROR,
)
)
val text = allText(view)
assertTrue("Should show fallback error text, got: $text", text.contains("Failed to send"))
}
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)
}
// ------ shared card shell ------
fun `test view contains BaseSessionQuestionPanel after show`() {
view.show(permission())
val panels = findAll<BaseQuestionView>(view)
assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
}
fun `test permission icon is rendered in header`() {
view.show(permission())
val labels = findAll<JBLabel>(view)
assertTrue(
"Expected permission warning icon in header",
labels.any { it.icon == AllIcons.General.Warning },
)
}
// ------ button types ------
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 does not have default style key`() {
view.show(permission())
val btn = view.denyButtonForTest()
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`() {
view.show(permission())
assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background)
assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background)
}
// ------ code labels use editor style ------
fun `test code label uses editor font family after applyStyle`() {
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 labels = view.codeLabelsForTest()
assertNotNull("Should have at least one code label for command", labels.firstOrNull())
assertEquals("Code label font family should use editor family", "Courier New", labels[0].font.name)
}
fun `test permission header uses headerFont 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 should equal headerFont", style.headerFont, header)
}
fun `test code label uses code background`() {
view.show(
Permission(
id = "perm_bg",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(command = "pwd"),
)
)
val labels = view.codeLabelsForTest()
assertFalse("Expected code labels", labels.isEmpty())
assertEquals(SessionUiStyle.View.headerHover(), labels[0].background)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
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)
}
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("<wbr>", ""), path)
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
val result = mutableListOf<T>()
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
}
}
@@ -0,0 +1,396 @@
package ai.kilocode.client.ui.layout
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)
}
// ------ 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 align CENTER CENTER produces centered layout`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.CENTER, VAlign.CENTER)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(80, 40, 40, 20, child)
}
fun `test align RIGHT TOP positions at top-right`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.RIGHT, VAlign.TOP)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(160, 0, 40, 20, child)
}
fun `test align LEFT FIT fills height`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.LEFT, VAlign.FIT)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(0, 0, 40, 100, child)
}
fun `test align CENTER TOP centers horizontally and pins to top`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.CENTER, VAlign.TOP)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(80, 0, 40, 20, child)
}
fun `test align FIT BOTTOM fills width and pins to bottom`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.FIT, VAlign.BOTTOM)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(0, 80, 200, 20, child)
}
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.align(HAlign.TRACK, VAlign.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 align TRACK TOP fills width only, V respects preferred`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.TRACK, VAlign.TOP)
wrap.setBounds(0, 0, 200, 100)
wrap.doLayout()
assertBounds(0, 0, 200, 20, child)
}
fun `test align CENTER TRACK fills height only, H respects preferred`() {
val child = child(pref = 40 x 20)
val wrap = child.align(HAlign.CENTER, VAlign.TRACK)
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)
}
}
@@ -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"))
}
@@ -239,6 +239,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,
@@ -248,6 +258,11 @@ data class PermissionRequestDto(
val metadata: Map<String, String> = emptyMap(),
val always: List<String> = emptyList(),
val tool: ToolRefDto? = null,
val message: String? = null,
val command: String? = null,
val rules: List<String> = emptyList(),
val filePath: String? = null,
val fileDiffs: List<PermissionFileDiffDto> = emptyList(),
)
@Serializable
-1
View File
@@ -37,7 +37,6 @@
"outputs": []
},
"@kilocode/kilo-jetbrains#test:ci": {
"dependsOn": ["@kilocode/kilo-jetbrains#typecheck"],
"outputs": [".artifacts/unit/junit.xml"]
},
"@opencode-ai/ui#test": {