diff --git a/.changeset/custom-question-jetbrains.md b/.changeset/custom-question-jetbrains.md new file mode 100644 index 00000000000..97a63d7f16c --- /dev/null +++ b/.changeset/custom-question-jetbrains.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support typed custom responses to question prompts in the JetBrains plugin. diff --git a/.changeset/jetbrains-permission-compact-rows.md b/.changeset/jetbrains-permission-compact-rows.md new file mode 100644 index 00000000000..495d1ded6cd --- /dev/null +++ b/.changeset/jetbrains-permission-compact-rows.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains permission prompts with compact action rows and diff badges. diff --git a/.changeset/session-expandable-defaults.md b/.changeset/session-expandable-defaults.md new file mode 100644 index 00000000000..905b3adb933 --- /dev/null +++ b/.changeset/session-expandable-defaults.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Start expandable session sections collapsed by default. diff --git a/.changeset/session-question-view-style.md b/.changeset/session-question-view-style.md new file mode 100644 index 00000000000..ca25c743d45 --- /dev/null +++ b/.changeset/session-question-view-style.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon. diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 13112339cd5..8afdd0ecac3 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d1646c33548..c4a920cc04a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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 { + 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 { + if (this == null) return emptyList() + val raw = this["rules"] ?: return emptyList() + val arr = raw.arr() + if (arr != null) { + return arr.mapNotNull { it.jsonPrimitive.contentOrNull } + } + val text = runCatching { raw.jsonPrimitive.contentOrNull }.getOrNull() ?: return emptyList() + if (text.startsWith("[")) { + return runCatching { + KiloCliDataParser.parseRulesJson(text) + }.getOrElse { listOf(text) } + } + return listOf(text) +} + +private fun JsonObject?.permissionDiffs(path: String?): List { + if (this == null) return emptyList() + val filediff = this["filediff"].obj() + if (filediff != null) { + val file = filediff.str("file") ?: filediff.str("relativePath") ?: path ?: return emptyList() + return listOf( + PermissionFileDiffDto( + file = file, + patch = filediff.str("patch"), + before = filediff.str("before"), + after = filediff.str("after"), + additions = filediff.long("additions")?.safeInt() ?: 0, + deletions = filediff.long("deletions")?.safeInt() ?: 0, + ) + ) + } + val files = this["files"].arr() + if (files != null) { + return files.mapNotNull { elem -> + val item = elem.obj() ?: return@mapNotNull null + val file = item.str("relativePath") ?: item.str("filePath") ?: item.str("file") ?: return@mapNotNull null + PermissionFileDiffDto( + file = file, + patch = item.str("patch"), + before = item.str("before"), + after = item.str("after"), + additions = item.long("additions")?.safeInt() ?: 0, + deletions = item.long("deletions")?.safeInt() ?: 0, + ) + } + } + val diff = str("diff") + if (diff != null) { + return listOf(PermissionFileDiffDto(file = path ?: "patch", patch = diff)) + } + return emptyList() +} + // JsonObject convenience extensions private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 03934123a03..c614d14bdb2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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 // ================================================================ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 329fa59d787..c06995e0ccd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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) }, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index d989efd9181..a72f6fe55a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -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 = mutableMapOf() + private val childIds: MutableSet = 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, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt index d6034eee7ca..efc9d568497 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt @@ -17,10 +17,12 @@ data class Permission( ) data class PermissionMeta( + val command: String? = null, val rules: List = emptyList(), val diff: String? = null, val filePath: String? = null, val fileDiff: PermissionFileDiff? = null, + val fileDiffs: List = emptyList(), val raw: Map = emptyMap(), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 3d079596616..339c6638a85 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -10,8 +10,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, private val history: () -> Unit = {}, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { - val view: CenterShrinkPanel = CenterShrinkPanel(this) + val view: Align = align(HAlign.CENTER, VAlign.CENTER) private val model = DefaultListModel() 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt index 04acd9f6b44..10be5d2285e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt @@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget { } override fun applyStyle(style: SessionEditorStyle) { - label.font = style.uiFont + label.font = style.regularFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 376816612de..5454737bc6c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -62,7 +62,7 @@ class ProgressPanel( } override fun applyStyle(style: SessionEditorStyle) { - label.font = style.uiFont + label.font = style.regularFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 704afd4b48d..4c5e49f4ae1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt new file mode 100644 index 00000000000..366bc5b4801 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session.ui.editor + +import ai.kilocode.client.session.ui.prompt.PromptDataKeys +import ai.kilocode.client.session.ui.prompt.SendPromptContext +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField + +/** + * A session-scoped [EditorTextField] for plain-text input. + * + * When [ctx] is non-null the component injects it into the data context so + * shortcut-based send/stop actions work (prompt use-case). When [ctx] is null + * the component does not expose [PromptDataKeys.SEND], preventing accidental + * `SendPromptAction` dispatch from question custom-answer editors. + * + * Both instances are created on the EDT. The underlying [EditorTextField] + * lazily initializes its IntelliJ editor the first time the component becomes + * visible; that initialization calls `EditorThreading.compute` internally, + * satisfying the platform's read-context requirement without additional + * wrapping here. + */ +internal open class SessionEditorTextField( + project: Project, + private val ctx: SendPromptContext? = null, +) : EditorTextField(project, PlainTextFileType.INSTANCE) { + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + ctx?.let { sink.set(PromptDataKeys.SEND, it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt index e15a525fdd1..67939dacead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt @@ -44,9 +44,9 @@ internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) { background = style.editorBackground foreground = style.editorForeground meter.background = style.editorBackground - used.font = style.smallUiFont + used.font = style.smallFont used.foreground = style.editorForeground - limit.font = style.smallUiFont + limit.font = style.smallFont limit.foreground = style.editorForeground } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 615f52df59e..cc7f31d1079 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -229,23 +229,23 @@ class SessionHeaderPanel( todoRow.background = style.editorBackground body.background = style.editorBackground viewport.background = style.editorBackground - title.font = style.boldUiFont + title.font = style.boldFont title.foreground = style.editorForeground - cost.font = style.uiFont + cost.font = style.regularFont cost.foreground = style.editorForeground - context.font = style.uiFont + context.font = style.regularFont context.foreground = style.editorForeground - todos.font = style.smallUiFont + todos.font = style.smallFont todos.foreground = style.editorForeground - tokenTitle.font = style.smallUiFont + tokenTitle.font = style.smallFont tokenTitle.foreground = style.editorForeground - input.font = style.smallUiFont + input.font = style.smallFont input.foreground = style.editorForeground - output.font = style.smallUiFont + output.font = style.smallFont output.foreground = style.editorForeground - cacheRead.font = style.smallUiFont + cacheRead.font = style.smallFont cacheRead.foreground = style.editorForeground - cacheWrite.font = style.smallUiFont + cacheWrite.font = style.smallFont cacheWrite.foreground = style.editorForeground bar.applyStyle(style) refresh() @@ -391,7 +391,7 @@ class SessionHeaderPanel( expand.accessibleContext.accessibleName = KiloBundle.message(key) } - private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, true) + private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, false) private fun sizeTimeline() { val size = timeline.preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt index e2462f96d86..c5aaa89b63f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt @@ -1,16 +1,9 @@ package ai.kilocode.client.session.ui.prompt -import com.intellij.openapi.actionSystem.DataSink -import com.intellij.openapi.fileTypes.PlainTextFileType +import ai.kilocode.client.session.ui.editor.SessionEditorTextField import com.intellij.openapi.project.Project -import com.intellij.ui.EditorTextField internal class PromptEditorTextField( project: Project, - private val ctx: SendPromptContext, -) : EditorTextField(project, PlainTextFileType.INSTANCE) { - override fun uiDataSnapshot(sink: DataSink) { - super.uiDataSnapshot(sink) - sink.set(PromptDataKeys.SEND, ctx) - } -} + ctx: SendPromptContext, +) : SessionEditorTextField(project, ctx) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt deleted file mode 100644 index 2e087b3bf0c..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt +++ /dev/null @@ -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>() - - // ---- 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 - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt deleted file mode 100644 index be821ffa30e..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt +++ /dev/null @@ -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() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index d510a0fdebe..02daa43ca22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -1,10 +1,10 @@ package ai.kilocode.client.session.ui.style +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Font import kotlin.math.roundToInt @@ -14,6 +14,12 @@ import kotlin.math.roundToInt * * Session UI uses this instead of reading editor globals in every component so font and color changes can be applied * consistently through [SessionEditorStyleTarget]. + * + * Editor-specific fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [editorForeground], [editorBackground]) + * are derived from the active editor color scheme and are used for code/editor-rendered content. + * + * UI font fields ([headerFont], [hintFont], [regularFont], [boldFont], [smallFont]) come from [UiStyle.Fonts] + * and follow standard platform typography — they do not derive from the editor font size. */ data class SessionEditorStyle( val editorScheme: EditorColorsScheme, @@ -24,9 +30,11 @@ data class SessionEditorStyle( val transcriptFont: Font, val smallEditorFont: Font, val boldEditorFont: Font, - val uiFont: Font, - val smallUiFont: Font, - val boldUiFont: Font, + val headerFont: Font, + val hintFont: Font, + val regularFont: Font, + val boldFont: Font, + val smallFont: Font, ) { /** Apply this snapshot to embedded IntelliJ editor components used by session UI. */ fun applyToEditor(editor: EditorEx) { @@ -46,9 +54,7 @@ data class SessionEditorStyle( family: String = scheme.editorFontName, size: Int = scheme.editorFontSize, ): SessionEditorStyle { - val small = scaledSize(size, JBFont.small()) - val ui = JBUI.Fonts.label().deriveFont(size.toFloat()) - val smallUi = JBFont.small().deriveFont(small.toFloat()) + val small = scaledEditorSize(size, JBFont.small()) return SessionEditorStyle( editorScheme = scheme, editorFamily = family, @@ -58,14 +64,16 @@ data class SessionEditorStyle( transcriptFont = Font(family, Font.PLAIN, size), smallEditorFont = Font(family, Font.PLAIN, small), boldEditorFont = Font(family, Font.BOLD, size), - uiFont = ui, - smallUiFont = smallUi, - boldUiFont = ui.deriveFont(Font.BOLD), + headerFont = UiStyle.Fonts.header(), + hintFont = UiStyle.Fonts.hint(), + regularFont = UiStyle.Fonts.regular(), + boldFont = UiStyle.Fonts.bold(), + smallFont = UiStyle.Fonts.small(), ) } - private fun scaledSize(size: Int, font: Font): Int { - val base = JBUI.Fonts.label().size.coerceAtLeast(1) + private fun scaledEditorSize(size: Int, font: Font): Int { + val base = com.intellij.util.ui.JBUI.Fonts.label().size.coerceAtLeast(1) val ratio = font.size.toFloat() / base return (size * ratio).roundToInt().coerceAtLeast(1) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 6cfdc93961e..df7f84ec064 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -72,6 +72,11 @@ object SessionUiStyle { const val USER_BORDER_HORIZONTAL_PADDING = 12 } + /** Permission card command preview limits. */ + object Permission { + const val COMMAND_LINES = 3 + } + /** Tool card preview limits and state colors. */ object Tool { const val BODY_LINES = 15 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index df406de4762..25435404d9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt index a616416843d..83a658f528d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index a01ca5838b6..e2db58c0c7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.ui.RoundedLineBorder import com.intellij.util.ui.JBUI diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt deleted file mode 100644 index be38165e41a..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt +++ /dev/null @@ -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() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index bec8c6b6fad..7faf0e3ac9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index e08880b9017..86ab85cdbec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import java.awt.BorderLayout diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 39048dd38af..2cb3a4f30b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 4d9483bbcc6..59c9b360dc2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.views.base.GenericView +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt new file mode 100644 index 00000000000..58c379568d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -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>() + + 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() + 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) { + 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 = 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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt similarity index 91% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 31db00f4ecd..0dcfb05e209 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.views +package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Generic @@ -36,8 +36,8 @@ class GenericView(content: Generic) : PartView() { fun labelText(): String = label.text override fun applyStyle(style: SessionEditorStyle) { - if (label.font == style.smallUiFont) return - label.font = style.smallUiFont + if (label.font == style.smallFont) return + label.font = style.smallFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt similarity index 77% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 1d32c764b7d..9cfacf9b41c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.views +package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -10,7 +10,7 @@ import javax.swing.JPanel * * Each subclass wraps one [Content] subtype and knows how to display * and update it. Subclasses extend [JPanel] so they can be added directly - * to [MessageView] without an extra component wrapper. + * to [ai.kilocode.client.session.views.MessageView] without an extra component wrapper. * * All methods must be called on the EDT. */ @@ -27,8 +27,8 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget { /** * Append a streaming delta to the existing content. - * Only meaningful for text-bearing renderers ([TextView], [ReasoningView]); - * others ignore deltas by default. + * Only meaningful for text-bearing renderers ([ai.kilocode.client.session.views.TextView], + * [ai.kilocode.client.session.views.ReasoningView]); others ignore deltas by default. */ open fun appendDelta(delta: String) {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt new file mode 100644 index 00000000000..fd9c200b8e9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt new file mode 100644 index 00000000000..886cf788e4e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -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() + private val diffViews = mutableListOf() + + 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) { + 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 = "
${XmlStringUtil.escapeString(text)}
" + applyTargetPane(this) + } + + private fun applyTargetPane(pane: JBHtmlPane) { + pane.font = style.transcriptFont + pane.foreground = style.editorForeground + pane.background = SessionUiStyle.View.headerHover() + pane.reloadCssStylesheets() + } + + private fun targetSheet(): StyleSheet { + val sheet = StyleSheet() + val font = style.transcriptFont + val fg = ColorUtil.toHtmlColor(style.editorForeground) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val family = font.name.replace("\\", "\\\\").replace("'", "\\'") + sheet.addRule("body { margin: 0; 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() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index db1994ef8dd..e33303ede14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -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 = 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index cf78a87ef09..2613300e2df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -5,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>() + // Per-question custom text state — survives navigation. + private var customTexts = emptyList() + // Per-question: whether the custom row is currently selected/open. + private var customOpen = emptyList() private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + // The custom editor for the currently shown question; null when not shown. + private var customEditor: SessionEditorTextField? = null + private var customFocus: FocusAdapter? = null - private val card = 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() + 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 { + 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): JPanel { + val open = customOpen.getOrElse(idx) { false } + val existing = customTexts.getOrElse(idx) { "" }.trim() + val showEditor = open || existing.isNotEmpty() + val row = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + // No bottom padding — it's the last row + border = JBUI.Borders.empty() + } + + val toggle: AbstractButton = if (item.multiple) { + JBCheckBox().apply { + actionCommand = "" + isSelected = open + isOpaque = false + } + } else { + JBRadioButton().apply { + actionCommand = "" + isSelected = open + isOpaque = false + } + } + + val toggleListener = { + val wasOpen = customOpen.getOrElse(idx) { false } + if (!wasOpen) { + // Opening custom row + if (!item.multiple) { + // Single-select: clear option selection + set.clear() + } + customOpen = customOpen.toMutableList().also { it[idx] = true } + } else { + // Closing custom row + customOpen = customOpen.toMutableList().also { it[idx] = false } + } + refreshCustomRow() + } + + if (item.multiple) { + (toggle as JBCheckBox).addActionListener { toggleListener() } + } else { + (toggle as JBRadioButton).addActionListener { + // When the custom radio is selected, deselect any option radio + set.clear() + customOpen = customOpen.toMutableList().also { it[idx] = true } + refreshCustomRow() + } + } + + val press = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (toggle.isEnabled) toggle.doClick() + } + } + + val icon = JPanel(GridBagLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + add(toggle) + addMouseListener(press) + } + + val col = JPanel().apply { + isOpaque = false + layout = GridBagLayout() + addMouseListener(press) + } + + val label = text(KiloBundle.message("session.question.custom.label"), UiStyle.Colors.fg(), true) + label.alignmentX = Component.LEFT_ALIGNMENT + label.addMouseListener(press) + col.add(label) + + val header = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + toolTipText = null + alignmentX = Component.LEFT_ALIGNMENT + } + header.addMouseListener(press) + header.add(icon, BorderLayout.WEST) + header.add(col, BorderLayout.CENTER) + row.add(header) + + if (showEditor) { + val ed = buildCustomEditor() + customEditor = ed + val focus = object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectCustom(item, set) + } + customFocus = focus + ed.addFocusListener(focus) + ed.addSettingsProvider { ex -> + ex.contentComponent.addFocusListener(focus) + ex.component.addFocusListener(focus) + } + val edWrapper = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(0, UiStyle.Gap.lg() + JBUI.scale(20), UiStyle.Gap.lg(), 0) + alignmentX = Component.LEFT_ALIGNMENT + add(ed, BorderLayout.CENTER) + } + row.add(edWrapper) + } + + return row + } + + internal fun testFocusCustomEditor() { + val ed = customEditor ?: return + val focus = customFocus ?: return + focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED)) + } + + private fun selectCustom(item: QuestionItem, set: MutableSet) { + if (customOpen.getOrElse(idx) { false }) return + if (!item.multiple) set.clear() + customOpen = customOpen.toMutableList().also { it[idx] = true } + refreshCustomRow() + } + + /** + * Builds and wires a custom-answer [SessionEditorTextField]. + * + * The component is created on the EDT (as required for all Swing components). + * [SessionEditorTextField] extends [com.intellij.ui.EditorTextField] which + * lazily initialises its IntelliJ editor via [com.intellij.openapi.editor.EditorThreading] + * the first time the component becomes visible, satisfying the platform's + * read-context requirement without any additional wrapping here. + */ + private fun buildCustomEditor(): SessionEditorTextField { + val ed = SessionEditorTextField(project) + ed.border = JBUI.Borders.empty() + ed.setFontInheritedFromLAF(false) + ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) + ed.setShowPlaceholderWhenFocused(true) + ed.setOneLineMode(false) + ed.addSettingsProvider { ex -> + style.applyToEditor(ex) + ex.setBorder(JBUI.Borders.empty()) + ex.scrollPane.border = JBUI.Borders.empty() + ex.scrollPane.viewportBorder = JBUI.Borders.empty() + ex.backgroundColor = style.editorScheme.defaultBackground + ex.scrollPane.background = style.editorScheme.defaultBackground + ex.scrollPane.viewport.background = style.editorScheme.defaultBackground + ex.settings.isUseSoftWraps = true + ex.settings.isAdditionalPageAtBottom = false + ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + ed.font = style.transcriptFont + ed.background = style.editorScheme.defaultBackground + + // Pre-fill with saved text. This call also forces lazy document creation so + // that addDocumentListener can install on a non-null document immediately. + val saved = customTexts.getOrElse(idx) { "" } + ed.text = saved + + // Sync preferred height to line count; update stored text on edits. + // EditorTextField.addDocumentListener is the preferred (non-deprecated) API. + // The document was already created above (ed.text = saved ensures getDocument() + // was called), so installDocumentListener succeeds. + ed.addDocumentListener(object : DocumentListener { + override fun documentChanged(e: DocumentEvent) { + val txt = ed.text + customTexts = customTexts.toMutableList().also { it[idx] = txt } + syncEditorHeight(ed) + question?.let(::syncControls) + refresh() + scroll() + } + }) + + syncEditorHeight(ed) + return ed + } + + private fun syncEditorHeight(ed: SessionEditorTextField) { + val editor = ed.getEditor(false) + val estimated = estimatedLines(ed) + val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated) + val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height + val height = line * lines.coerceAtLeast(1) + JBUI.scale(16) + ed.preferredSize = Dimension(0, height) + ed.minimumSize = Dimension(0, height) + } + + private fun estimatedLines(ed: SessionEditorTextField): Int { + val width = space(ed) + if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1) + val metrics = ed.getFontMetrics(ed.font) + val columns = (width / metrics.charWidth('m').coerceAtLeast(1)).coerceAtLeast(1) + return ed.text.lineSequence().sumOf { line -> + ((line.length + columns - 1) / columns).coerceAtLeast(1) + }.coerceAtLeast(1) + } + + private fun space(component: Component): Int { + if (component.width > 0) return component.width + var node = component.parent + while (node != null) { + if (node.width > 0) { + val ins = node.insets + return (node.width - ins.left - ins.right).coerceAtLeast(0) + } + node = node.parent + } + return 0 + } + + /** Re-syncs the current page after the custom row toggle changes. */ + private fun refreshCustomRow() { + val q = question ?: return + syncPage() + // Request focus on the editor when opening + if (customOpen.getOrElse(idx) { false }) { + customEditor?.requestFocusInWindow() + } + syncControls(q) + scroll() + } + private fun radioRow(opt: QuestionOption, set: MutableSet, group: ButtonGroup): JPanel { val radio = JBRadioButton().apply { actionCommand = opt.label @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt deleted file mode 100644 index 57e7d95e5f4..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ai.kilocode.client.ui - -import java.awt.Component -import java.awt.Dimension -import javax.swing.JPanel - -/** - * Centers its single child and shrinks it to available space when needed. - * If available space is larger than the child's maximum size, the child is not expanded. - */ -class CenterShrinkPanel(child: Component) : JPanel(null) { - init { - isOpaque = false - add(child) - } - - override fun doLayout() { - if (componentCount == 0) return - val child = getComponent(0) - val insets = getInsets() - val availW = width - insets.left - insets.right - val availH = height - insets.top - insets.bottom - val pref = child.preferredSize - val max = child.maximumSize - val w = minOf(pref.width, max.width, availW) - val h = minOf(pref.height, max.height, availH) - val x = insets.left + (availW - w) / 2 - val y = insets.top + (availH - h) / 2 - child.setBounds(x, y, w, h) - } - - override fun getPreferredSize(): Dimension { - if (componentCount == 0) return super.getPreferredSize() - val pref = getComponent(0).preferredSize - val insets = getInsets() - return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt new file mode 100644 index 00000000000..1882f0a9ee3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.ui + +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JPanel + +internal class DiffStatBadge( + additions: Int, + deletions: Int, +) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) { + private val removed = JBLabel("-$deletions").apply { + foreground = removedColor() + font = JBFont.small() + } + private val added = JBLabel("+$additions").apply { + foreground = addedColor() + font = JBFont.small() + } + + init { + isOpaque = false + add(removed) + add(added) + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = backgroundColor() + g2.fillRoundRect(0, 0, width, height, height, height) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + internal fun removedLabelForTest() = removed + + internal fun addedLabelForTest() = added +} + +private fun backgroundColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.background", + JBColor(Color(0x26, 0x26, 0x26), Color(0x26, 0x26, 0x26)), +) + +private fun removedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.removedForeground", + JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)), +) + +private fun addedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.addedForeground", + JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)), +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 4d5f3265f7a..156d0760338 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -130,6 +130,21 @@ object UiStyle { /** Prominent short content, e.g. device auth code. Maps to [JBFont.h2] bold. */ fun large(): JBFont = JBFont.h2().asBold() + + /** Card/question header font — bold at heading level 4. */ + fun header(): JBFont = JBFont.h4().asBold() + + /** Hint or description font — plain regular size. */ + fun hint(): JBFont = JBFont.regular() + + /** Standard body/label text. */ + fun regular(): JBFont = JBFont.regular() + + /** Bold body/label text. */ + fun bold(): JBFont = JBFont.regular().asBold() + + /** Small secondary text, e.g. metadata labels. */ + fun small(): JBFont = JBFont.small() } /** Small component helpers that keep repeated Swing setup in one place. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt new file mode 100644 index 00000000000..e811fc8a7f8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt @@ -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 { + 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 2f299325292..1135022c543 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.ui.UiStyle import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -355,7 +356,11 @@ abstract class MdView private constructor() { linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } - preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preBgOverride?.let { + val color = hex(it) + rules.append("div.code-block { background: $color; border-color: $color; padding: ${UiStyle.Gap.xs()}px ${UiStyle.Gap.lg()}px } ") + rules.append("pre { background: $color; border-color: $color } ") + } preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg new file mode 100644 index 00000000000..a8f3398dfe1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index fe80d55a286..763099b2a0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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… diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 01249a40d3c..4b101d140c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 727f527b25f..aa095e0a136 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,8 +1,12 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.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: + name: edit + patterns: *.kt + always: + file: + state: PENDING + metadata: + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) + } + + fun `test child PermissionReplied clears root awaiting permission`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")), flush = false) + emit(ChatEventDto.PermissionReplied("ses_child", "child_perm1")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test replyPermission for child request sends correct requestId`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + edt { m.replyPermission("child_perm1", PermissionReplyDto("once")) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("child_perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test child non-permission events do not change root state`() { + val (m, _, modelEvents) = prompted() + val initialState = m.model.state + + // Emit non-permission child events — they must not affect the root + emit(ChatEventDto.TurnOpen("ses_child"), flush = false) + emit(ChatEventDto.SessionStatusChanged("ses_child", ai.kilocode.rpc.dto.SessionStatusDto("busy")), flush = false) + emit(ChatEventDto.SessionIdle("ses_child")) + + assertEquals(initialState, m.model.state) + // No extra model state events from child non-permission events + val stateEvents = modelEvents.filterIsInstance() + assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty()) + } + + fun `test root permission event is not processed as child permission`() { + val (m, _, _) = prompted() + + // No task part emitted — root permission should still work + emit(ChatEventDto.PermissionAsked("ses_test", permission("root_perm"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("root_perm", perm.id) + } + + private fun taskPart(childSessionId: String) = ChatEventDto.PartUpdated( + sessionID = "ses_test", + part = PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to childSessionId), + ), + ) + + private fun childPermission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_child", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + ) + private fun permission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index e31795516c5..8cb0c1f3507 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -1,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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index b11df3d3320..cc5b1130da0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Font @@ -37,7 +38,7 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } - fun `test custom style derives fonts from supplied editor baseline`() { + fun `test custom style keeps editor fields from supplied baseline`() { val style = SessionEditorStyle.create(family = "Courier New", size = 22) assertEquals("Courier New", style.editorFamily) @@ -48,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") } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a8ab4951f2f..45959beeb29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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 = { _ -> }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 375ae454afb..161fa553118 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -125,8 +125,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val mv = panel.findMessage("a1")!! val gv = mv.part("g1") assertNotNull(gv) - assertTrue(gv is ai.kilocode.client.session.views.GenericView) - assertTrue((gv as ai.kilocode.client.session.views.GenericView).labelText().contains("snapshot")) + assertTrue(gv is ai.kilocode.client.session.views.base.GenericView) + assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot")) } // ------ silent part types ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 217c8ca94d2..361d1846e1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -54,7 +54,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val style = SessionEditorStyle.current() assertTrue(panel.isVisible) - assertTrue(panel.isExpanded()) + assertFalse(panel.isExpanded()) assertEquals("Generated title", panel.titleText()) assertEquals("$0.07", panel.costText()) assertEquals("1%", panel.contextText()) @@ -135,6 +135,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val timeline = panel.timelinePanel() val bar = panel.contextBar() + assertFalse(panel.isExpanded()) + panel.expandButton().doClick() + assertTrue(panel.isExpanded()) assertSame(body, panel.bodyPanel()) assertSame(timeline, panel.timelinePanel()) @@ -244,26 +247,27 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - assertTrue(panel.isExpanded()) - assertEquals("Hide session metrics", panel.expandTip()) - - panel.expandButton().doClick() - emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) - assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) panel.expandButton().doClick() - emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) assertTrue(panel.isExpanded()) assertEquals("Hide session metrics", panel.expandTip()) + + panel.expandButton().doClick() + emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) + + assertFalse(panel.isExpanded()) + assertEquals("Show session metrics", panel.expandTip()) } fun `test collapse persists and new header starts collapsed`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) + panel.expandButton().doClick() panel.expandButton().doClick() assertFalse(panel.isExpanded()) @@ -294,6 +298,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { } fun `test hidden empty header collapse keeps saved expansion preference`() { + PropertiesComponent.getInstance().setValue(SessionHeaderPanel.EXPANDED_KEY, "true") appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val c = controller() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt deleted file mode 100644 index 7d57b03a7ce..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt +++ /dev/null @@ -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 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 - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index 6b2bd18d909..63aa4cbaa87 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -1,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(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(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 edt(block: () -> T): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt deleted file mode 100644 index 73dca0fc954..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt +++ /dev/null @@ -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>() - 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 = root.components.flatMap { comp -> - val item = if (comp is AbstractButton) listOf(comp) else emptyList() - if (comp is Container) item + buttons(comp) else item - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 9038cbffb3e..8d03559f920 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -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 ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index dff95461e65..b35c7e1538b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -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(view, "Minimal").doClick() + button(view, "Next").doClick() + option(view, "Unit").doClick() + button(view, "Review").doClick() + + val dismiss = button(view, "Dismiss") + val back = button(view, "Back") + val submit = button(view, "Submit") + assertSame("Dismiss and Back should be in the same right-aligned group", dismiss.parent, back.parent) + assertSame("Back and Submit should be in the same right-aligned group", back.parent, submit.parent) + assertTrue("Dismiss should appear before Back", dismiss.parent.components.indexOf(dismiss) < back.parent.components.indexOf(back)) + assertTrue("Back should appear before Submit", back.parent.components.indexOf(back) < submit.parent.components.indexOf(submit)) + } + // ------ radio options ------ fun `test single question renders radio options`() { @@ -159,19 +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(view, "Plain") + val label = text(view, "Plain") + val row = label.parent.parent as Container + + val radioCenter = center(radio, row) + val labelCenter = center(label, row) + assertTrue( + "radio should be vertically centered with a single-line label: radio=$radioCenter label=$labelCenter row=${row.size}", + abs(radioCenter - labelCenter) <= 2, + ) + } + + fun `test custom row centers button beside label`() { + view.show(customSingleQuestion("custom_center")) + layout(view) + + val radio = findAll(view).first { it.actionCommand == "" } + val label = text(view, "Add your own response") + val row = label.parent.parent as Container + + val radioCenter = center(radio, row) + val labelCenter = center(label, row) + assertTrue( + "custom radio should be vertically centered with the label: radio=$radioCenter label=$labelCenter row=${row.size}", + abs(radioCenter - labelCenter) <= 2, + ) + } + + fun `test question title uses headerFont and hint uses hintFont`() { view.show(singleSelectQuestion("q_fonts")) 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(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(view).first { it.actionCommand == "" } + customRadio.doClick() + + // Find the editor that appeared and type text + val ed = findAll(view).first() + ed.text = "my custom answer" + + button(view, "Submit").doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers) + } + + fun `test custom editor grows for wrapped input`() { + view.show(customSingleQuestion("q_custom_grow")) + layout(view, 240) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + layout(view, 240) + + val ed = findAll(view).first() + val initial = ed.preferredSize.height + ed.text = "wrapped ".repeat(30) + + assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial) + } + + fun `test blank custom input does not enable submit`() { + view.show(customSingleQuestion("q_custom_blank")) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + + val submit = button(view, "Submit") + assertFalse("Submit should remain disabled when custom text is blank", submit.isEnabled) + } + + fun `test selecting normal option after custom input sends option not custom text`() { + view.show(customSingleQuestion("q_custom_revert")) + + // Open custom and type something + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "stale custom" + + // Now select a normal option + option(view, "Minimal").doClick() + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + } + + fun `test selecting normal option after custom input clears custom radio selection`() { + view.show(customSingleQuestion("q_custom_clear_radio")) + + val radio = findAll(view).first { it.actionCommand == "" } + radio.doClick() + val ed = findAll(view).first() + ed.text = "stale custom" + + option(view, "Minimal").doClick() + + val custom = findAll(view).first { it.actionCommand == "" } + assertFalse("Custom radio should not stay selected after choosing a normal option", custom.isSelected) + assertTrue("Normal option should be selected", option(view, "Minimal").isSelected) + assertTrue("Custom editor should stay visible for non-empty text", findAll(view).any { it.parent != null && it.text == "stale custom" }) + assertLabelsDoNotContain(view, "stale custom") + } + + fun `test empty custom editor is removed after selecting normal option`() { + view.show(customSingleQuestion("q_custom_empty_editor")) + + findAll(view).first { it.actionCommand == "" }.doClick() + assertNotNull(findAll(view).firstOrNull { it.parent != null }) + + option(view, "Minimal").doClick() + + assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null }) + } + + fun `test focusing retained custom editor reselects custom response`() { + view.show(customSingleQuestion("q_custom_focus")) + + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "stale custom" + option(view, "Minimal").doClick() + + view.testFocusCustomEditor() + + assertTrue("Custom radio should be selected when its editor takes focus", findAll(view).first { it.actionCommand == "" }.isSelected) + assertFalse("Normal option should be cleared when custom editor takes focus", option(view, "Minimal").isSelected) + assertEquals("Submit should send custom text after focusing retained editor", listOf(listOf("stale custom")), run { + button(view, "Submit").doClick() + replies.single().second.answers + }) + } + + fun `test multi select custom answer combines with selected options`() { + view.show(customMultiQuestion("q_multi_custom")) + + option(view, "A").doClick() + val customBox = findAll(view).first { it.actionCommand == "" } + customBox.doClick() + val ed = findAll(view).first() + ed.text = "extra" + + button(view, "Review").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A", "extra")), replies.single().second.answers) + } + + fun `test custom input is trimmed before submit`() { + view.show(customSingleQuestion("q_custom_trim")) + + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = " trimmed answer " + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("trimmed answer")), replies.single().second.answers) + } + + fun `test multi select custom answer can be unchecked`() { + view.show(customMultiQuestion("q_multi_custom_unchecked")) + + option(view, "A").doClick() + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "extra" + + findAll(view).first { it.actionCommand == "" }.doClick() + + assertFalse( + "Custom checkbox should be unchecked", + findAll(view).first { it.actionCommand == "" }.isSelected, + ) + assertTrue("Review should stay enabled because a normal option is selected", button(view, "Review").isEnabled) + button(view, "Review").doClick() + assertLabelsContain(view, "A") + assertLabelsDoNotContain(view, "extra") + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A")), replies.single().second.answers) + } + + fun `test duplicate custom answer is submitted once`() { + view.show(customMultiQuestion("q_multi_custom_duplicate")) + + option(view, "A").doClick() + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "A" + + button(view, "Review").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A")), replies.single().second.answers) + } + + fun `test custom text appears in review`() { + view.show( + Question( + id = "q_custom_review", + items = listOf( + QuestionItem( + question = "How?", + header = "H", + options = listOf(QuestionOption("X", "")), + multiple = false, + custom = true, + ), + QuestionItem( + question = "What?", + header = "W", + options = listOf(QuestionOption("Y", "")), + multiple = false, + custom = false, + ), + ), + ) + ) + + // Answer first with custom + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "typed answer" + + button(view, "Next").doClick() + option(view, "Y").doClick() + button(view, "Review").doClick() + + assertLabelsContain(view, "typed answer") + } + + fun `test custom text preserved across navigation`() { + view.show( + Question( + id = "q_custom_nav", + items = listOf( + QuestionItem( + question = "How?", + header = "H", + options = listOf(QuestionOption("X", "")), + multiple = false, + custom = true, + ), + QuestionItem( + question = "What?", + header = "W", + options = listOf(QuestionOption("Y", "")), + multiple = false, + custom = false, + ), + ), + ) + ) + + // Open custom on first question and type + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "preserved text" + + // Navigate forward + button(view, "Next").doClick() + option(view, "Y").doClick() + + // Navigate back + navButton(view, "Back").doClick() + + // Custom row should still be open with the preserved text in the editor + val editorAfterBack = findAll(view).firstOrNull() + assertNotNull("Custom editor should still be visible after navigating back", editorAfterBack) + assertEquals("Custom editor should have preserved text", "preserved text", editorAfterBack!!.text) + } + + fun `test optionless custom question is answerable`() { + view.show( + Question( + id = "q_optionless", + items = listOf( + QuestionItem( + question = "Free answer", + header = "Free", + options = emptyList(), + multiple = false, + custom = true, + ) + ), + ) + ) + + // The custom row should be present + assertLabelsContain(view, "Add your own response") + + // Open the custom row + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + + val ed = findAll(view).first() + ed.text = "my answer" + + val submit = button(view, "Submit") + assertTrue("Submit should be enabled after typing in optionless custom question", submit.isEnabled) + + submit.doClick() + + assertFalse(view.isVisible) + assertEquals(listOf(listOf("my answer")), replies.single().second.answers) + } + // ------ helpers ------ /** @@ -514,6 +877,21 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun layout(root: Container, width: Int = 400) { + root.setSize(width, root.preferredSize.height) + layoutTree(root) + } + + private fun layoutTree(root: Container) { + root.doLayout() + for (child in root.components) { + if (child is Container) layoutTree(child) + } + } + + private fun center(component: Component, root: Component): Int = + SwingUtilities.convertPoint(component, 0, component.height / 2, root).y + private fun singleSelectQuestion(id: String) = Question( id = id, items = listOf( @@ -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(root).any { it.text == text } || findAll(root).any { it.text == text } assertTrue("Expected label '$text' to be present", found) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index 6cae7834def..f0cf4d20169 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -8,61 +8,58 @@ import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class ReasoningViewTest : BasePlatformTestCase() { - fun `test completed reasoning is expanded by default`() { + fun `test completed reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("Reasoning", view.headerText()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) assertTrue(view.bodyCreated()) } fun `test short completed reasoning is collapsible`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) view.toggle() - assertFalse(view.isExpanded()) - assertFalse(view.bodyVisible()) + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is expanded by default`() { + fun `test streaming reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) } - fun `test update to done preserves visible reasoning`() { + fun `test update to done preserves collapsed reasoning`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) } fun `test toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + view.toggle() assertTrue(view.isExpanded()) view.toggle() assertFalse(view.isExpanded()) - view.toggle() - assertTrue(view.isExpanded()) } - fun `test collapsed reasoning expands on update`() { + fun `test collapsed reasoning stays collapsed on update`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) - - view.toggle() view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("one\ntwo\nthree", view.markdown()) } @@ -72,39 +69,38 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) } - fun `test blank reasoning expands when delta arrives`() { + fun `test blank reasoning stays collapsed when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) view.appendDelta("b") assertEquals("b", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) + assertTrue(view.hasToggle()) } - fun `test collapsed append reattaches eager reasoning body`() { + fun `test collapsed append keeps eager reasoning body detached`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) - view.toggle() view.appendDelta("b") assertEquals("ab", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) } - fun `test collapsed update reattaches eager reasoning body`() { + fun `test collapsed update keeps eager reasoning body detached`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) - view.toggle() view.update(reasoning("p1", done = false, text = "abc")) assertEquals("abc", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) } fun `test reasoning reuses eager markdown body`() { @@ -116,7 +112,7 @@ class ReasoningViewTest : BasePlatformTestCase() { view.toggle() assertSame(component, view.md.component) - assertFalse(view.bodyVisible()) + assertTrue(view.bodyVisible()) } fun `test blank reasoning has no toggle`() { @@ -158,6 +154,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) + view.toggle() assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt new file mode 100644 index 00000000000..5312712fe83 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -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(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(panel).firstOrNull { it.font.isBold } + assertNotNull("Bold header text area should be present", bold) + assertEquals("My Title", bold!!.text) + } + } + + fun `test setHeader with description shows description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "Hint text") + val desc = findAll(panel).firstOrNull { it.text == "Hint text" } + assertNotNull("Description text area should be present", desc) + } + } + + fun `test setHeader without description hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title") + val areas = findAll(panel) + val nonBold = areas.filter { !it.font.isBold } + // description should either be hidden or blank + assertTrue("Non-bold text areas should be hidden or empty", nonBold.all { !it.isVisible || it.text.isBlank() }) + } + } + + fun `test setDescription with blank hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "some text") + panel.setDescription("") + val areas = findAll(panel) + val desc = areas.firstOrNull { !it.font.isBold } + assertTrue("Description should be hidden when blank", desc == null || !desc.isVisible) + } + } + + fun `test setDescription with null hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "some text") + panel.setDescription(null) + val areas = findAll(panel) + val desc = areas.firstOrNull { !it.font.isBold } + assertTrue("Description should be hidden when null", desc == null || !desc.isVisible) + } + } + + // ------ 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(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(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(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(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 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 findAll(root: Container): List = findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (child is Container) result.addAll(findAllCls(child, cls)) + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt new file mode 100644 index 00000000000..8882b3b0c47 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -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>() + 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(view) + assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty()) + } + + fun `test permission icon is rendered in header`() { + view.show(permission()) + + val labels = findAll(view) + assertTrue( + "Expected permission warning icon in header", + labels.any { it.icon == AllIcons.General.Warning }, + ) + } + + // ------ 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 = 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("", ""), path) + + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + if (child is Container && child !is AbstractButton) { + result.addAll(findAllCls(child, cls)) + } + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt new file mode 100644 index 00000000000..e12db964182 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 8a40caf7337..27c6e901c4c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -243,6 +243,7 @@ class MdViewTest : BasePlatformTestCase() { view.set("```\ncode\n```") val sheet = view.overrideSheet() assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("div.code-block")) assertTrue(sheet.contains("#d0e0f0")) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 9c17eb0dbc7..e333599e2b3 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -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 = emptyMap(), val always: List = emptyList(), val tool: ToolRefDto? = null, + val message: String? = null, + val command: String? = null, + val rules: List = emptyList(), + val filePath: String? = null, + val fileDiffs: List = emptyList(), ) @Serializable diff --git a/turbo.json b/turbo.json index 8c569b72fef..c1798ac2a7e 100644 --- a/turbo.json +++ b/turbo.json @@ -37,7 +37,6 @@ "outputs": [] }, "@kilocode/kilo-jetbrains#test:ci": { - "dependsOn": ["@kilocode/kilo-jetbrains#typecheck"], "outputs": [".artifacts/unit/junit.xml"] }, "@opencode-ai/ui#test": {