mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
Merge pull request #10398 from Kilo-Org/scythe-dust
feat(jetbrains): polish session permission prompts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support typed custom responses to question prompts in the JetBrains plugin.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve JetBrains permission prompts with compact action rows and diff badges.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Start expandable session sections collapsed by default.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon.
|
||||
@@ -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
|
||||
|
||||
|
||||
+91
-5
@@ -18,6 +18,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.ModelStateDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionFileDiffDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartTimeDto
|
||||
@@ -511,11 +512,27 @@ object KiloCliDataParser {
|
||||
val permission = obj.str("permission") ?: return null
|
||||
val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
val meta = obj["metadata"]?.jsonObject?.let { m ->
|
||||
m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") }
|
||||
} ?: emptyMap()
|
||||
val ref = toolRef(obj)
|
||||
return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref)
|
||||
val metaObj = obj["metadata"].obj()
|
||||
val meta = metaObj?.entries?.mapNotNull { (key, value) ->
|
||||
val text = value.scalar() ?: return@mapNotNull null
|
||||
key to text
|
||||
}?.toMap() ?: emptyMap()
|
||||
val path = metaObj.path()
|
||||
val diffs = metaObj.permissionDiffs(path)
|
||||
return PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = sid,
|
||||
permission = permission,
|
||||
patterns = patterns,
|
||||
metadata = meta,
|
||||
always = always,
|
||||
tool = toolRef(obj),
|
||||
message = obj.str("message") ?: metaObj?.str("message"),
|
||||
command = metaObj?.str("command") ?: obj.str("command"),
|
||||
rules = metaObj.rules(),
|
||||
filePath = path,
|
||||
fileDiffs = diffs,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? {
|
||||
@@ -702,6 +719,11 @@ object KiloCliDataParser {
|
||||
/**
|
||||
* Build the JSON body for `POST /permission/{requestID}/reply`.
|
||||
*/
|
||||
internal fun parseRulesJson(text: String): List<String> {
|
||||
val arr = runCatching { json.parseToJsonElement(text).jsonArray }.getOrNull() ?: return listOf(text)
|
||||
return arr.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
|
||||
}
|
||||
|
||||
fun buildPermissionReplyJson(reply: PermissionReplyDto): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("""{"reply":${escape(reply.reply)}""")
|
||||
@@ -770,6 +792,70 @@ object KiloCliDataParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Permission metadata helpers
|
||||
|
||||
private fun JsonElement?.obj(): JsonObject? = runCatching { this?.jsonObject }.getOrNull()
|
||||
private fun JsonElement?.arr(): JsonArray? = runCatching { this?.jsonArray }.getOrNull()
|
||||
|
||||
private fun JsonObject?.path(): String? {
|
||||
if (this == null) return null
|
||||
return str("filepath") ?: str("filePath") ?: str("file") ?: str("path")
|
||||
}
|
||||
|
||||
private fun JsonObject?.rules(): List<String> {
|
||||
if (this == null) return emptyList()
|
||||
val raw = this["rules"] ?: return emptyList()
|
||||
val arr = raw.arr()
|
||||
if (arr != null) {
|
||||
return arr.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
}
|
||||
val text = runCatching { raw.jsonPrimitive.contentOrNull }.getOrNull() ?: return emptyList()
|
||||
if (text.startsWith("[")) {
|
||||
return runCatching {
|
||||
KiloCliDataParser.parseRulesJson(text)
|
||||
}.getOrElse { listOf(text) }
|
||||
}
|
||||
return listOf(text)
|
||||
}
|
||||
|
||||
private fun JsonObject?.permissionDiffs(path: String?): List<PermissionFileDiffDto> {
|
||||
if (this == null) return emptyList()
|
||||
val filediff = this["filediff"].obj()
|
||||
if (filediff != null) {
|
||||
val file = filediff.str("file") ?: filediff.str("relativePath") ?: path ?: return emptyList()
|
||||
return listOf(
|
||||
PermissionFileDiffDto(
|
||||
file = file,
|
||||
patch = filediff.str("patch"),
|
||||
before = filediff.str("before"),
|
||||
after = filediff.str("after"),
|
||||
additions = filediff.long("additions")?.safeInt() ?: 0,
|
||||
deletions = filediff.long("deletions")?.safeInt() ?: 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
val files = this["files"].arr()
|
||||
if (files != null) {
|
||||
return files.mapNotNull { elem ->
|
||||
val item = elem.obj() ?: return@mapNotNull null
|
||||
val file = item.str("relativePath") ?: item.str("filePath") ?: item.str("file") ?: return@mapNotNull null
|
||||
PermissionFileDiffDto(
|
||||
file = file,
|
||||
patch = item.str("patch"),
|
||||
before = item.str("before"),
|
||||
after = item.str("after"),
|
||||
additions = item.long("additions")?.safeInt() ?: 0,
|
||||
deletions = item.long("deletions")?.safeInt() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
val diff = str("diff")
|
||||
if (diff != null) {
|
||||
return listOf(PermissionFileDiffDto(file = path ?: "patch", patch = diff))
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// JsonObject convenience extensions
|
||||
private fun JsonObject.str(key: String): String? =
|
||||
this[key]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
+142
@@ -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
|
||||
// ================================================================
|
||||
|
||||
+5
-2
@@ -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) },
|
||||
|
||||
+115
-3
@@ -11,6 +11,7 @@ import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.model.Question
|
||||
@@ -21,6 +22,7 @@ import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.ConfigWarningDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
import ai.kilocode.rpc.dto.LoadErrorDto
|
||||
@@ -110,6 +112,8 @@ class SessionController(
|
||||
private var partType: String? = null
|
||||
private var tool: String? = null
|
||||
private var eventJob: Job? = null
|
||||
private val childJobs: MutableMap<String, Job> = mutableMapOf()
|
||||
private val childIds: MutableSet<String> = mutableSetOf()
|
||||
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
|
||||
private var recentsState: RecentsState = RecentsState.Idle
|
||||
private var viewState: SessionControllerEvent.ViewChanged? = null
|
||||
@@ -315,6 +319,7 @@ class SessionController(
|
||||
fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) {
|
||||
assertEdt()
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" }
|
||||
updatePermission(requestId, PermissionRequestState.RESPONDING)
|
||||
cs.launch {
|
||||
try {
|
||||
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
|
||||
@@ -322,10 +327,29 @@ class SessionController(
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
edt {
|
||||
updatePermission(
|
||||
requestId,
|
||||
PermissionRequestState.ERROR,
|
||||
e.message ?: KiloBundle.message("session.permission.error"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
|
||||
assertEdt()
|
||||
val current = model.state
|
||||
if (current !is SessionState.AwaitingPermission) return
|
||||
if (current.permission.id != id) return
|
||||
val perm = current.permission.copy(
|
||||
state = state,
|
||||
message = message ?: current.permission.message,
|
||||
)
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(perm)) }
|
||||
}
|
||||
|
||||
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
|
||||
assertEdt()
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
|
||||
@@ -459,6 +483,7 @@ class SessionController(
|
||||
val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull()
|
||||
val items = sessions.messages(id, directory)
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" }
|
||||
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (sid != id) return@runEdt
|
||||
@@ -471,6 +496,7 @@ class SessionController(
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (sid != id) return@runEdt
|
||||
for (child in discovered) trackChild(child)
|
||||
showSession()
|
||||
loaded(!model.isEmpty())
|
||||
}
|
||||
@@ -503,6 +529,7 @@ class SessionController(
|
||||
val session = sessions.importCloudSession(id, directory)
|
||||
val items = sessions.messages(session.id, directory)
|
||||
LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" }
|
||||
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
ref = SessionRef.Local(session)
|
||||
@@ -515,6 +542,7 @@ class SessionController(
|
||||
recoverPending(session.id)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
for (child in discovered) trackChild(child)
|
||||
subscribeEvents()
|
||||
showSession()
|
||||
loaded(!model.isEmpty())
|
||||
@@ -552,6 +580,9 @@ class SessionController(
|
||||
val id = sid ?: return
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" }
|
||||
eventJob?.cancel()
|
||||
childJobs.values.forEach { it.cancel() }
|
||||
childJobs.clear()
|
||||
childIds.clear()
|
||||
eventJob = cs.launch {
|
||||
try {
|
||||
sessions.events(id, directory).collect { event ->
|
||||
@@ -568,6 +599,46 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeChild(child: String) {
|
||||
if (childJobs.containsKey(child)) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=true" }
|
||||
val job = cs.launch {
|
||||
try {
|
||||
sessions.events(child, directory).collect { event ->
|
||||
if (!isChildPermissionEvent(event, child)) return@collect
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" }
|
||||
updates.enqueue(event)
|
||||
}
|
||||
} finally {
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=false" }
|
||||
}
|
||||
}
|
||||
childJobs[child] = job
|
||||
}
|
||||
|
||||
private fun trackChild(child: String) {
|
||||
if (!childIds.add(child)) return
|
||||
subscribeChild(child)
|
||||
cs.launch { recoverChildPermissions(child) }
|
||||
}
|
||||
|
||||
private suspend fun recoverChildPermissions(child: String) {
|
||||
try {
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
|
||||
if (permissions.isEmpty()) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
|
||||
val last = toPermission(permissions.last())
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
// Do not overwrite an existing root or other child AwaitingPermission state
|
||||
if (model.state is SessionState.AwaitingPermission) return@runEdt
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(last)) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rehydrate pending permissions/questions and current session status after history load. */
|
||||
private suspend fun recoverPending(id: String) {
|
||||
try {
|
||||
@@ -640,6 +711,7 @@ class SessionController(
|
||||
if (model.state is SessionState.Busy) {
|
||||
model.setState(SessionState.Busy(status()))
|
||||
}
|
||||
childID(event.part)?.let { child -> trackChild(child) }
|
||||
}
|
||||
|
||||
is ChatEventDto.PartDelta -> {
|
||||
@@ -690,7 +762,8 @@ class SessionController(
|
||||
}
|
||||
|
||||
is ChatEventDto.PermissionAsked -> {
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(event.request)))
|
||||
val perm = toPermission(event.request)
|
||||
model.setState(SessionState.AwaitingPermission(perm))
|
||||
}
|
||||
|
||||
is ChatEventDto.PermissionReplied -> {
|
||||
@@ -1194,6 +1267,9 @@ class SessionController(
|
||||
disposed = true
|
||||
connectionDelay.dispose()
|
||||
eventJob?.cancel()
|
||||
childJobs.values.forEach { it.cancel() }
|
||||
childJobs.clear()
|
||||
childIds.clear()
|
||||
cs.cancel()
|
||||
}
|
||||
|
||||
@@ -1248,6 +1324,19 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts the child session ID from a task tool part's metadata, or null if not a task part. */
|
||||
private fun childID(part: PartDto): String? {
|
||||
if (part.type != "tool" || part.tool != "task") return null
|
||||
return part.metadata["sessionId"]
|
||||
}
|
||||
|
||||
/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */
|
||||
private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) {
|
||||
is ChatEventDto.PermissionAsked -> event.sessionID == child
|
||||
is ChatEventDto.PermissionReplied -> event.sessionID == child
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */
|
||||
private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) {
|
||||
is ChatEventDto.MessageUpdated -> event.sessionID == id
|
||||
@@ -1372,17 +1461,40 @@ private fun ConfigWarningDto.toDetailLine(): String {
|
||||
|
||||
private fun toPermission(dto: PermissionRequestDto): Permission {
|
||||
val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) }
|
||||
val file = dto.metadata["file"] ?: dto.metadata["path"]
|
||||
val state = dto.metadata["state"]?.let { raw ->
|
||||
PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) }
|
||||
} ?: PermissionRequestState.PENDING
|
||||
val diffs = dto.fileDiffs.map {
|
||||
PermissionFileDiff(
|
||||
file = it.file,
|
||||
patch = it.patch,
|
||||
before = it.before,
|
||||
after = it.after,
|
||||
additions = it.additions,
|
||||
deletions = it.deletions,
|
||||
)
|
||||
}
|
||||
val file = dto.filePath
|
||||
?: dto.metadata["filepath"]
|
||||
?: dto.metadata["filePath"]
|
||||
?: dto.metadata["file"]
|
||||
?: dto.metadata["path"]
|
||||
return Permission(
|
||||
id = dto.id,
|
||||
sessionId = dto.sessionID,
|
||||
name = dto.permission,
|
||||
patterns = dto.patterns,
|
||||
always = dto.always,
|
||||
meta = PermissionMeta(filePath = file, raw = dto.metadata),
|
||||
meta = PermissionMeta(
|
||||
command = dto.command ?: dto.metadata["command"],
|
||||
rules = dto.rules,
|
||||
diff = dto.metadata["diff"],
|
||||
filePath = file,
|
||||
fileDiff = diffs.firstOrNull(),
|
||||
fileDiffs = diffs,
|
||||
raw = dto.metadata,
|
||||
),
|
||||
message = dto.message ?: dto.metadata["message"],
|
||||
tool = ref,
|
||||
state = state,
|
||||
)
|
||||
|
||||
+2
@@ -17,10 +17,12 @@ data class Permission(
|
||||
)
|
||||
|
||||
data class PermissionMeta(
|
||||
val command: String? = null,
|
||||
val rules: List<String> = emptyList(),
|
||||
val diff: String? = null,
|
||||
val filePath: String? = null,
|
||||
val fileDiff: PermissionFileDiff? = null,
|
||||
val fileDiffs: List<PermissionFileDiff> = emptyList(),
|
||||
val raw: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
|
||||
+9
-6
@@ -10,8 +10,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.controller.SessionController
|
||||
import ai.kilocode.client.ui.CenterShrinkPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Align
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.Disposable
|
||||
@@ -44,7 +47,7 @@ import javax.swing.ListSelectionModel
|
||||
* Empty-session panel.
|
||||
*
|
||||
* The content is a BorderLayout panel, wrapped in a
|
||||
* [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering.
|
||||
* [Align] (exposed as [view]) so callers need not know about centering.
|
||||
*/
|
||||
class EmptySessionPanel(
|
||||
parent: Disposable,
|
||||
@@ -52,7 +55,7 @@ class EmptySessionPanel(
|
||||
recents: List<SessionDto>,
|
||||
private val history: () -> Unit = {},
|
||||
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
|
||||
val view: CenterShrinkPanel = CenterShrinkPanel(this)
|
||||
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
|
||||
|
||||
private val model = DefaultListModel<LocalHistoryItem>()
|
||||
private var hover = -1
|
||||
@@ -133,7 +136,7 @@ class EmptySessionPanel(
|
||||
val header = BorderLayoutPanel(0, gap).apply {
|
||||
isOpaque = false
|
||||
add(logo, BorderLayout.NORTH)
|
||||
add(CenterShrinkPanel(description), BorderLayout.CENTER)
|
||||
add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
val recent = BorderLayoutPanel().apply {
|
||||
@@ -303,8 +306,8 @@ class EmptySessionPanel(
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
welcomeLabel.font = style.uiFont
|
||||
recentTitle.font = style.smallUiFont
|
||||
welcomeLabel.font = style.regularFont
|
||||
recentTitle.font = style.smallFont
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget {
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
label.font = style.uiFont
|
||||
label.font = style.regularFont
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ class ProgressPanel(
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
label.font = style.uiFont
|
||||
label.font = style.regularFont
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+32
@@ -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) }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
+3
-10
@@ -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)
|
||||
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
package ai.kilocode.client.session.ui.shared
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* Shared rounded background panel for session inline views that follow the
|
||||
* question-view visual style: a card surface with a header text area, a
|
||||
* description text area, an optional component above the header, and slots
|
||||
* for view-specific body and footer content.
|
||||
*
|
||||
* Both [ai.kilocode.client.session.views.question.QuestionView] and
|
||||
* [ai.kilocode.client.session.views.LoginRequiredView] use this as their
|
||||
* outer card shell so they share the same background, padding, and text
|
||||
* styling without duplicating the setup.
|
||||
*
|
||||
* The column always contains (in order): optional top, [headerText],
|
||||
* [descriptionText], optional body, optional footer. Call [setTopPanel],
|
||||
* [setBody], or [setFooter] to replace those slots at any time.
|
||||
*/
|
||||
class BaseSessionQuestionPanel : RoundedContentPanel(
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.pad(),
|
||||
), SessionEditorStyleTarget {
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
// All JBTextArea instances that need editor-font updates, paired with bold flag
|
||||
private val tracked = mutableListOf<Pair<JBTextArea, Boolean>>()
|
||||
|
||||
// ---- header text ----
|
||||
val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
|
||||
|
||||
// ---- description text ----
|
||||
val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
|
||||
|
||||
// ---- slot fields ----
|
||||
private var top: JComponent? = null
|
||||
private var body: JComponent? = null
|
||||
private var footer: JComponent? = null
|
||||
|
||||
// ---- inner layout ----
|
||||
private val col = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
}
|
||||
|
||||
init {
|
||||
addToCenter(col)
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional panel rendered above the header row (e.g. summary + nav in
|
||||
* [ai.kilocode.client.session.views.question.QuestionView]). When set,
|
||||
* it is inserted as the first child of the column; calling with `null`
|
||||
* removes a previously set component.
|
||||
*
|
||||
* The header/description text areas follow immediately after.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setTopPanel(top: JComponent?) {
|
||||
this.top = top
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the body slot that comes after the header/description.
|
||||
* Pass `null` to remove the current body.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setBody(body: JComponent?) {
|
||||
this.body = body
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the footer slot that comes after the body.
|
||||
* Pass `null` to remove the current footer.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setFooter(footer: JComponent?) {
|
||||
this.footer = footer
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
// ---- SessionEditorStyleTarget ----
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
for ((area, bold) in tracked) applyFont(area, bold)
|
||||
}
|
||||
|
||||
// ---- contentColor override ----
|
||||
|
||||
override fun contentColor(): Color = SessionUiStyle.View.surface()
|
||||
|
||||
override fun outlineColor(): Color = SessionUiStyle.View.line()
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private fun rebuildCol() {
|
||||
col.removeAll()
|
||||
top?.let { col.add(it) }
|
||||
col.add(headerText)
|
||||
col.add(descriptionText)
|
||||
body?.let { col.add(it) }
|
||||
footer?.let { col.add(it) }
|
||||
col.revalidate()
|
||||
col.repaint()
|
||||
}
|
||||
|
||||
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
|
||||
val area = object : JBTextArea(value) {
|
||||
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
|
||||
private fun withWidth(fallback: Int): Dimension {
|
||||
val w = availableWidth()
|
||||
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
|
||||
val old = size
|
||||
setSize(w, Int.MAX_VALUE)
|
||||
val ps = super.getPreferredSize()
|
||||
setSize(old)
|
||||
return Dimension(w, ps.height)
|
||||
}
|
||||
|
||||
private fun availableWidth(): Int {
|
||||
var node = parent
|
||||
while (node != null) {
|
||||
if (node.width > 0) {
|
||||
val ins = node.insets
|
||||
return (node.width - ins.left - ins.right).coerceAtLeast(0)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return width
|
||||
}
|
||||
}.apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
isFocusable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = color
|
||||
border = JBUI.Borders.empty()
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
tracked.add(area to bold)
|
||||
applyFont(area, bold)
|
||||
return area
|
||||
}
|
||||
|
||||
private fun applyFont(area: JBTextArea, bold: Boolean) {
|
||||
val font = if (bold) style.boldEditorFont else style.transcriptFont
|
||||
if (area.font != font) area.font = font
|
||||
}
|
||||
}
|
||||
-41
@@ -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() } }
|
||||
+20
-12
@@ -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)
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+3
-2
@@ -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()
|
||||
}
|
||||
|
||||
+15
-25
@@ -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()
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-100
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
package ai.kilocode.client.session.views.base
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* Shared rounded background panel for session inline views that follow the
|
||||
* question-view visual style: a card surface with a header text area, a
|
||||
* description text area, an optional component above the header, and slots
|
||||
* for view-specific content and a base-owned action-button footer.
|
||||
*
|
||||
* Both [ai.kilocode.client.session.views.question.QuestionView] and
|
||||
* [ai.kilocode.client.session.views.LoginRequiredView] use this as their
|
||||
* outer card shell so they share the same background, padding, and text
|
||||
* styling without duplicating the setup.
|
||||
*
|
||||
* The column always contains (in order): optional top, header row with the
|
||||
* header text, description text, optional content, optional action footer.
|
||||
* Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription],
|
||||
* [setContent], [setActions], or [setActionEnabled] to configure the card.
|
||||
*/
|
||||
class BaseQuestionView : RoundedContentPanel(
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.pad(),
|
||||
), SessionEditorStyleTarget {
|
||||
|
||||
// ---- Action descriptor ----
|
||||
|
||||
/**
|
||||
* Describes a button to render in the card's action footer.
|
||||
*
|
||||
* @param id Stable identifier so [setActionEnabled] can target a specific button.
|
||||
* @param text Button label shown to the user.
|
||||
* @param primary True → rendered as the platform default (accent) button.
|
||||
* @param enabled Initial enabled state.
|
||||
* @param handler Called when the button is clicked.
|
||||
*/
|
||||
data class Action(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val primary: Boolean,
|
||||
val enabled: Boolean = true,
|
||||
val handler: () -> Unit,
|
||||
)
|
||||
|
||||
// ---- private state ----
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
private val tracked = mutableListOf<Pair<JBTextArea, Boolean>>()
|
||||
|
||||
private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) {
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
}.apply {
|
||||
isOpaque = false
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
|
||||
private val icon = JBLabel().apply {
|
||||
border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
|
||||
private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
|
||||
|
||||
private var top: JComponent? = null
|
||||
private var content: JComponent? = null
|
||||
|
||||
// action buttons keyed by id for enabled-state updates
|
||||
private val actionButtons = mutableMapOf<String, JButton>()
|
||||
private var actionFooter: JComponent? = null
|
||||
|
||||
private val col = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
}
|
||||
|
||||
init {
|
||||
header.add(icon, BorderLayout.WEST)
|
||||
header.add(headerText, BorderLayout.CENTER)
|
||||
addToCenter(col)
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
// ---- public text API ----
|
||||
|
||||
/**
|
||||
* Set the header text and, optionally, the description text in one call.
|
||||
* Pass `null` or an empty string for [description] to hide the description row.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setHeader(text: String, description: String? = null) {
|
||||
headerText.text = text
|
||||
setDescription(description)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the description text below the header.
|
||||
* The description row is visible only when [text] is non-null and non-blank.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setDescription(text: String?) {
|
||||
descriptionText.text = text ?: ""
|
||||
descriptionText.isVisible = !text.isNullOrBlank()
|
||||
}
|
||||
|
||||
// ---- public slot API ----
|
||||
|
||||
/**
|
||||
* Optional panel rendered above the header row (e.g. summary + nav in
|
||||
* [ai.kilocode.client.session.views.question.QuestionView]). When set,
|
||||
* it is inserted as the first child of the column; calling with `null`
|
||||
* removes a previously set component.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setTopPanel(top: JComponent?) {
|
||||
this.top = top
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional icon rendered at the left edge of the header row.
|
||||
* Pass `null` to remove the icon while keeping header text alignment stable.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setHeaderIcon(icon: Icon?, tooltip: String? = null) {
|
||||
this.icon.icon = icon
|
||||
this.icon.toolTipText = tooltip
|
||||
this.icon.isVisible = icon != null
|
||||
this.icon.revalidate()
|
||||
this.icon.repaint()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the view-specific content slot that comes after the header/description.
|
||||
* Pass `null` to remove the current content.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setContent(content: JComponent?) {
|
||||
this.content = content
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the action buttons shown in the card's right-aligned footer.
|
||||
*
|
||||
* All buttons are created fresh; stable button references across calls can be
|
||||
* maintained by the caller through [setActionEnabled] using the [Action.id].
|
||||
* Pass an empty list to remove the footer entirely.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setActions(actions: List<Action>) {
|
||||
actionButtons.clear()
|
||||
actionFooter = if (actions.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val row = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.X_AXIS)
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
for ((idx, action) in actions.withIndex()) {
|
||||
if (idx > 0) row.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
|
||||
val btn = makeButton(action.text, action.primary).apply {
|
||||
isEnabled = action.enabled
|
||||
addActionListener { action.handler() }
|
||||
}
|
||||
actionButtons[action.id] = btn
|
||||
row.add(btn)
|
||||
}
|
||||
val footer = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
footer.add(row, BorderLayout.EAST)
|
||||
footer
|
||||
}
|
||||
rebuildCol()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a specific action button identified by [id].
|
||||
* No-ops if the id is not found (e.g. before [setActions] is called).
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setActionEnabled(id: String, enabled: Boolean) {
|
||||
actionButtons[id]?.isEnabled = enabled
|
||||
}
|
||||
|
||||
// ---- SessionEditorStyleTarget ----
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
for ((area, bold) in tracked) applyFont(area, bold)
|
||||
}
|
||||
|
||||
// ---- contentColor override ----
|
||||
|
||||
override fun contentColor(): Color = SessionUiStyle.View.surface()
|
||||
|
||||
override fun outlineColor(): Color = SessionUiStyle.View.line()
|
||||
|
||||
// ---- internal test helpers ----
|
||||
|
||||
/** Returns the font currently applied to the header text area. For tests only. */
|
||||
internal fun headerFont() = headerText.font
|
||||
|
||||
/** Returns the font currently applied to the description text area. For tests only. */
|
||||
internal fun descriptionFont() = descriptionText.font
|
||||
|
||||
/** Returns all action buttons as generic JButton, keyed by their action id. For tests only. */
|
||||
internal fun actionButtonsForTest(): Map<String, JButton> = actionButtons.toMap()
|
||||
|
||||
// ---- private helpers ----
|
||||
|
||||
private fun rebuildCol() {
|
||||
col.removeAll()
|
||||
top?.let { col.add(it) }
|
||||
col.add(header)
|
||||
col.add(descriptionText)
|
||||
content?.let {
|
||||
col.add(gap())
|
||||
col.add(it)
|
||||
}
|
||||
actionFooter?.let {
|
||||
col.add(gap())
|
||||
col.add(it)
|
||||
}
|
||||
col.revalidate()
|
||||
col.repaint()
|
||||
}
|
||||
|
||||
private fun gap(): Component = Box.createVerticalStrut(UiStyle.Gap.lg()).apply {
|
||||
setAlignmentX(Component.LEFT_ALIGNMENT)
|
||||
}
|
||||
|
||||
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
|
||||
val area = object : JBTextArea(value) {
|
||||
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
|
||||
private fun withWidth(fallback: Int): Dimension {
|
||||
val w = availableWidth()
|
||||
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
|
||||
val old = size
|
||||
setSize(w, Int.MAX_VALUE)
|
||||
val ps = super.getPreferredSize()
|
||||
setSize(old)
|
||||
return Dimension(w, ps.height)
|
||||
}
|
||||
|
||||
private fun availableWidth(): Int {
|
||||
var node = parent
|
||||
while (node != null) {
|
||||
if (node.width > 0) {
|
||||
val ins = node.insets
|
||||
return (node.width - ins.left - ins.right).coerceAtLeast(0)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return width
|
||||
}
|
||||
}.apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
isFocusable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = color
|
||||
border = JBUI.Borders.empty()
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
tracked.add(area to bold)
|
||||
applyFont(area, bold)
|
||||
return area
|
||||
}
|
||||
|
||||
private fun applyFont(area: JBTextArea, bold: Boolean) {
|
||||
val font = if (bold) style.headerFont else style.hintFont
|
||||
if (area.font != font) area.font = font
|
||||
}
|
||||
|
||||
private fun makeButton(text: String, primary: Boolean): JButton {
|
||||
val btn = object : JButton(text) {
|
||||
init {
|
||||
if (primary) putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
|
||||
syncBackground()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
syncBackground()
|
||||
}
|
||||
|
||||
private fun syncBackground() {
|
||||
background = SessionUiStyle.View.surface()
|
||||
}
|
||||
}
|
||||
return btn
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
+4
-4
@@ -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) {}
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package ai.kilocode.client.session.views.permission
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.ColorUtil
|
||||
import com.intellij.ui.components.JBHtmlPane
|
||||
import com.intellij.ui.components.JBHtmlPaneConfiguration
|
||||
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.FlowLayout
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.text.html.StyleSheet
|
||||
|
||||
/**
|
||||
* Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
|
||||
* at the end of the transcript when the session is in
|
||||
* [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
|
||||
*
|
||||
* Shows a compact row with action label and target as an inline code fragment, plus diff badges.
|
||||
*/
|
||||
class PermissionView(
|
||||
private val reply: (String, PermissionReplyDto) -> Unit,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
|
||||
override val sessionViewKind = SessionView.Kind.Default
|
||||
|
||||
private var requestId: String? = null
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
private val card = BaseQuestionView()
|
||||
|
||||
private val body = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
|
||||
// Track target panes for style updates
|
||||
private val panes = mutableListOf<JBHtmlPane>()
|
||||
private val diffViews = mutableListOf<PermissionDiffView>()
|
||||
|
||||
private val ID_DENY = "deny"
|
||||
private val ID_RUN = "run"
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
|
||||
card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
|
||||
card.setContent(body)
|
||||
card.setActions(listOf(
|
||||
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") },
|
||||
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") },
|
||||
))
|
||||
addToCenter(card)
|
||||
}
|
||||
|
||||
/** Populate the view for [permission] and make it visible. */
|
||||
fun show(permission: Permission) {
|
||||
requestId = permission.id
|
||||
|
||||
card.setHeader(KiloBundle.message("session.permission.title"))
|
||||
|
||||
body.removeAll()
|
||||
panes.clear()
|
||||
diffViews.clear()
|
||||
|
||||
val tool = permission.name
|
||||
val cmd = permission.meta.command
|
||||
|
||||
val action = toolLabel(tool)
|
||||
val target = cmd ?: resolveTarget(permission)
|
||||
addDetailRow(action, target, permission.meta.fileDiffs)
|
||||
addStateMessage(permission)
|
||||
|
||||
val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
|
||||
card.setActionEnabled(ID_RUN, !responding)
|
||||
card.setActionEnabled(ID_DENY, !responding)
|
||||
|
||||
isVisible = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Hide this view and clear the active request id. */
|
||||
fun hideView() {
|
||||
requestId = null
|
||||
body.removeAll()
|
||||
panes.clear()
|
||||
diffViews.clear()
|
||||
isVisible = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
card.applyStyle(style)
|
||||
for (pane in panes) {
|
||||
applyTargetPane(pane)
|
||||
}
|
||||
for (dv in diffViews) {
|
||||
dv.applyStyle(style)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a three-column permission detail row: tool, target, and changes. */
|
||||
private fun addDetailRow(action: String, target: String?, diffs: List<PermissionFileDiff>) {
|
||||
val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply {
|
||||
isOpaque = false
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
|
||||
val actionLbl = JBLabel(action).apply {
|
||||
font = UiStyle.Fonts.bold()
|
||||
}
|
||||
row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST)
|
||||
|
||||
if (!target.isNullOrBlank()) {
|
||||
val pane = targetPane(target)
|
||||
panes.add(pane)
|
||||
row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
if (diffs.isNotEmpty()) {
|
||||
val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
for (diff in diffs) {
|
||||
val dv = PermissionDiffView(diff)
|
||||
diffViews.add(dv)
|
||||
changes.add(dv)
|
||||
}
|
||||
row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
|
||||
}
|
||||
|
||||
body.add(row)
|
||||
}
|
||||
|
||||
private fun targetPane(text: String) = JBHtmlPane(
|
||||
JBHtmlPaneStyleConfiguration {},
|
||||
JBHtmlPaneConfiguration {
|
||||
customStyleSheetProvider { targetSheet() }
|
||||
},
|
||||
).apply {
|
||||
isEditable = false
|
||||
isOpaque = true
|
||||
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
|
||||
applyTargetPane(this)
|
||||
}
|
||||
|
||||
private fun applyTargetPane(pane: JBHtmlPane) {
|
||||
pane.font = style.transcriptFont
|
||||
pane.foreground = style.editorForeground
|
||||
pane.background = SessionUiStyle.View.headerHover()
|
||||
pane.reloadCssStylesheets()
|
||||
}
|
||||
|
||||
private fun targetSheet(): StyleSheet {
|
||||
val sheet = StyleSheet()
|
||||
val font = style.transcriptFont
|
||||
val fg = ColorUtil.toHtmlColor(style.editorForeground)
|
||||
val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover())
|
||||
val family = font.name.replace("\\", "\\\\").replace("'", "\\'")
|
||||
sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }")
|
||||
sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }")
|
||||
return sheet
|
||||
}
|
||||
|
||||
private fun resolveTarget(permission: Permission): String? {
|
||||
val path = permission.meta.filePath
|
||||
if (!path.isNullOrBlank()) return path
|
||||
|
||||
val filtered = permission.patterns.filter { it != "*" }
|
||||
return when {
|
||||
filtered.size == 1 -> filtered[0]
|
||||
filtered.size > 1 -> filtered.joinToString(", ")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun addStateMessage(permission: Permission) {
|
||||
val msg = when (permission.state) {
|
||||
PermissionRequestState.ERROR ->
|
||||
permission.message ?: KiloBundle.message("session.permission.error")
|
||||
PermissionRequestState.RESPONDING ->
|
||||
KiloBundle.message("session.permission.responding")
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
val label = JBLabel(msg).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
body.add(label)
|
||||
}
|
||||
|
||||
private fun toolLabel(tool: String): String = when (tool) {
|
||||
"read" -> KiloBundle.message("session.permission.tool.read")
|
||||
"edit" -> KiloBundle.message("session.permission.tool.edit")
|
||||
"write" -> KiloBundle.message("session.permission.tool.write")
|
||||
"patch" -> KiloBundle.message("session.permission.tool.patch")
|
||||
"multiedit" -> KiloBundle.message("session.permission.tool.multiedit")
|
||||
"glob" -> KiloBundle.message("session.permission.tool.glob")
|
||||
"grep" -> KiloBundle.message("session.permission.tool.grep")
|
||||
"list" -> KiloBundle.message("session.permission.tool.list")
|
||||
"bash" -> KiloBundle.message("session.permission.tool.bash")
|
||||
"external_directory" -> KiloBundle.message("session.permission.tool.external_directory")
|
||||
"webfetch" -> KiloBundle.message("session.permission.tool.webfetch")
|
||||
"websearch" -> KiloBundle.message("session.permission.tool.websearch")
|
||||
"codesearch" -> KiloBundle.message("session.permission.tool.codesearch")
|
||||
"todoread" -> KiloBundle.message("session.permission.tool.todoread")
|
||||
"todowrite" -> KiloBundle.message("session.permission.tool.todowrite")
|
||||
"task" -> KiloBundle.message("session.permission.tool.task")
|
||||
"skill" -> KiloBundle.message("session.permission.tool.skill")
|
||||
"lsp" -> KiloBundle.message("session.permission.tool.lsp")
|
||||
else -> tool
|
||||
}
|
||||
|
||||
private fun decide(value: String) {
|
||||
val id = requestId ?: return
|
||||
card.setActionEnabled(ID_RUN, false)
|
||||
card.setActionEnabled(ID_DENY, false)
|
||||
reply(id, PermissionReplyDto(reply = value))
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!!
|
||||
internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!!
|
||||
internal fun codeLabelsForTest() = panes.toList()
|
||||
internal fun diffViewsForTest() = diffViews.toList()
|
||||
internal fun headerFontForTest() = card.headerFont()
|
||||
}
|
||||
+8
-3
@@ -5,7 +5,7 @@ import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.PartView
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.session.views.ToolView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
@@ -108,7 +108,9 @@ class QuestionResultView(tool: Tool) : PartView() {
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
val label = setFont(title, style.boldEditorFont) || setFont(sub, style.smallEditorFont)
|
||||
val t = setFont(title, style.boldFont)
|
||||
val s = setFont(sub, style.smallFont)
|
||||
val label = t || s
|
||||
val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
|
||||
if (!label && !body) return
|
||||
refresh()
|
||||
@@ -137,6 +139,9 @@ class QuestionResultView(tool: Tool) : PartView() {
|
||||
|
||||
fun bodyFonts(): List<Font> = texts.map { it.first.font }
|
||||
|
||||
fun titleFont(): Font = title.font
|
||||
fun subFont(): Font = sub.font
|
||||
|
||||
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
|
||||
|
||||
companion object {
|
||||
@@ -268,7 +273,7 @@ class QuestionResultView(tool: Tool) : PartView() {
|
||||
}
|
||||
|
||||
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
|
||||
val font = if (bold) style.boldEditorFont else style.transcriptFont
|
||||
val font = if (bold) style.boldFont else style.regularFont
|
||||
if (area.font == font) return false
|
||||
area.font = font
|
||||
return true
|
||||
|
||||
+354
-71
@@ -5,16 +5,15 @@ import ai.kilocode.client.session.model.Question
|
||||
import ai.kilocode.client.session.model.QuestionItem
|
||||
import ai.kilocode.client.session.model.QuestionOption
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
|
||||
import ai.kilocode.client.session.ui.shared.SessionQuestionButton
|
||||
import ai.kilocode.client.session.ui.shared.applyButton
|
||||
import ai.kilocode.client.session.ui.shared.dismissButton
|
||||
import ai.kilocode.client.session.ui.editor.SessionEditorTextField
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
@@ -22,10 +21,14 @@ import com.intellij.ui.components.JBRadioButton
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.AbstractButton
|
||||
@@ -33,9 +36,12 @@ import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.ButtonGroup
|
||||
import javax.swing.JPanel
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
|
||||
/** Question tool form rendered inside the session transcript. */
|
||||
class QuestionView(
|
||||
private val project: Project,
|
||||
private val reply: (String, QuestionReplyDto) -> Unit,
|
||||
private val reject: (String) -> Unit,
|
||||
private val scroll: () -> Unit = {},
|
||||
@@ -46,10 +52,17 @@ class QuestionView(
|
||||
private var question: Question? = null
|
||||
private var idx = 0
|
||||
private var selections = emptyList<MutableSet<String>>()
|
||||
// Per-question custom text state — survives navigation.
|
||||
private var customTexts = emptyList<String>()
|
||||
// Per-question: whether the custom row is currently selected/open.
|
||||
private var customOpen = emptyList<Boolean>()
|
||||
private var style = SessionEditorStyle.current()
|
||||
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
|
||||
// The custom editor for the currently shown question; null when not shown.
|
||||
private var customEditor: SessionEditorTextField? = null
|
||||
private var customFocus: FocusAdapter? = null
|
||||
|
||||
private val card = BaseSessionQuestionPanel()
|
||||
private val card = BaseQuestionView()
|
||||
|
||||
private val summary = JBLabel()
|
||||
private val nav = JPanel().apply {
|
||||
@@ -80,16 +93,11 @@ class QuestionView(
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
private val footer = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
private val dismiss = dismissButton(KiloBundle.message("session.question.dismiss")) { doReject() }
|
||||
private val right = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.X_AXIS)
|
||||
}
|
||||
|
||||
// Stable action ids for setActionEnabled calls
|
||||
private val ID_DISMISS = "dismiss"
|
||||
private val ID_BACK = "back"
|
||||
private val ID_MAIN = "main" // next / review / submit
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
@@ -99,12 +107,9 @@ class QuestionView(
|
||||
nav.add(fwd)
|
||||
topPanel.add(summary, BorderLayout.WEST)
|
||||
topPanel.add(nav, BorderLayout.EAST)
|
||||
footer.add(dismiss, BorderLayout.WEST)
|
||||
footer.add(right, BorderLayout.EAST)
|
||||
|
||||
card.setTopPanel(topPanel)
|
||||
card.setBody(body)
|
||||
card.setFooter(footer)
|
||||
card.setContent(body)
|
||||
add(card, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@@ -117,6 +122,8 @@ class QuestionView(
|
||||
question = q
|
||||
idx = 0
|
||||
selections = List(q.items.size) { mutableSetOf() }
|
||||
customTexts = List(q.items.size) { "" }
|
||||
customOpen = List(q.items.size) { false }
|
||||
isVisible = true
|
||||
syncPage()
|
||||
}
|
||||
@@ -126,9 +133,13 @@ class QuestionView(
|
||||
question = null
|
||||
idx = 0
|
||||
selections = emptyList()
|
||||
customTexts = emptyList()
|
||||
customOpen = emptyList()
|
||||
customEditor = null
|
||||
customFocus = null
|
||||
texts.clear()
|
||||
body.removeAll()
|
||||
right.removeAll()
|
||||
card.setActions(emptyList())
|
||||
isVisible = false
|
||||
refresh()
|
||||
}
|
||||
@@ -136,6 +147,11 @@ class QuestionView(
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
card.applyStyle(style)
|
||||
customEditor?.let { ed ->
|
||||
ed.font = style.transcriptFont
|
||||
ed.getEditor(false)?.let(style::applyToEditor)
|
||||
ed.background = style.editorScheme.defaultBackground
|
||||
}
|
||||
val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
|
||||
if (!changed) return
|
||||
refresh()
|
||||
@@ -144,21 +160,18 @@ class QuestionView(
|
||||
private fun syncPage() {
|
||||
val q = question ?: return
|
||||
texts.clear()
|
||||
customEditor = null
|
||||
customFocus = null
|
||||
body.removeAll()
|
||||
if (review(q)) {
|
||||
card.headerText.text = KiloBundle.message("session.question.review.title")
|
||||
card.descriptionText.text = ""
|
||||
card.descriptionText.isVisible = false
|
||||
card.setHeader(KiloBundle.message("session.question.review.title"))
|
||||
addReview(q)
|
||||
} else {
|
||||
val item = q.items[idx]
|
||||
card.headerText.text = item.question
|
||||
card.headerText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
|
||||
card.descriptionText.text = KiloBundle.message(
|
||||
val hint = KiloBundle.message(
|
||||
if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"
|
||||
)
|
||||
card.descriptionText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
card.descriptionText.isVisible = true
|
||||
card.setHeader(item.question, hint)
|
||||
addContent(item, selections[idx])
|
||||
}
|
||||
syncHeader(q)
|
||||
@@ -176,42 +189,69 @@ class QuestionView(
|
||||
}
|
||||
|
||||
private fun syncFooter(q: Question) {
|
||||
right.removeAll()
|
||||
if (review(q)) {
|
||||
val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() }
|
||||
val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() }
|
||||
right.add(back)
|
||||
right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
|
||||
right.add(submit)
|
||||
return
|
||||
}
|
||||
val actions = mutableListOf<BaseQuestionView.Action>()
|
||||
actions.add(BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.question.dismiss"), primary = false) { doReject() })
|
||||
|
||||
val label = when {
|
||||
direct(q) -> KiloBundle.message("session.question.submit")
|
||||
lastItem(q) -> KiloBundle.message("session.question.review")
|
||||
else -> KiloBundle.message("session.question.next")
|
||||
}
|
||||
val isPrimary = direct(q) || lastItem(q)
|
||||
val button = SessionQuestionButton(label, isPrimary).apply {
|
||||
addActionListener {
|
||||
if (review(q)) {
|
||||
actions.add(BaseQuestionView.Action(ID_BACK, KiloBundle.message("session.question.back"), primary = false) { goBack() })
|
||||
actions.add(BaseQuestionView.Action(ID_MAIN, KiloBundle.message("session.question.submit"), primary = true) { doReply() })
|
||||
} else {
|
||||
val label = when {
|
||||
direct(q) -> KiloBundle.message("session.question.submit")
|
||||
lastItem(q) -> KiloBundle.message("session.question.review")
|
||||
else -> KiloBundle.message("session.question.next")
|
||||
}
|
||||
val isPrimary = direct(q) || lastItem(q)
|
||||
actions.add(BaseQuestionView.Action(ID_MAIN, label, isPrimary) {
|
||||
when {
|
||||
direct(q) -> doReply()
|
||||
lastItem(q) -> goReview()
|
||||
else -> goForward()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
right.add(button)
|
||||
card.setActions(actions)
|
||||
}
|
||||
|
||||
private fun syncControls(q: Question) {
|
||||
val ready = selections.getOrNull(idx)?.isNotEmpty() == true
|
||||
val ready = isReady(idx)
|
||||
back.isEnabled = idx > 0
|
||||
fwd.isEnabled = idx < q.items.size && ready
|
||||
for (node in right.components) {
|
||||
if (node is SessionQuestionButton && node.text != KiloBundle.message("session.question.back")) {
|
||||
node.isEnabled = review(q) || ready
|
||||
}
|
||||
card.setActionEnabled(ID_MAIN, review(q) || ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes whether the question at [i] has an effective (non-blank) answer.
|
||||
* For a question with custom=true and custom row selected, the custom text
|
||||
* must be non-blank. For option-only answers the selection set must be non-empty.
|
||||
*/
|
||||
private fun isReady(i: Int): Boolean {
|
||||
val open = customOpen.getOrElse(i) { false }
|
||||
val txt = customTexts.getOrElse(i) { "" }.trim()
|
||||
val sel = selections.getOrNull(i)
|
||||
return if (open) txt.isNotEmpty() else sel?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective answers for question at index [i] — what will be sent
|
||||
* in the reply payload. Custom text is included when non-blank and the custom
|
||||
* row is selected (single-select) or active (multi-select).
|
||||
*/
|
||||
private fun effectiveAnswers(i: Int): List<String> {
|
||||
val q = question ?: return emptyList()
|
||||
val item = q.items.getOrNull(i) ?: return emptyList()
|
||||
val txt = customTexts.getOrElse(i) { "" }.trim()
|
||||
val open = customOpen.getOrElse(i) { false }
|
||||
val sel = selections.getOrNull(i) ?: emptySet()
|
||||
|
||||
return if (item.multiple) {
|
||||
val result = sel.toMutableList()
|
||||
if (open && txt.isNotEmpty() && txt !in result) result.add(txt)
|
||||
result
|
||||
} else {
|
||||
// single-select: if custom is open, use custom text; otherwise use selection
|
||||
if (open && txt.isNotEmpty()) listOf(txt)
|
||||
else sel.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +267,8 @@ class QuestionView(
|
||||
row.alignmentX = Component.LEFT_ALIGNMENT
|
||||
body.add(row)
|
||||
}
|
||||
// Remove bottom padding on the last review row to match the top gap.
|
||||
(body.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
|
||||
}
|
||||
|
||||
private fun reviewRow(item: QuestionItem, i: Int): JPanel {
|
||||
@@ -235,11 +277,12 @@ class QuestionView(
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
}
|
||||
val question = text(item.question, UiStyle.Colors.weak())
|
||||
question.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(question)
|
||||
val qText = text(item.question, UiStyle.Colors.weak())
|
||||
qText.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(qText)
|
||||
|
||||
val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty()
|
||||
val answers = effectiveAnswers(i)
|
||||
val joined = answers.joinToString(", ")
|
||||
val answer = text(
|
||||
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
|
||||
UiStyle.Colors.fg(),
|
||||
@@ -257,13 +300,245 @@ class QuestionView(
|
||||
}
|
||||
if (item.multiple) {
|
||||
for (opt in item.options) panel.add(checkboxRow(opt, set))
|
||||
return panel
|
||||
} else {
|
||||
val group = ButtonGroup()
|
||||
for (opt in item.options) panel.add(radioRow(opt, set, group))
|
||||
}
|
||||
|
||||
if (item.custom) {
|
||||
panel.add(customRow(item, set))
|
||||
} else {
|
||||
// Remove bottom padding on the last option so the gap before the action
|
||||
// footer matches the gap above the options (both use Gap.lg).
|
||||
(panel.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
|
||||
}
|
||||
val group = ButtonGroup()
|
||||
for (opt in item.options) panel.add(radioRow(opt, set, group))
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun customRow(item: QuestionItem, set: MutableSet<String>): JPanel {
|
||||
val open = customOpen.getOrElse(idx) { false }
|
||||
val existing = customTexts.getOrElse(idx) { "" }.trim()
|
||||
val showEditor = open || existing.isNotEmpty()
|
||||
val row = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
// No bottom padding — it's the last row
|
||||
border = JBUI.Borders.empty()
|
||||
}
|
||||
|
||||
val toggle: AbstractButton = if (item.multiple) {
|
||||
JBCheckBox().apply {
|
||||
actionCommand = ""
|
||||
isSelected = open
|
||||
isOpaque = false
|
||||
}
|
||||
} else {
|
||||
JBRadioButton().apply {
|
||||
actionCommand = ""
|
||||
isSelected = open
|
||||
isOpaque = false
|
||||
}
|
||||
}
|
||||
|
||||
val toggleListener = {
|
||||
val wasOpen = customOpen.getOrElse(idx) { false }
|
||||
if (!wasOpen) {
|
||||
// Opening custom row
|
||||
if (!item.multiple) {
|
||||
// Single-select: clear option selection
|
||||
set.clear()
|
||||
}
|
||||
customOpen = customOpen.toMutableList().also { it[idx] = true }
|
||||
} else {
|
||||
// Closing custom row
|
||||
customOpen = customOpen.toMutableList().also { it[idx] = false }
|
||||
}
|
||||
refreshCustomRow()
|
||||
}
|
||||
|
||||
if (item.multiple) {
|
||||
(toggle as JBCheckBox).addActionListener { toggleListener() }
|
||||
} else {
|
||||
(toggle as JBRadioButton).addActionListener {
|
||||
// When the custom radio is selected, deselect any option radio
|
||||
set.clear()
|
||||
customOpen = customOpen.toMutableList().also { it[idx] = true }
|
||||
refreshCustomRow()
|
||||
}
|
||||
}
|
||||
|
||||
val press = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (toggle.isEnabled) toggle.doClick()
|
||||
}
|
||||
}
|
||||
|
||||
val icon = JPanel(GridBagLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
|
||||
add(toggle)
|
||||
addMouseListener(press)
|
||||
}
|
||||
|
||||
val col = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = GridBagLayout()
|
||||
addMouseListener(press)
|
||||
}
|
||||
|
||||
val label = text(KiloBundle.message("session.question.custom.label"), UiStyle.Colors.fg(), true)
|
||||
label.alignmentX = Component.LEFT_ALIGNMENT
|
||||
label.addMouseListener(press)
|
||||
col.add(label)
|
||||
|
||||
val header = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
toolTipText = null
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
header.addMouseListener(press)
|
||||
header.add(icon, BorderLayout.WEST)
|
||||
header.add(col, BorderLayout.CENTER)
|
||||
row.add(header)
|
||||
|
||||
if (showEditor) {
|
||||
val ed = buildCustomEditor()
|
||||
customEditor = ed
|
||||
val focus = object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) = selectCustom(item, set)
|
||||
}
|
||||
customFocus = focus
|
||||
ed.addFocusListener(focus)
|
||||
ed.addSettingsProvider { ex ->
|
||||
ex.contentComponent.addFocusListener(focus)
|
||||
ex.component.addFocusListener(focus)
|
||||
}
|
||||
val edWrapper = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.lg() + JBUI.scale(20), UiStyle.Gap.lg(), 0)
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
add(ed, BorderLayout.CENTER)
|
||||
}
|
||||
row.add(edWrapper)
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
internal fun testFocusCustomEditor() {
|
||||
val ed = customEditor ?: return
|
||||
val focus = customFocus ?: return
|
||||
focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED))
|
||||
}
|
||||
|
||||
private fun selectCustom(item: QuestionItem, set: MutableSet<String>) {
|
||||
if (customOpen.getOrElse(idx) { false }) return
|
||||
if (!item.multiple) set.clear()
|
||||
customOpen = customOpen.toMutableList().also { it[idx] = true }
|
||||
refreshCustomRow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and wires a custom-answer [SessionEditorTextField].
|
||||
*
|
||||
* The component is created on the EDT (as required for all Swing components).
|
||||
* [SessionEditorTextField] extends [com.intellij.ui.EditorTextField] which
|
||||
* lazily initialises its IntelliJ editor via [com.intellij.openapi.editor.EditorThreading]
|
||||
* the first time the component becomes visible, satisfying the platform's
|
||||
* read-context requirement without any additional wrapping here.
|
||||
*/
|
||||
private fun buildCustomEditor(): SessionEditorTextField {
|
||||
val ed = SessionEditorTextField(project)
|
||||
ed.border = JBUI.Borders.empty()
|
||||
ed.setFontInheritedFromLAF(false)
|
||||
ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder"))
|
||||
ed.setShowPlaceholderWhenFocused(true)
|
||||
ed.setOneLineMode(false)
|
||||
ed.addSettingsProvider { ex ->
|
||||
style.applyToEditor(ex)
|
||||
ex.setBorder(JBUI.Borders.empty())
|
||||
ex.scrollPane.border = JBUI.Borders.empty()
|
||||
ex.scrollPane.viewportBorder = JBUI.Borders.empty()
|
||||
ex.backgroundColor = style.editorScheme.defaultBackground
|
||||
ex.scrollPane.background = style.editorScheme.defaultBackground
|
||||
ex.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
ex.settings.isUseSoftWraps = true
|
||||
ex.settings.isAdditionalPageAtBottom = false
|
||||
ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}
|
||||
ed.font = style.transcriptFont
|
||||
ed.background = style.editorScheme.defaultBackground
|
||||
|
||||
// Pre-fill with saved text. This call also forces lazy document creation so
|
||||
// that addDocumentListener can install on a non-null document immediately.
|
||||
val saved = customTexts.getOrElse(idx) { "" }
|
||||
ed.text = saved
|
||||
|
||||
// Sync preferred height to line count; update stored text on edits.
|
||||
// EditorTextField.addDocumentListener is the preferred (non-deprecated) API.
|
||||
// The document was already created above (ed.text = saved ensures getDocument()
|
||||
// was called), so installDocumentListener succeeds.
|
||||
ed.addDocumentListener(object : DocumentListener {
|
||||
override fun documentChanged(e: DocumentEvent) {
|
||||
val txt = ed.text
|
||||
customTexts = customTexts.toMutableList().also { it[idx] = txt }
|
||||
syncEditorHeight(ed)
|
||||
question?.let(::syncControls)
|
||||
refresh()
|
||||
scroll()
|
||||
}
|
||||
})
|
||||
|
||||
syncEditorHeight(ed)
|
||||
return ed
|
||||
}
|
||||
|
||||
private fun syncEditorHeight(ed: SessionEditorTextField) {
|
||||
val editor = ed.getEditor(false)
|
||||
val estimated = estimatedLines(ed)
|
||||
val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated)
|
||||
val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height
|
||||
val height = line * lines.coerceAtLeast(1) + JBUI.scale(16)
|
||||
ed.preferredSize = Dimension(0, height)
|
||||
ed.minimumSize = Dimension(0, height)
|
||||
}
|
||||
|
||||
private fun estimatedLines(ed: SessionEditorTextField): Int {
|
||||
val width = space(ed)
|
||||
if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1)
|
||||
val metrics = ed.getFontMetrics(ed.font)
|
||||
val columns = (width / metrics.charWidth('m').coerceAtLeast(1)).coerceAtLeast(1)
|
||||
return ed.text.lineSequence().sumOf { line ->
|
||||
((line.length + columns - 1) / columns).coerceAtLeast(1)
|
||||
}.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun space(component: Component): Int {
|
||||
if (component.width > 0) return component.width
|
||||
var node = component.parent
|
||||
while (node != null) {
|
||||
if (node.width > 0) {
|
||||
val ins = node.insets
|
||||
return (node.width - ins.left - ins.right).coerceAtLeast(0)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Re-syncs the current page after the custom row toggle changes. */
|
||||
private fun refreshCustomRow() {
|
||||
val q = question ?: return
|
||||
syncPage()
|
||||
// Request focus on the editor when opening
|
||||
if (customOpen.getOrElse(idx) { false }) {
|
||||
customEditor?.requestFocusInWindow()
|
||||
}
|
||||
syncControls(q)
|
||||
scroll()
|
||||
}
|
||||
|
||||
private fun radioRow(opt: QuestionOption, set: MutableSet<String>, group: ButtonGroup): JPanel {
|
||||
val radio = JBRadioButton().apply {
|
||||
actionCommand = opt.label
|
||||
@@ -274,7 +549,13 @@ class QuestionView(
|
||||
radio.addActionListener {
|
||||
set.clear()
|
||||
set.add(opt.label)
|
||||
refreshSelection()
|
||||
// Selecting a normal option closes the custom row
|
||||
customOpen = customOpen.toMutableList().also { it[idx] = false }
|
||||
if (customEditor == null) {
|
||||
refreshSelection()
|
||||
return@addActionListener
|
||||
}
|
||||
refreshCustomRow()
|
||||
}
|
||||
return optionRow(radio, opt)
|
||||
}
|
||||
@@ -304,15 +585,16 @@ class QuestionView(
|
||||
if (toggle.isEnabled) toggle.doClick()
|
||||
}
|
||||
}
|
||||
val icon = JPanel(BorderLayout()).apply {
|
||||
val center = opt.description.isBlank()
|
||||
val icon = JPanel(if (center) GridBagLayout() else BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
|
||||
add(toggle, BorderLayout.NORTH)
|
||||
if (center) add(toggle) else add(toggle, BorderLayout.NORTH)
|
||||
addMouseListener(press)
|
||||
}
|
||||
val col = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
layout = if (center) GridBagLayout() else BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
addMouseListener(press)
|
||||
}
|
||||
val label = text(opt.label, UiStyle.Colors.fg(), true)
|
||||
@@ -396,12 +678,12 @@ class QuestionView(
|
||||
|
||||
private fun goForward() {
|
||||
val q = question ?: return
|
||||
if (idx >= q.items.size || selections.getOrNull(idx)?.isEmpty() != false) return
|
||||
val review = idx == q.items.size - 1 && !direct(q)
|
||||
if (review) {
|
||||
if (idx >= q.items.size || !isReady(idx)) return
|
||||
val toReview = idx == q.items.size - 1 && !direct(q)
|
||||
if (toReview) {
|
||||
goReview()
|
||||
}
|
||||
if (!review) {
|
||||
if (!toReview) {
|
||||
idx++
|
||||
syncPage()
|
||||
scroll()
|
||||
@@ -410,7 +692,7 @@ class QuestionView(
|
||||
|
||||
private fun goReview() {
|
||||
val q = question ?: return
|
||||
if (idx == q.items.size - 1 && selections[idx].isNotEmpty()) {
|
||||
if (idx == q.items.size - 1 && isReady(idx)) {
|
||||
idx = q.items.size
|
||||
syncPage()
|
||||
scroll()
|
||||
@@ -425,8 +707,9 @@ class QuestionView(
|
||||
|
||||
private fun doReply() {
|
||||
val id = request ?: return
|
||||
if (selections.any { it.isEmpty() }) return
|
||||
reply(id, QuestionReplyDto(selections.map { it.toList() }))
|
||||
if ((question?.items?.indices ?: return).any { !isReady(it) }) return
|
||||
val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) }
|
||||
reply(id, QuestionReplyDto(answers))
|
||||
hideView()
|
||||
}
|
||||
|
||||
@@ -437,7 +720,7 @@ class QuestionView(
|
||||
}
|
||||
|
||||
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
|
||||
val font = if (bold) style.boldEditorFont else style.transcriptFont
|
||||
val font = if (bold) style.boldFont else style.regularFont
|
||||
if (area.font == font) return false
|
||||
area.font = font
|
||||
return true
|
||||
|
||||
-38
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal class DiffStatBadge(
|
||||
additions: Int,
|
||||
deletions: Int,
|
||||
) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) {
|
||||
private val removed = JBLabel("-$deletions").apply {
|
||||
foreground = removedColor()
|
||||
font = JBFont.small()
|
||||
}
|
||||
private val added = JBLabel("+$additions").apply {
|
||||
foreground = addedColor()
|
||||
font = JBFont.small()
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
add(removed)
|
||||
add(added)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = backgroundColor()
|
||||
g2.fillRoundRect(0, 0, width, height, height, height)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
internal fun removedLabelForTest() = removed
|
||||
|
||||
internal fun addedLabelForTest() = added
|
||||
}
|
||||
|
||||
private fun backgroundColor(): Color = JBColor.namedColor(
|
||||
"Kilo.DiffStat.background",
|
||||
JBColor(Color(0x26, 0x26, 0x26), Color(0x26, 0x26, 0x26)),
|
||||
)
|
||||
|
||||
private fun removedColor(): Color = JBColor.namedColor(
|
||||
"Kilo.DiffStat.removedForeground",
|
||||
JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)),
|
||||
)
|
||||
|
||||
private fun addedColor(): Color = JBColor.namedColor(
|
||||
"Kilo.DiffStat.addedForeground",
|
||||
JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)),
|
||||
)
|
||||
@@ -130,6 +130,21 @@ object UiStyle {
|
||||
|
||||
/** Prominent short content, e.g. device auth code. Maps to [JBFont.h2] bold. */
|
||||
fun large(): JBFont = JBFont.h2().asBold()
|
||||
|
||||
/** Card/question header font — bold at heading level 4. */
|
||||
fun header(): JBFont = JBFont.h4().asBold()
|
||||
|
||||
/** Hint or description font — plain regular size. */
|
||||
fun hint(): JBFont = JBFont.regular()
|
||||
|
||||
/** Standard body/label text. */
|
||||
fun regular(): JBFont = JBFont.regular()
|
||||
|
||||
/** Bold body/label text. */
|
||||
fun bold(): JBFont = JBFont.regular().asBold()
|
||||
|
||||
/** Small secondary text, e.g. metadata labels. */
|
||||
fun small(): JBFont = JBFont.small()
|
||||
}
|
||||
|
||||
/** Small component helpers that keep repeated Swing setup in one place. */
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package ai.kilocode.client.ui.layout
|
||||
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JPanel
|
||||
|
||||
enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT }
|
||||
enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM }
|
||||
|
||||
/**
|
||||
* A transparent wrapper panel that positions its single child according to independent
|
||||
* horizontal ([h]) and vertical ([v]) alignment modes.
|
||||
*
|
||||
* **TRACK**: child fills all available space on that axis, ignoring child min/preferred/max.
|
||||
* The wrapper reports zero contribution from the child on that axis for its own min/preferred/max.
|
||||
*
|
||||
* **FIT**: child fills available space clamped to child's effective [min, max] range.
|
||||
*
|
||||
* **LEFT / CENTER / RIGHT** (horizontal) and **TOP / CENTER / BOTTOM** (vertical):
|
||||
* child uses its bounded preferred size (coerced into [min, max]) and is placed at the
|
||||
* corresponding edge or centered. Shrinks to available space when necessary.
|
||||
*
|
||||
* Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution
|
||||
* (zero for TRACK axes) with the panel insets.
|
||||
*
|
||||
* Use the factory extension for concise call sites:
|
||||
* ```
|
||||
* label.align(HAlign.CENTER, VAlign.CENTER)
|
||||
* button.align(HAlign.RIGHT, VAlign.CENTER)
|
||||
* panel.align(HAlign.LEFT, VAlign.TOP)
|
||||
* scrollable.align(HAlign.TRACK, VAlign.TOP)
|
||||
* ```
|
||||
*/
|
||||
class Align(
|
||||
child: Component,
|
||||
private val h: HAlign = HAlign.FIT,
|
||||
private val v: VAlign = VAlign.FIT,
|
||||
) : JPanel(null) {
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
add(child)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun doLayout() {
|
||||
if (componentCount == 0) return
|
||||
val child = getComponent(0)
|
||||
val ins = insets
|
||||
val availW = maxOf(0, width - ins.left - ins.right)
|
||||
val availH = maxOf(0, height - ins.top - ins.bottom)
|
||||
|
||||
val (w, cx) = placeAxis(h, availW, child.minimumSize.width, child.preferredSize.width, child.maximumSize.width)
|
||||
val (ht, cy) = placeAxis(v, availH, child.minimumSize.height, child.preferredSize.height, child.maximumSize.height)
|
||||
|
||||
child.setBounds(ins.left + cx, ins.top + cy, w, ht)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wrapper size negotiation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun getMinimumSize(): Dimension {
|
||||
if (componentCount == 0) return super.getMinimumSize()
|
||||
val child = getComponent(0)
|
||||
val ins = insets
|
||||
val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width
|
||||
val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height
|
||||
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
if (componentCount == 0) return super.getPreferredSize()
|
||||
val child = getComponent(0)
|
||||
val ins = insets
|
||||
val cw = if (h == HAlign.TRACK) 0 else bounded(child.preferredSize.width, child.minimumSize.width, child.maximumSize.width)
|
||||
val ch = if (v == VAlign.TRACK) 0 else bounded(child.preferredSize.height, child.minimumSize.height, child.maximumSize.height)
|
||||
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
|
||||
}
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
if (componentCount == 0) return super.getMaximumSize()
|
||||
val child = getComponent(0)
|
||||
val ins = insets
|
||||
val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right
|
||||
val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom
|
||||
return Dimension(cw, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns (size, offset) for a single axis. Offset is relative to the inner origin (after insets).
|
||||
* - TRACK: size = avail, offset = 0
|
||||
* - FIT: size = clamp(avail, min, max), offset = 0
|
||||
* - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment
|
||||
*/
|
||||
private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<Int, Int> {
|
||||
val effMax = maxOf(min, max)
|
||||
return when (mode) {
|
||||
HAlign.TRACK, VAlign.TRACK -> avail to 0
|
||||
HAlign.FIT, VAlign.FIT -> {
|
||||
// fill available, capped at effMax; if avail < min we still shrink to avail
|
||||
val size = minOf(avail, effMax)
|
||||
size to 0
|
||||
}
|
||||
HAlign.LEFT, VAlign.TOP -> {
|
||||
val size = minOf(bounded(pref, min, effMax), avail)
|
||||
size to 0
|
||||
}
|
||||
HAlign.CENTER, VAlign.CENTER -> {
|
||||
val size = minOf(bounded(pref, min, effMax), avail)
|
||||
size to (avail - size) / 2
|
||||
}
|
||||
HAlign.RIGHT, VAlign.BOTTOM -> {
|
||||
val size = minOf(bounded(pref, min, effMax), avail)
|
||||
size to (avail - size)
|
||||
}
|
||||
else -> avail to 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory extension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fun Component.align(h: HAlign, v: VAlign) = Align(this, h, v)
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.ui.md
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ui.components.JBHtmlPane
|
||||
import com.intellij.ui.components.JBHtmlPaneConfiguration
|
||||
@@ -355,7 +356,11 @@ abstract class MdView private constructor() {
|
||||
|
||||
linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") }
|
||||
codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") }
|
||||
preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") }
|
||||
preBgOverride?.let {
|
||||
val color = hex(it)
|
||||
rules.append("div.code-block { background: $color; border-color: $color; padding: ${UiStyle.Gap.xs()}px ${UiStyle.Gap.lg()}px } ")
|
||||
rules.append("pre { background: $color; border-color: $color } ")
|
||||
}
|
||||
preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") }
|
||||
codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") }
|
||||
quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path fill="#AFB1B3" d="M8 1L2 3.5v4C2 11.1 4.7 14.1 8 15c3.3-.9 6-3.9 6-7.5v-4L8 1zm0 6.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 3c-1.7 0-2-.75-2-.75V10c0-.55 1-1 2-1s2 .45 2 1v-.25S9.7 10.5 8 10.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -15,10 +15,37 @@ session.scroll.bottom=Scroll to bottom
|
||||
session.tab.new=New Session
|
||||
session.tab.untitled=Untitled Session
|
||||
|
||||
session.permission.title=Permission request
|
||||
session.permission.title=Permission required
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.meta=Tool: {0} • Patterns: {1}
|
||||
session.permission.run=Run
|
||||
session.permission.allow=Allow
|
||||
session.permission.deny=Deny
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
session.permission.diff=Changes
|
||||
session.permission.diff.summary=+{0} -{1}
|
||||
session.permission.no.details={0} requires permission.
|
||||
session.permission.responding=Sending response...
|
||||
session.permission.error=Failed to send permission response
|
||||
session.permission.tool.read=Read
|
||||
session.permission.tool.edit=Edit
|
||||
session.permission.tool.write=Write
|
||||
session.permission.tool.patch=Patch
|
||||
session.permission.tool.multiedit=Edit
|
||||
session.permission.tool.glob=Glob Search
|
||||
session.permission.tool.grep=Grep Search
|
||||
session.permission.tool.list=List
|
||||
session.permission.tool.bash=Shell
|
||||
session.permission.tool.external_directory=External Directory
|
||||
session.permission.tool.webfetch=Web Fetch
|
||||
session.permission.tool.websearch=Web Search
|
||||
session.permission.tool.codesearch=Code Search
|
||||
session.permission.tool.todoread=Read Todo List
|
||||
session.permission.tool.todowrite=Update Todo List
|
||||
session.permission.tool.task=Task
|
||||
session.permission.tool.skill=Skill
|
||||
session.permission.tool.lsp=Language Server
|
||||
session.question.dismiss=Dismiss
|
||||
session.question.submit=Submit
|
||||
session.question.next=Next
|
||||
@@ -31,6 +58,8 @@ session.question.review.title=Review your answers
|
||||
session.question.review.notAnswered=(not answered)
|
||||
session.question.result.title=Questions
|
||||
session.question.result.answered={0} answered
|
||||
session.question.custom.label=Add your own response
|
||||
session.question.custom.placeholder=Type your response...
|
||||
|
||||
session.status.considering=Considering next steps…
|
||||
session.status.thinking=Thinking…
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+149
@@ -1,8 +1,12 @@
|
||||
package ai.kilocode.client.session.controller
|
||||
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionFileDiffDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.QuestionInfoDto
|
||||
@@ -10,6 +14,7 @@ import ai.kilocode.rpc.dto.QuestionOptionDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
import ai.kilocode.rpc.dto.ToolRefDto
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
|
||||
class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
|
||||
@@ -175,6 +180,150 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
assertEquals("q1", rpc.questionRejects[0].first)
|
||||
}
|
||||
|
||||
fun `test PermissionAsked maps rich fields to meta`() {
|
||||
val (m, _, _) = prompted()
|
||||
val req = PermissionRequestDto(
|
||||
id = "perm_rich",
|
||||
sessionID = "ses_test",
|
||||
permission = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
command = "git diff",
|
||||
fileDiffs = listOf(PermissionFileDiffDto("src/A.kt", patch = "@@ @@", additions = 1, deletions = 0)),
|
||||
)
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", req))
|
||||
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
val perm = (m.model.state as SessionState.AwaitingPermission).permission
|
||||
assertEquals("git diff", perm.meta.command)
|
||||
assertEquals(1, perm.meta.fileDiffs.size)
|
||||
assertEquals("src/A.kt", perm.meta.fileDiffs[0].file)
|
||||
}
|
||||
|
||||
fun `test replyPermission without rules leaves rulesSaved empty`() {
|
||||
val (m, _, _) = prompted()
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
|
||||
edt { m.replyPermission("perm1", PermissionReplyDto("once")) }
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.permissionRulesSaved.isEmpty())
|
||||
assertEquals(1, rpc.permissionReplies.size)
|
||||
}
|
||||
|
||||
// ------ Child session (subagent) permission bubbling ------
|
||||
|
||||
fun `test task part with child sessionId causes controller to track child`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(taskPart("ses_child"), flush = false)
|
||||
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")))
|
||||
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
val perm = (m.model.state as SessionState.AwaitingPermission).permission
|
||||
assertEquals("child_perm1", perm.id)
|
||||
assertEquals("ses_child", perm.sessionId)
|
||||
}
|
||||
|
||||
fun `test child PermissionAsked moves root model to AwaitingPermission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(taskPart("ses_child"), flush = false)
|
||||
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
permission#child_perm1
|
||||
tool: <none>
|
||||
name: edit
|
||||
patterns: *.kt
|
||||
always: <none>
|
||||
file: <none>
|
||||
state: PENDING
|
||||
metadata: <none>
|
||||
|
||||
[code] [kilo/gpt-5] [awaiting-permission]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test child PermissionReplied clears root awaiting permission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(taskPart("ses_child"), flush = false)
|
||||
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")), flush = false)
|
||||
emit(ChatEventDto.PermissionReplied("ses_child", "child_perm1"))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [busy] [considering next steps]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test replyPermission for child request sends correct requestId`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(taskPart("ses_child"), flush = false)
|
||||
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")))
|
||||
|
||||
edt { m.replyPermission("child_perm1", PermissionReplyDto("once")) }
|
||||
flush()
|
||||
|
||||
assertEquals(1, rpc.permissionReplies.size)
|
||||
assertEquals("child_perm1", rpc.permissionReplies[0].first)
|
||||
assertEquals("once", rpc.permissionReplies[0].third.reply)
|
||||
}
|
||||
|
||||
fun `test child non-permission events do not change root state`() {
|
||||
val (m, _, modelEvents) = prompted()
|
||||
val initialState = m.model.state
|
||||
|
||||
// Emit non-permission child events — they must not affect the root
|
||||
emit(ChatEventDto.TurnOpen("ses_child"), flush = false)
|
||||
emit(ChatEventDto.SessionStatusChanged("ses_child", ai.kilocode.rpc.dto.SessionStatusDto("busy")), flush = false)
|
||||
emit(ChatEventDto.SessionIdle("ses_child"))
|
||||
|
||||
assertEquals(initialState, m.model.state)
|
||||
// No extra model state events from child non-permission events
|
||||
val stateEvents = modelEvents.filterIsInstance<ai.kilocode.client.session.model.SessionModelEvent.StateChanged>()
|
||||
assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty())
|
||||
}
|
||||
|
||||
fun `test root permission event is not processed as child permission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
// No task part emitted — root permission should still work
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("root_perm")))
|
||||
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
val perm = (m.model.state as SessionState.AwaitingPermission).permission
|
||||
assertEquals("root_perm", perm.id)
|
||||
}
|
||||
|
||||
private fun taskPart(childSessionId: String) = ChatEventDto.PartUpdated(
|
||||
sessionID = "ses_test",
|
||||
part = PartDto(
|
||||
id = "part_task",
|
||||
sessionID = "ses_test",
|
||||
messageID = "msg1",
|
||||
type = "tool",
|
||||
tool = "task",
|
||||
metadata = mapOf("sessionId" to childSessionId),
|
||||
),
|
||||
)
|
||||
|
||||
private fun childPermission(id: String) = PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = "ses_child",
|
||||
permission = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
)
|
||||
|
||||
private fun permission(id: String) = PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = "ses_test",
|
||||
|
||||
+103
@@ -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(
|
||||
|
||||
+40
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 = { _ -> },
|
||||
)
|
||||
|
||||
+2
-2
@@ -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 ------
|
||||
|
||||
+13
-8
@@ -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()
|
||||
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
package ai.kilocode.client.session.ui.shared
|
||||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import java.awt.Container
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JPanel
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
|
||||
|
||||
// ------ initial state ------
|
||||
|
||||
fun `test headerText and descriptionText are in the component tree by default`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
assertNotNull("headerText should be present", find(panel, panel.headerText))
|
||||
assertNotNull("descriptionText should be present", find(panel, panel.descriptionText))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test header and description have correct initial text`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
assertEquals("", panel.headerText.text)
|
||||
assertEquals("", panel.descriptionText.text)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setTopPanel ------
|
||||
|
||||
fun `test setTopPanel adds component before header`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val top = JLabel("top")
|
||||
panel.setTopPanel(top)
|
||||
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val topIdx = comps.indexOf(top)
|
||||
val headerIdx = comps.indexOf(panel.headerText)
|
||||
assertTrue("top should appear before headerText", topIdx < headerIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setTopPanel null removes top component`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val top = JLabel("top")
|
||||
panel.setTopPanel(top)
|
||||
panel.setTopPanel(null)
|
||||
|
||||
assertNull("top should be removed after setTopPanel(null)", find(panel, top))
|
||||
assertNotNull("headerText should still be present", find(panel, panel.headerText))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setTopPanel replaces previous top without duplicates`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val first = JLabel("first")
|
||||
val second = JLabel("second")
|
||||
panel.setTopPanel(first)
|
||||
panel.setTopPanel(second)
|
||||
|
||||
assertNull("first top should be gone after replacement", find(panel, first))
|
||||
assertNotNull("second top should be present", find(panel, second))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setBody ------
|
||||
|
||||
fun `test setBody adds component after descriptionText`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val body = JLabel("body")
|
||||
panel.setBody(body)
|
||||
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val descIdx = comps.indexOf(panel.descriptionText)
|
||||
val bodyIdx = comps.indexOf(body)
|
||||
assertTrue("body should appear after descriptionText", descIdx < bodyIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setBody null removes body`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val body = JLabel("body")
|
||||
panel.setBody(body)
|
||||
panel.setBody(null)
|
||||
|
||||
assertNull("body should be removed after setBody(null)", find(panel, body))
|
||||
assertNotNull("headerText should still be present", find(panel, panel.headerText))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setBody replaces previous body without duplicates`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val first = JLabel("first body")
|
||||
val second = JLabel("second body")
|
||||
panel.setBody(first)
|
||||
panel.setBody(second)
|
||||
|
||||
assertNull("first body should be gone", find(panel, first))
|
||||
assertNotNull("second body should be present", find(panel, second))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setFooter ------
|
||||
|
||||
fun `test setFooter adds component after body`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val body = JLabel("body")
|
||||
val footer = JLabel("footer")
|
||||
panel.setBody(body)
|
||||
panel.setFooter(footer)
|
||||
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val bodyIdx = comps.indexOf(body)
|
||||
val footerIdx = comps.indexOf(footer)
|
||||
assertTrue("footer should appear after body", bodyIdx < footerIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setFooter null removes footer`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val footer = JLabel("footer")
|
||||
panel.setFooter(footer)
|
||||
panel.setFooter(null)
|
||||
|
||||
assertNull("footer should be removed after setFooter(null)", find(panel, footer))
|
||||
assertNotNull("headerText should still be present", find(panel, panel.headerText))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setFooter replaces existing footer without duplicates`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val first = JLabel("first footer")
|
||||
val second = JLabel("second footer")
|
||||
panel.setFooter(first)
|
||||
panel.setFooter(second)
|
||||
|
||||
assertNull("first footer should be gone", find(panel, first))
|
||||
assertNotNull("second footer should be present", find(panel, second))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ordering with all slots ------
|
||||
|
||||
fun `test all slots appear in correct order top-header-desc-body-footer`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val top = JLabel("top")
|
||||
val body = JLabel("body")
|
||||
val footer = JLabel("footer")
|
||||
panel.setTopPanel(top)
|
||||
panel.setBody(body)
|
||||
panel.setFooter(footer)
|
||||
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val topIdx = comps.indexOf(top)
|
||||
val headerIdx = comps.indexOf(panel.headerText)
|
||||
val descIdx = comps.indexOf(panel.descriptionText)
|
||||
val bodyIdx = comps.indexOf(body)
|
||||
val footerIdx = comps.indexOf(footer)
|
||||
assertTrue("top < header", topIdx < headerIdx)
|
||||
assertTrue("header < desc", headerIdx < descIdx)
|
||||
assertTrue("desc < body", descIdx < bodyIdx)
|
||||
assertTrue("body < footer", bodyIdx < footerIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test header and description survive multiple setBody calls`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
repeat(3) { i -> panel.setBody(JLabel("body $i")) }
|
||||
assertNotNull(find(panel, panel.headerText))
|
||||
assertNotNull(find(panel, panel.descriptionText))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ column child count sanity ------
|
||||
|
||||
fun `test col has exactly two children with no optional slots`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
val col = findCol(panel)!!
|
||||
assertEquals("headerText + descriptionText only", 2, col.componentCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test col child count grows by one for each optional slot added`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
panel.setTopPanel(JLabel("top"))
|
||||
assertEquals(3, findCol(panel)!!.componentCount)
|
||||
panel.setBody(JLabel("body"))
|
||||
assertEquals(4, findCol(panel)!!.componentCount)
|
||||
panel.setFooter(JLabel("footer"))
|
||||
assertEquals(5, findCol(panel)!!.componentCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test col shrinks back after removing optional slots`() {
|
||||
edt {
|
||||
val panel = BaseSessionQuestionPanel()
|
||||
panel.setTopPanel(JLabel("top"))
|
||||
panel.setBody(JLabel("body"))
|
||||
panel.setFooter(JLabel("footer"))
|
||||
|
||||
panel.setTopPanel(null)
|
||||
panel.setBody(null)
|
||||
panel.setFooter(null)
|
||||
|
||||
assertEquals(2, findCol(panel)!!.componentCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private fun findCol(panel: BaseSessionQuestionPanel): JPanel? {
|
||||
for (child in panel.components) {
|
||||
if (child is JPanel) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun find(root: Container, target: JComponent): JComponent? {
|
||||
if (root === target) return target
|
||||
for (child in root.components) {
|
||||
if (child === target) return target
|
||||
if (child is Container) {
|
||||
val found = find(child, target)
|
||||
if (found != null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+51
-30
@@ -1,13 +1,12 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.ui.shared.SessionQuestionButton
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import java.awt.Container
|
||||
import javax.swing.JButton
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
@@ -49,29 +48,11 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
|
||||
// ------ open profile button style ------
|
||||
|
||||
fun `test open profile button is SessionQuestionButton`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.openProfileButton
|
||||
assertTrue("Open profile button should be a SessionQuestionButton", btn is SessionQuestionButton)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test open profile button is primary`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.openProfileButton as SessionQuestionButton
|
||||
assertTrue("Open profile button should be primary", btn.primary)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test open profile button has DarculaButtonUI default style key`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.openProfileButton
|
||||
val btn = view.openProfileButton()
|
||||
assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
}
|
||||
@@ -80,28 +61,32 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.openProfileButton
|
||||
val btn = view.openProfileButton()
|
||||
assertEquals(SessionUiStyle.View.surface(), btn.background)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ dismiss button style ------
|
||||
|
||||
fun `test dismiss button is SessionQuestionButton`() {
|
||||
fun `test dismiss button does not have default style key`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.dismissButton
|
||||
assertTrue("Dismiss button should be a SessionQuestionButton", btn is SessionQuestionButton)
|
||||
val btn = view.dismissButton()
|
||||
val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Dismiss should not be primary", key == null || key == false)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test dismiss button is not primary`() {
|
||||
fun `test login action buttons share right-aligned footer group`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val btn = view.dismissButton as SessionQuestionButton
|
||||
assertFalse("Dismiss button should not be primary", btn.primary)
|
||||
|
||||
val dismiss = view.dismissButton()
|
||||
val open = view.openProfileButton()
|
||||
assertSame("Dismiss and open profile should be in the same right-aligned group", dismiss.parent, open.parent)
|
||||
assertTrue("Dismiss should appear before open profile", dismiss.parent.components.indexOf(dismiss) < open.parent.components.indexOf(open))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +97,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = { called = true }, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
view.openProfileButton.doClick()
|
||||
view.openProfileButton().doClick()
|
||||
}
|
||||
assertTrue("openProfile should have been called", called)
|
||||
}
|
||||
@@ -122,7 +107,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = { called = true })
|
||||
view.show("Sign in required.")
|
||||
view.dismissButton.doClick()
|
||||
view.dismissButton().doClick()
|
||||
}
|
||||
assertTrue("dismiss should have been called", called)
|
||||
}
|
||||
@@ -161,6 +146,42 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
// ------ fonts: standard UI family, not editor ------
|
||||
|
||||
fun `test header uses headerFont not editor font family`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
|
||||
view.applyStyle(style)
|
||||
|
||||
val title = findAll<JBTextArea>(view).firstOrNull { it.font.isBold }
|
||||
assertNotNull("Bold title text area should be present", title)
|
||||
assertFalse(
|
||||
"Title font should not use editor font family",
|
||||
title!!.font.name == "Courier New",
|
||||
)
|
||||
assertEquals("Title font should equal headerFont", style.headerFont, title.font)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test description uses hintFont not editor font family`() {
|
||||
edt {
|
||||
val view = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
view.show("Sign in required.")
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
|
||||
view.applyStyle(style)
|
||||
|
||||
val desc = findAll<JBTextArea>(view).firstOrNull { it.text == "Sign in required." }
|
||||
assertNotNull("Description text area should be present", desc)
|
||||
assertFalse(
|
||||
"Description font should not use editor font family",
|
||||
desc!!.font.name == "Courier New",
|
||||
)
|
||||
assertEquals("Description font should equal hintFont", style.hintFont, desc.font)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import java.awt.Container
|
||||
import javax.swing.AbstractButton
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
|
||||
private lateinit var view: PermissionView
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
view = PermissionView(
|
||||
reply = { id, dto -> replies.add(id to dto) },
|
||||
)
|
||||
}
|
||||
|
||||
fun `test allow button uses bundle text and replies once`() {
|
||||
view.show(permission())
|
||||
|
||||
buttons(view).first { it.text == "Allow" }.doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
}
|
||||
|
||||
fun `test deny button uses bundle text and rejects`() {
|
||||
view.show(permission())
|
||||
|
||||
buttons(view).first { it.text == "Deny" }.doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
}
|
||||
|
||||
fun `test blank patterns display star`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm2",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
}
|
||||
|
||||
private fun permission() = Permission(
|
||||
id = "perm1",
|
||||
sessionId = "ses_test",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
message = "Review file changes",
|
||||
)
|
||||
|
||||
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
|
||||
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
|
||||
if (comp is Container) item + buttons(comp) else item
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -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 ------
|
||||
|
||||
+430
-23
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.views
|
||||
import ai.kilocode.client.session.model.Question
|
||||
import ai.kilocode.client.session.model.QuestionItem
|
||||
import ai.kilocode.client.session.model.QuestionOption
|
||||
import ai.kilocode.client.session.ui.shared.SessionQuestionButton
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
@@ -11,13 +10,17 @@ import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBRadioButton
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import kotlin.math.abs
|
||||
import javax.swing.AbstractButton
|
||||
import javax.swing.JButton
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class QuestionViewTest : BasePlatformTestCase() {
|
||||
@@ -30,6 +33,7 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
view = QuestionView(
|
||||
project = project,
|
||||
reply = { id, dto -> replies.add(id to dto) },
|
||||
reject = { id -> rejects.add(id) },
|
||||
scroll = { scrolls++ },
|
||||
@@ -87,6 +91,31 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertTrue(replies.isEmpty())
|
||||
}
|
||||
|
||||
fun `test question action buttons share right-aligned footer group`() {
|
||||
view.show(singleSelectQuestion("req_actions"))
|
||||
|
||||
val dismiss = button(view, "Dismiss")
|
||||
val submit = button(view, "Submit")
|
||||
assertSame("Dismiss and Submit should be in the same right-aligned group", dismiss.parent, submit.parent)
|
||||
assertTrue("Dismiss should appear before Submit", dismiss.parent.components.indexOf(dismiss) < submit.parent.components.indexOf(submit))
|
||||
}
|
||||
|
||||
fun `test review action buttons share right-aligned footer group`() {
|
||||
view.show(twoItemQuestion("req_review_actions"))
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
button(view, "Review").doClick()
|
||||
|
||||
val dismiss = button(view, "Dismiss")
|
||||
val back = button(view, "Back")
|
||||
val submit = button(view, "Submit")
|
||||
assertSame("Dismiss and Back should be in the same right-aligned group", dismiss.parent, back.parent)
|
||||
assertSame("Back and Submit should be in the same right-aligned group", back.parent, submit.parent)
|
||||
assertTrue("Dismiss should appear before Back", dismiss.parent.components.indexOf(dismiss) < back.parent.components.indexOf(back))
|
||||
assertTrue("Back should appear before Submit", back.parent.components.indexOf(back) < submit.parent.components.indexOf(submit))
|
||||
}
|
||||
|
||||
// ------ radio options ------
|
||||
|
||||
fun `test single question renders radio options`() {
|
||||
@@ -159,19 +188,64 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals("description should align in the text renderer", label.parent, desc.parent)
|
||||
|
||||
val style = SessionEditorStyle.current()
|
||||
assertEquals("option label should use bold editor font", style.boldEditorFont, label.font)
|
||||
assertEquals("description should use transcript font", style.transcriptFont, desc.font)
|
||||
assertEquals("option label should use boldFont", style.boldFont, label.font)
|
||||
assertEquals("description should use regularFont", style.regularFont, desc.font)
|
||||
}
|
||||
|
||||
fun `test question title and hint use editor fonts`() {
|
||||
fun `test option row without description centers button beside label`() {
|
||||
view.show(
|
||||
Question(
|
||||
id = "no_desc_center",
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "Pick one",
|
||||
header = "Pick",
|
||||
options = listOf(QuestionOption("Plain", "")),
|
||||
multiple = false,
|
||||
custom = false,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
layout(view)
|
||||
|
||||
val radio = option<JBRadioButton>(view, "Plain")
|
||||
val label = text(view, "Plain")
|
||||
val row = label.parent.parent as Container
|
||||
|
||||
val radioCenter = center(radio, row)
|
||||
val labelCenter = center(label, row)
|
||||
assertTrue(
|
||||
"radio should be vertically centered with a single-line label: radio=$radioCenter label=$labelCenter row=${row.size}",
|
||||
abs(radioCenter - labelCenter) <= 2,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test custom row centers button beside label`() {
|
||||
view.show(customSingleQuestion("custom_center"))
|
||||
layout(view)
|
||||
|
||||
val radio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
val label = text(view, "Add your own response")
|
||||
val row = label.parent.parent as Container
|
||||
|
||||
val radioCenter = center(radio, row)
|
||||
val labelCenter = center(label, row)
|
||||
assertTrue(
|
||||
"custom radio should be vertically centered with the label: radio=$radioCenter label=$labelCenter row=${row.size}",
|
||||
abs(radioCenter - labelCenter) <= 2,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test question title uses headerFont and hint uses hintFont`() {
|
||||
view.show(singleSelectQuestion("q_fonts"))
|
||||
|
||||
val style = SessionEditorStyle.current()
|
||||
val title = text(view, "Choose approach")
|
||||
val hint = text(view, "Select one answer")
|
||||
|
||||
assertEquals(style.boldEditorFont, title.font)
|
||||
assertEquals(style.transcriptFont, hint.font)
|
||||
assertEquals("title should use headerFont", style.headerFont, title.font)
|
||||
assertEquals("hint should use hintFont", style.hintFont, hint.font)
|
||||
}
|
||||
|
||||
// ------ multi-question navigation ------
|
||||
@@ -366,22 +440,19 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
|
||||
fun `test submit is SessionQuestionButton with primary true`() {
|
||||
fun `test submit has DarculaButtonUI default style key`() {
|
||||
view.show(singleSelectQuestion("q_btn_type"))
|
||||
|
||||
val submit = button(view, "Submit")
|
||||
|
||||
assertTrue("Submit should be SessionQuestionButton", submit is SessionQuestionButton)
|
||||
assertTrue("Submit should be primary", (submit as SessionQuestionButton).primary)
|
||||
assertEquals("Submit should be primary (default style key)", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
|
||||
fun `test dismiss is SessionQuestionButton with primary false`() {
|
||||
fun `test dismiss does not have default style key`() {
|
||||
view.show(singleSelectQuestion("q_dismiss_type"))
|
||||
|
||||
val dismiss = button(view, "Dismiss")
|
||||
|
||||
assertTrue("Dismiss should be SessionQuestionButton", dismiss is SessionQuestionButton)
|
||||
assertFalse("Dismiss should not be primary", (dismiss as SessionQuestionButton).primary)
|
||||
val key = dismiss.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Dismiss should not be primary", key == null || key == false)
|
||||
}
|
||||
|
||||
fun `test session question buttons use question surface background`() {
|
||||
@@ -394,7 +465,7 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(SessionUiStyle.View.surface(), submit.background)
|
||||
}
|
||||
|
||||
fun `test review submit and back buttons are correct types on review page`() {
|
||||
fun `test review submit and back buttons have correct primary state on review page`() {
|
||||
view.show(twoItemQuestion("q_review_types"))
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
@@ -405,10 +476,9 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
val submit = button(view, "Submit")
|
||||
val back = button(view, "Back")
|
||||
|
||||
assertTrue("Submit on review page should be SessionQuestionButton", submit is SessionQuestionButton)
|
||||
assertTrue("Submit on review page should be primary", (submit as SessionQuestionButton).primary)
|
||||
assertTrue("Back on review page should be SessionQuestionButton", back is SessionQuestionButton)
|
||||
assertFalse("Back on review page should not be primary", (back as SessionQuestionButton).primary)
|
||||
assertEquals("Submit on review page should be primary", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
val backKey = back.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Back on review page should not be primary", backKey == null || backKey == false)
|
||||
}
|
||||
|
||||
fun `test next button is not primary before last item`() {
|
||||
@@ -416,8 +486,8 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val next = button(view, "Next")
|
||||
|
||||
assertTrue(next is SessionQuestionButton)
|
||||
assertFalse("Next should not be primary on first question", (next as SessionQuestionButton).primary)
|
||||
val key = next.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Next should not be primary on first question", key == null || key == false)
|
||||
}
|
||||
|
||||
fun `test review button is primary on last item`() {
|
||||
@@ -427,8 +497,7 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val review = button(view, "Review")
|
||||
|
||||
assertTrue(review is SessionQuestionButton)
|
||||
assertTrue("Review should be primary on last question", (review as SessionQuestionButton).primary)
|
||||
assertEquals("Review should be primary on last question", true, review.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
|
||||
fun `test single question hides header nav`() {
|
||||
@@ -495,6 +564,300 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(listOf(listOf("A")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
// ------ custom question row ------
|
||||
|
||||
fun `test custom row renders when custom is true`() {
|
||||
view.show(customSingleQuestion("q_custom_present"))
|
||||
|
||||
assertLabelsContain(view, "Add your own response")
|
||||
}
|
||||
|
||||
fun `test custom row is absent when custom is false`() {
|
||||
view.show(singleSelectQuestion("q_custom_absent"))
|
||||
|
||||
assertLabelsDoNotContain(view, "Add your own response")
|
||||
}
|
||||
|
||||
fun `test custom single select answer submits as typed text`() {
|
||||
view.show(customSingleQuestion("q_custom_submit"))
|
||||
|
||||
// Click the custom radio button (actionCommand is "")
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
|
||||
// Find the editor that appeared and type text
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "my custom answer"
|
||||
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test custom editor grows for wrapped input`() {
|
||||
view.show(customSingleQuestion("q_custom_grow"))
|
||||
layout(view, 240)
|
||||
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
layout(view, 240)
|
||||
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
val initial = ed.preferredSize.height
|
||||
ed.text = "wrapped ".repeat(30)
|
||||
|
||||
assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial)
|
||||
}
|
||||
|
||||
fun `test blank custom input does not enable submit`() {
|
||||
view.show(customSingleQuestion("q_custom_blank"))
|
||||
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
|
||||
val submit = button(view, "Submit")
|
||||
assertFalse("Submit should remain disabled when custom text is blank", submit.isEnabled)
|
||||
}
|
||||
|
||||
fun `test selecting normal option after custom input sends option not custom text`() {
|
||||
view.show(customSingleQuestion("q_custom_revert"))
|
||||
|
||||
// Open custom and type something
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "stale custom"
|
||||
|
||||
// Now select a normal option
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test selecting normal option after custom input clears custom radio selection`() {
|
||||
view.show(customSingleQuestion("q_custom_clear_radio"))
|
||||
|
||||
val radio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
radio.doClick()
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "stale custom"
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
|
||||
val custom = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
assertFalse("Custom radio should not stay selected after choosing a normal option", custom.isSelected)
|
||||
assertTrue("Normal option should be selected", option<JBRadioButton>(view, "Minimal").isSelected)
|
||||
assertTrue("Custom editor should stay visible for non-empty text", findAll<EditorTextField>(view).any { it.parent != null && it.text == "stale custom" })
|
||||
assertLabelsDoNotContain(view, "stale custom")
|
||||
}
|
||||
|
||||
fun `test empty custom editor is removed after selecting normal option`() {
|
||||
view.show(customSingleQuestion("q_custom_empty_editor"))
|
||||
|
||||
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
|
||||
assertNotNull(findAll<EditorTextField>(view).firstOrNull { it.parent != null })
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
|
||||
assertNull("Empty custom editor should be removed after selecting a normal option", findAll<EditorTextField>(view).firstOrNull { it.parent != null })
|
||||
}
|
||||
|
||||
fun `test focusing retained custom editor reselects custom response`() {
|
||||
view.show(customSingleQuestion("q_custom_focus"))
|
||||
|
||||
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
|
||||
findAll<EditorTextField>(view).first().text = "stale custom"
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
|
||||
view.testFocusCustomEditor()
|
||||
|
||||
assertTrue("Custom radio should be selected when its editor takes focus", findAll<JBRadioButton>(view).first { it.actionCommand == "" }.isSelected)
|
||||
assertFalse("Normal option should be cleared when custom editor takes focus", option<JBRadioButton>(view, "Minimal").isSelected)
|
||||
assertEquals("Submit should send custom text after focusing retained editor", listOf(listOf("stale custom")), run {
|
||||
button(view, "Submit").doClick()
|
||||
replies.single().second.answers
|
||||
})
|
||||
}
|
||||
|
||||
fun `test multi select custom answer combines with selected options`() {
|
||||
view.show(customMultiQuestion("q_multi_custom"))
|
||||
|
||||
option<JBCheckBox>(view, "A").doClick()
|
||||
val customBox = findAll<JBCheckBox>(view).first { it.actionCommand == "" }
|
||||
customBox.doClick()
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "extra"
|
||||
|
||||
button(view, "Review").doClick()
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("A", "extra")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test custom input is trimmed before submit`() {
|
||||
view.show(customSingleQuestion("q_custom_trim"))
|
||||
|
||||
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
|
||||
findAll<EditorTextField>(view).first().text = " trimmed answer "
|
||||
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("trimmed answer")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test multi select custom answer can be unchecked`() {
|
||||
view.show(customMultiQuestion("q_multi_custom_unchecked"))
|
||||
|
||||
option<JBCheckBox>(view, "A").doClick()
|
||||
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
|
||||
findAll<EditorTextField>(view).first().text = "extra"
|
||||
|
||||
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
|
||||
|
||||
assertFalse(
|
||||
"Custom checkbox should be unchecked",
|
||||
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.isSelected,
|
||||
)
|
||||
assertTrue("Review should stay enabled because a normal option is selected", button(view, "Review").isEnabled)
|
||||
button(view, "Review").doClick()
|
||||
assertLabelsContain(view, "A")
|
||||
assertLabelsDoNotContain(view, "extra")
|
||||
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("A")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test duplicate custom answer is submitted once`() {
|
||||
view.show(customMultiQuestion("q_multi_custom_duplicate"))
|
||||
|
||||
option<JBCheckBox>(view, "A").doClick()
|
||||
findAll<JBCheckBox>(view).first { it.actionCommand == "" }.doClick()
|
||||
findAll<EditorTextField>(view).first().text = "A"
|
||||
|
||||
button(view, "Review").doClick()
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("A")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test custom text appears in review`() {
|
||||
view.show(
|
||||
Question(
|
||||
id = "q_custom_review",
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "How?",
|
||||
header = "H",
|
||||
options = listOf(QuestionOption("X", "")),
|
||||
multiple = false,
|
||||
custom = true,
|
||||
),
|
||||
QuestionItem(
|
||||
question = "What?",
|
||||
header = "W",
|
||||
options = listOf(QuestionOption("Y", "")),
|
||||
multiple = false,
|
||||
custom = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
// Answer first with custom
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "typed answer"
|
||||
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Y").doClick()
|
||||
button(view, "Review").doClick()
|
||||
|
||||
assertLabelsContain(view, "typed answer")
|
||||
}
|
||||
|
||||
fun `test custom text preserved across navigation`() {
|
||||
view.show(
|
||||
Question(
|
||||
id = "q_custom_nav",
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "How?",
|
||||
header = "H",
|
||||
options = listOf(QuestionOption("X", "")),
|
||||
multiple = false,
|
||||
custom = true,
|
||||
),
|
||||
QuestionItem(
|
||||
question = "What?",
|
||||
header = "W",
|
||||
options = listOf(QuestionOption("Y", "")),
|
||||
multiple = false,
|
||||
custom = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
// Open custom on first question and type
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "preserved text"
|
||||
|
||||
// Navigate forward
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Y").doClick()
|
||||
|
||||
// Navigate back
|
||||
navButton(view, "Back").doClick()
|
||||
|
||||
// Custom row should still be open with the preserved text in the editor
|
||||
val editorAfterBack = findAll<EditorTextField>(view).firstOrNull()
|
||||
assertNotNull("Custom editor should still be visible after navigating back", editorAfterBack)
|
||||
assertEquals("Custom editor should have preserved text", "preserved text", editorAfterBack!!.text)
|
||||
}
|
||||
|
||||
fun `test optionless custom question is answerable`() {
|
||||
view.show(
|
||||
Question(
|
||||
id = "q_optionless",
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "Free answer",
|
||||
header = "Free",
|
||||
options = emptyList(),
|
||||
multiple = false,
|
||||
custom = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
// The custom row should be present
|
||||
assertLabelsContain(view, "Add your own response")
|
||||
|
||||
// Open the custom row
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
ed.text = "my answer"
|
||||
|
||||
val submit = button(view, "Submit")
|
||||
assertTrue("Submit should be enabled after typing in optionless custom question", submit.isEnabled)
|
||||
|
||||
submit.doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(listOf(listOf("my answer")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
/**
|
||||
@@ -514,6 +877,21 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
private fun text(root: Container, value: String): JBTextArea =
|
||||
findAll<JBTextArea>(root).first { it.text == value }
|
||||
|
||||
private fun layout(root: Container, width: Int = 400) {
|
||||
root.setSize(width, root.preferredSize.height)
|
||||
layoutTree(root)
|
||||
}
|
||||
|
||||
private fun layoutTree(root: Container) {
|
||||
root.doLayout()
|
||||
for (child in root.components) {
|
||||
if (child is Container) layoutTree(child)
|
||||
}
|
||||
}
|
||||
|
||||
private fun center(component: Component, root: Component): Int =
|
||||
SwingUtilities.convertPoint(component, 0, component.height / 2, root).y
|
||||
|
||||
private fun singleSelectQuestion(id: String) = Question(
|
||||
id = id,
|
||||
items = listOf(
|
||||
@@ -556,6 +934,35 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
),
|
||||
)
|
||||
|
||||
private fun customSingleQuestion(id: String) = Question(
|
||||
id = id,
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "Choose approach",
|
||||
header = "Approach",
|
||||
options = listOf(
|
||||
QuestionOption("Minimal", "Smallest safe change"),
|
||||
QuestionOption("Balanced", "Focused implementation"),
|
||||
),
|
||||
multiple = false,
|
||||
custom = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private fun customMultiQuestion(id: String) = Question(
|
||||
id = id,
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "Pick features",
|
||||
header = "Features",
|
||||
options = listOf(QuestionOption("A", ""), QuestionOption("B", "")),
|
||||
multiple = true,
|
||||
custom = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private fun assertLabelsContain(root: Container, text: String) {
|
||||
val found = findAll<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
|
||||
assertTrue("Expected label '$text' to be present", found)
|
||||
|
||||
+23
-26
@@ -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)
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
package ai.kilocode.client.session.views.base
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JPanel
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class BaseQuestionViewTest : BasePlatformTestCase() {
|
||||
|
||||
// ------ initial state ------
|
||||
|
||||
fun `test header and description text areas are in the component tree by default`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val areas = findAll<JBTextArea>(panel)
|
||||
assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setHeader sets the header text`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("My Title")
|
||||
val bold = findAll<JBTextArea>(panel).firstOrNull { it.font.isBold }
|
||||
assertNotNull("Bold header text area should be present", bold)
|
||||
assertEquals("My Title", bold!!.text)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setHeader with description shows description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "Hint text")
|
||||
val desc = findAll<JBTextArea>(panel).firstOrNull { it.text == "Hint text" }
|
||||
assertNotNull("Description text area should be present", desc)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setHeader without description hides description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title")
|
||||
val areas = findAll<JBTextArea>(panel)
|
||||
val nonBold = areas.filter { !it.font.isBold }
|
||||
// description should either be hidden or blank
|
||||
assertTrue("Non-bold text areas should be hidden or empty", nonBold.all { !it.isVisible || it.text.isBlank() })
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setDescription with blank hides description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "some text")
|
||||
panel.setDescription("")
|
||||
val areas = findAll<JBTextArea>(panel)
|
||||
val desc = areas.firstOrNull { !it.font.isBold }
|
||||
assertTrue("Description should be hidden when blank", desc == null || !desc.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setDescription with null hides description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "some text")
|
||||
panel.setDescription(null)
|
||||
val areas = findAll<JBTextArea>(panel)
|
||||
val desc = areas.firstOrNull { !it.font.isBold }
|
||||
assertTrue("Description should be hidden when null", desc == null || !desc.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setTopPanel ------
|
||||
|
||||
fun `test setTopPanel adds component before header`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val top = JLabel("top")
|
||||
panel.setTopPanel(top)
|
||||
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val topIdx = comps.indexOf(top)
|
||||
// header row is the JPanel containing the header text area
|
||||
val headerRow = findAll<JBTextArea>(panel).firstOrNull { it.font.isBold }?.parent as? JPanel
|
||||
val headerIdx = if (headerRow != null) comps.indexOf(headerRow) else comps.indexOfFirst { it is JPanel }
|
||||
assertTrue("top should appear before headerText row", topIdx >= 0 && topIdx < headerIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setTopPanel null removes top component`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val top = JLabel("top")
|
||||
panel.setTopPanel(top)
|
||||
panel.setTopPanel(null)
|
||||
|
||||
assertNull("top should be removed after setTopPanel(null)", find(panel, top))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setTopPanel replaces previous top without duplicates`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val first = JLabel("first")
|
||||
val second = JLabel("second")
|
||||
panel.setTopPanel(first)
|
||||
panel.setTopPanel(second)
|
||||
|
||||
assertNull("first top should be gone after replacement", find(panel, first))
|
||||
assertNotNull("second top should be present", find(panel, second))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setContent ------
|
||||
|
||||
fun `test setContent adds component after description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val body = JLabel("body")
|
||||
panel.setContent(body)
|
||||
assertNotNull("body should be in the tree", find(panel, body))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setContent null removes content`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val body = JLabel("body")
|
||||
panel.setContent(body)
|
||||
panel.setContent(null)
|
||||
assertNull("body should be removed after setContent(null)", find(panel, body))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setContent replaces previous content without duplicates`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val first = JLabel("first body")
|
||||
val second = JLabel("second body")
|
||||
panel.setContent(first)
|
||||
panel.setContent(second)
|
||||
assertNull("first body should be gone", find(panel, first))
|
||||
assertNotNull("second body should be present", find(panel, second))
|
||||
}
|
||||
}
|
||||
|
||||
// ------ setActions ------
|
||||
|
||||
fun `test setActions renders one button per action`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(
|
||||
BaseQuestionView.Action("a", "Cancel", primary = false) {},
|
||||
BaseQuestionView.Action("b", "OK", primary = true) {},
|
||||
))
|
||||
val btns = panel.actionButtonsForTest()
|
||||
assertEquals(2, btns.size)
|
||||
assertNotNull(btns["a"])
|
||||
assertNotNull(btns["b"])
|
||||
assertEquals("Cancel", btns["a"]!!.text)
|
||||
assertEquals("OK", btns["b"]!!.text)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test primary action has DarculaButtonUI default style key`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
|
||||
val btn = panel.actionButtonsForTest()["ok"]!!
|
||||
assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test non-primary action does not have DarculaButtonUI default style key`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(BaseQuestionView.Action("cancel", "Cancel", primary = false) {}))
|
||||
val btn = panel.actionButtonsForTest()["cancel"]!!
|
||||
val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Non-primary should not have default style key", key == null || key == false)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test action button click invokes handler`() {
|
||||
edt {
|
||||
var clicked = false
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) { clicked = true }))
|
||||
panel.actionButtonsForTest()["ok"]!!.doClick()
|
||||
assertTrue("handler should have been invoked", clicked)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setActionEnabled disables and enables button`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true, enabled = true) {}))
|
||||
panel.setActionEnabled("ok", false)
|
||||
assertFalse(panel.actionButtonsForTest()["ok"]!!.isEnabled)
|
||||
panel.setActionEnabled("ok", true)
|
||||
assertTrue(panel.actionButtonsForTest()["ok"]!!.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setActions empty removes all action buttons`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
|
||||
panel.setActions(emptyList())
|
||||
assertTrue("actionButtonsForTest should be empty", panel.actionButtonsForTest().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test action buttons use question card surface background`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setActions(listOf(
|
||||
BaseQuestionView.Action("a", "A", primary = false) {},
|
||||
BaseQuestionView.Action("b", "B", primary = true) {},
|
||||
))
|
||||
val btns = panel.actionButtonsForTest()
|
||||
assertEquals(SessionUiStyle.View.surface(), btns["a"]!!.background)
|
||||
assertEquals(SessionUiStyle.View.surface(), btns["b"]!!.background)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ordering ------
|
||||
|
||||
fun `test content appears after description in col`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val body = JLabel("body")
|
||||
panel.setContent(body)
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val descIdx = comps.indexOfFirst { it is JBTextArea && !(it).font.isBold }
|
||||
val bodyIdx = comps.indexOf(body)
|
||||
assertTrue("body should appear after description", descIdx < bodyIdx)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test action footer appears after content`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
val body = JLabel("body")
|
||||
panel.setContent(body)
|
||||
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
|
||||
val col = findCol(panel)!!
|
||||
val comps = col.components.toList()
|
||||
val bodyIdx = comps.indexOf(body)
|
||||
val btn = panel.actionButtonsForTest()["ok"]!!
|
||||
// find the footer panel that contains the button
|
||||
val footerIdx = comps.indexOfFirst { it is JPanel && find(it, btn) != null }
|
||||
assertTrue("footer should appear after body", bodyIdx < footerIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ header icon ------
|
||||
|
||||
fun `test setHeaderIcon adds icon to the left side of header row`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeaderIcon(AllIcons.General.Warning, "warning")
|
||||
|
||||
val labels = findAll<JBLabel>(panel).filter { it.icon != null && it.isVisible }
|
||||
assertEquals("Expected one header icon", 1, labels.size)
|
||||
assertSame(AllIcons.General.Warning, labels[0].icon)
|
||||
assertEquals("warning", labels[0].toolTipText)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setHeaderIcon null hides header icon`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeaderIcon(AllIcons.General.Warning)
|
||||
panel.setHeaderIcon(null)
|
||||
|
||||
val labels = findAll<JBLabel>(panel).filter { it.icon != null && it.isVisible }
|
||||
assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
// ------ applyStyle: UI fonts ----
|
||||
|
||||
fun `test applyStyle applies headerFont to header and hintFont to description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "Hint")
|
||||
val style = SessionEditorStyle.current()
|
||||
panel.applyStyle(style)
|
||||
|
||||
assertEquals("headerText should use headerFont", style.headerFont, panel.headerFont())
|
||||
assertEquals("descriptionText should use hintFont", style.hintFont, panel.descriptionFont())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test applyStyle does not apply editor font family to header or description`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "Hint")
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
|
||||
panel.applyStyle(style)
|
||||
|
||||
assertFalse("headerText should not use editor font family", panel.headerFont().name == "Courier New")
|
||||
assertFalse("descriptionText should not use editor font family", panel.descriptionFont().name == "Courier New")
|
||||
}
|
||||
}
|
||||
|
||||
fun `test description uses same vertical stacking as option descriptions`() {
|
||||
edt {
|
||||
val panel = BaseQuestionView()
|
||||
panel.setHeader("Title", "Hint")
|
||||
val desc = findAll<JBTextArea>(panel).firstOrNull { it.text == "Hint" }
|
||||
assertNotNull(desc)
|
||||
val ins = desc!!.border.getBorderInsets(desc)
|
||||
assertEquals("description should not add extra top padding", 0, ins.top)
|
||||
}
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private fun findCol(panel: BaseQuestionView): JPanel? {
|
||||
for (child in panel.components) {
|
||||
if (child is JPanel) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun find(root: Container, target: JComponent): JComponent? {
|
||||
if (root === target) return target
|
||||
for (child in root.components) {
|
||||
if (child === target) return target
|
||||
if (child is Container) {
|
||||
val found = find(child, target)
|
||||
if (found != null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun find(root: JPanel, target: JButton): JButton? {
|
||||
for (child in root.components) {
|
||||
if (child === target) return target
|
||||
if (child is JPanel) {
|
||||
val found = find(child, target)
|
||||
if (found != null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
|
||||
|
||||
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
if (cls.isInstance(root)) result.add(cls.cast(root))
|
||||
for (child in root.components) {
|
||||
if (child is Container) result.addAll(findAllCls(child, cls))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
package ai.kilocode.client.session.views.permission
|
||||
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import java.awt.Container
|
||||
import javax.swing.AbstractButton
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
|
||||
private lateinit var view: PermissionView
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
view = PermissionView(
|
||||
reply = { id, dto -> replies.add(id to dto) },
|
||||
)
|
||||
}
|
||||
|
||||
fun `test run button replies once`() {
|
||||
view.show(permission())
|
||||
|
||||
view.runButtonForTest().doClick()
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
assertFalse(view.runButtonForTest().isEnabled)
|
||||
assertFalse(view.denyButtonForTest().isEnabled)
|
||||
}
|
||||
|
||||
fun `test deny button rejects`() {
|
||||
view.show(permission())
|
||||
|
||||
view.denyButtonForTest().doClick()
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
}
|
||||
|
||||
fun `test view is visible after show`() {
|
||||
view.show(permission())
|
||||
assertTrue(view.isVisible)
|
||||
}
|
||||
|
||||
fun `test hideView makes invisible`() {
|
||||
view.show(permission())
|
||||
view.hideView()
|
||||
assertFalse(view.isVisible)
|
||||
}
|
||||
|
||||
fun `test blank patterns show only action label with no code fragment`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm2",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
val text = allText(view)
|
||||
assertTrue("Expected tool label in text, got: $text", text.contains("Edit"))
|
||||
// No code label should be added when there is no target
|
||||
assertTrue("Expected no code labels for empty patterns", view.codeLabelsForTest().isEmpty())
|
||||
}
|
||||
|
||||
fun `test star-only patterns show action label with no code fragment`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm3",
|
||||
sessionId = "ses",
|
||||
name = "read",
|
||||
patterns = listOf("*"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
val text = allText(view)
|
||||
assertTrue("Expected Read label in text, got: $text", text.contains("Read"))
|
||||
assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty())
|
||||
}
|
||||
|
||||
fun `test bash permission shows action and command on same row`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm4",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "git status --short"),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected Shell action label in text, got: $text", text.contains("Shell"))
|
||||
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertEquals("Expected exactly one target pane for command", 1, labels.size)
|
||||
assertTrue("Expected command in target pane, got: ${labels[0].text}", labels[0].text.contains("git status --short"))
|
||||
}
|
||||
|
||||
fun `test bash permission shows only header and compact detail`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm4b",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "git status --short"),
|
||||
message = "Run this command?",
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected permission header, got: $text", text.contains("Permission required"))
|
||||
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
|
||||
// State message should not appear for PENDING state
|
||||
assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?"))
|
||||
}
|
||||
|
||||
fun `test non-bash patterns show action and path as separate labels`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm5",
|
||||
sessionId = "ses",
|
||||
name = "read",
|
||||
patterns = listOf("src/App.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected 'Read' in text, got: $text", text.contains("Read"))
|
||||
assertTrue("Expected path in text, got: $text", text.containsPath("src/App.kt"))
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertEquals("Expected exactly one target pane for the pattern", 1, labels.size)
|
||||
assertTrue("Expected path in target pane, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt"))
|
||||
}
|
||||
|
||||
fun `test multiple patterns joined in code label`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_multi",
|
||||
sessionId = "ses",
|
||||
name = "glob",
|
||||
patterns = listOf("src/*.kt", "test/*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertEquals("Expected one combined code label for multiple patterns", 1, labels.size)
|
||||
assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("src/*.kt"))
|
||||
assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("test/*.kt"))
|
||||
}
|
||||
|
||||
fun `test diff preview renders only stat badge without duplicate file path`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm6",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("src/A.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
fileDiffs = listOf(
|
||||
PermissionFileDiff(
|
||||
file = "src/A.kt",
|
||||
patch = "@@ -1 +1 @@\n-old\n+new",
|
||||
additions = 1,
|
||||
deletions = 2,
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
|
||||
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
// Patch markers should NOT appear — no diff content is shown
|
||||
assertFalse("Should not render patch content, got: $text", text.contains("@@"))
|
||||
assertFalse("Should not render old line, got: $text", text.contains("-old"))
|
||||
assertFalse("Should not render new line, got: $text", text.contains("+new"))
|
||||
|
||||
val diffs = view.diffViewsForTest()
|
||||
assertEquals("Expected one diff view", 1, diffs.size)
|
||||
val badge = diffs[0].badgeForTest()
|
||||
assertEquals("-2", badge.removedLabelForTest().text)
|
||||
assertEquals("+1", badge.addedLabelForTest().text)
|
||||
assertNotSame("Removed and added labels should use different colors", badge.removedLabelForTest().foreground, badge.addedLabelForTest().foreground)
|
||||
}
|
||||
|
||||
fun `test diff preview shows no unavailable fallback text`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_no_patch",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("src/A.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
fileDiffs = listOf(
|
||||
PermissionFileDiff(
|
||||
file = "src/A.kt",
|
||||
patch = null,
|
||||
additions = 3,
|
||||
deletions = 1,
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
|
||||
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
// No "unavailable" fallback text expected in new design
|
||||
assertFalse("Should not render unavailable fallback, got: $text", text.contains("unavailable"))
|
||||
val badge = view.diffViewsForTest().single().badgeForTest()
|
||||
assertEquals("-1", badge.removedLabelForTest().text)
|
||||
assertEquals("+3", badge.addedLabelForTest().text)
|
||||
}
|
||||
|
||||
fun `test multiple diffs render each file separately`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_multi_diff",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("src/A.kt", "src/B.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
fileDiffs = listOf(
|
||||
PermissionFileDiff(
|
||||
file = "src/A.kt",
|
||||
patch = "@@ -1 +1 @@\n-a\n+b",
|
||||
additions = 1,
|
||||
deletions = 1,
|
||||
),
|
||||
PermissionFileDiff(
|
||||
file = "src/B.kt",
|
||||
patch = "@@ -2 +2 @@\n-c\n+d",
|
||||
additions = 2,
|
||||
deletions = 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val diffs = view.diffViewsForTest()
|
||||
assertEquals("Expected two diff views", 2, diffs.size)
|
||||
assertEquals("-1", diffs[0].badgeForTest().removedLabelForTest().text)
|
||||
assertEquals("+1", diffs[0].badgeForTest().addedLabelForTest().text)
|
||||
assertEquals("-3", diffs[1].badgeForTest().removedLabelForTest().text)
|
||||
assertEquals("+2", diffs[1].badgeForTest().addedLabelForTest().text)
|
||||
// Patch content should not be in text
|
||||
val text = allText(view)
|
||||
assertFalse("Should not render patch markers, got: $text", text.contains("@@"))
|
||||
}
|
||||
|
||||
fun `test no rule controls rendered`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm7",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = listOf("src/**"),
|
||||
meta = PermissionMeta(rules = listOf("rule1")),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertFalse("Should not contain 'Manage Auto-Approve Rules'", text.contains("Manage Auto-Approve Rules"))
|
||||
// Only Run and Deny buttons — not extra rule toggle buttons
|
||||
val btns = buttons(view)
|
||||
assertEquals("Expected exactly 2 buttons (Run and Deny)", 2, btns.size)
|
||||
}
|
||||
|
||||
fun `test responding state disables buttons`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm8",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
state = PermissionRequestState.RESPONDING,
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(view.runButtonForTest().isEnabled)
|
||||
assertFalse(view.denyButtonForTest().isEnabled)
|
||||
}
|
||||
|
||||
fun `test responding state shows responding message`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_responding",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
state = PermissionRequestState.RESPONDING,
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should show responding message, got: $text", text.contains("Sending response"))
|
||||
}
|
||||
|
||||
fun `test error state shows error message`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_error",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
message = "Boom",
|
||||
state = PermissionRequestState.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should show error message, got: $text", text.contains("Boom"))
|
||||
// ERROR state should keep buttons enabled so user can retry
|
||||
assertTrue(view.runButtonForTest().isEnabled)
|
||||
assertTrue(view.denyButtonForTest().isEnabled)
|
||||
}
|
||||
|
||||
fun `test error state shows fallback error text when no message`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_error_fallback",
|
||||
sessionId = "ses",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
message = null,
|
||||
state = PermissionRequestState.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should show fallback error text, got: $text", text.contains("Failed to send"))
|
||||
}
|
||||
|
||||
fun `test allow button uses bundle text and replies once`() {
|
||||
view.show(permission())
|
||||
|
||||
// run button (previously "Allow") should trigger once reply
|
||||
view.runButtonForTest().doClick()
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
}
|
||||
|
||||
fun `test deny button uses bundle text and rejects`() {
|
||||
view.show(permission())
|
||||
|
||||
view.denyButtonForTest().doClick()
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
}
|
||||
|
||||
// ------ shared card shell ------
|
||||
|
||||
fun `test view contains BaseSessionQuestionPanel after show`() {
|
||||
view.show(permission())
|
||||
|
||||
val panels = findAll<BaseQuestionView>(view)
|
||||
assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
|
||||
}
|
||||
|
||||
fun `test permission icon is rendered in header`() {
|
||||
view.show(permission())
|
||||
|
||||
val labels = findAll<JBLabel>(view)
|
||||
assertTrue(
|
||||
"Expected permission warning icon in header",
|
||||
labels.any { it.icon == AllIcons.General.Warning },
|
||||
)
|
||||
}
|
||||
|
||||
// ------ button types ------
|
||||
|
||||
fun `test run button uses default style key`() {
|
||||
view.show(permission())
|
||||
|
||||
val btn = view.runButtonForTest()
|
||||
assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
|
||||
}
|
||||
|
||||
fun `test deny button does not have default style key`() {
|
||||
view.show(permission())
|
||||
|
||||
val btn = view.denyButtonForTest()
|
||||
val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)
|
||||
assertTrue("Deny should not be primary", key == null || key == false)
|
||||
}
|
||||
|
||||
fun `test session question buttons use question surface background`() {
|
||||
view.show(permission())
|
||||
|
||||
assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background)
|
||||
assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background)
|
||||
}
|
||||
|
||||
// ------ code labels use editor style ------
|
||||
|
||||
fun `test code label uses editor font family after applyStyle`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_codefont",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "git log"),
|
||||
)
|
||||
)
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
|
||||
view.applyStyle(style)
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertNotNull("Should have at least one code label for command", labels.firstOrNull())
|
||||
assertEquals("Code label font family should use editor family", "Courier New", labels[0].font.name)
|
||||
}
|
||||
|
||||
fun `test permission header uses headerFont not editor font family`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_font",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "ls"),
|
||||
)
|
||||
)
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
|
||||
view.applyStyle(style)
|
||||
|
||||
val header = view.headerFontForTest()
|
||||
assertFalse("Permission header should not use editor font family", header.name == "Courier New")
|
||||
assertTrue("Permission header should be bold", header.isBold)
|
||||
assertEquals("Permission header should equal headerFont", style.headerFont, header)
|
||||
}
|
||||
|
||||
fun `test code label uses code background`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_bg",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "pwd"),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertFalse("Expected code labels", labels.isEmpty())
|
||||
assertEquals(SessionUiStyle.View.headerHover(), labels[0].background)
|
||||
}
|
||||
|
||||
private fun permission() = Permission(
|
||||
id = "perm1",
|
||||
sessionId = "ses_test",
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(),
|
||||
message = "Review file changes",
|
||||
)
|
||||
|
||||
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
|
||||
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
|
||||
if (comp is Container) item + buttons(comp) else item
|
||||
}
|
||||
|
||||
private fun allText(root: Container): String = buildString {
|
||||
fun collect(c: Container) {
|
||||
for (comp in c.components) {
|
||||
if (comp is javax.swing.text.JTextComponent) append(comp.text).append(" ")
|
||||
if (comp is javax.swing.JLabel) append(comp.text).append(" ")
|
||||
if (comp is AbstractButton) append(comp.text).append(" ")
|
||||
if (comp is Container) collect(comp)
|
||||
}
|
||||
}
|
||||
collect(root)
|
||||
}
|
||||
|
||||
private fun occurrences(text: String, token: String): Int {
|
||||
if (token.isEmpty()) return 0
|
||||
return text.split(token).size - 1
|
||||
}
|
||||
|
||||
private fun String.containsPath(path: String) = pathOccurrences(this, path) > 0
|
||||
|
||||
private fun pathOccurrences(text: String, path: String): Int = occurrences(text.replace("<wbr>", ""), path)
|
||||
|
||||
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
|
||||
|
||||
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
if (cls.isInstance(root)) result.add(cls.cast(root))
|
||||
for (child in root.components) {
|
||||
if (cls.isInstance(child)) result.add(cls.cast(child))
|
||||
if (child is Container && child !is AbstractButton) {
|
||||
result.addAll(findAllCls(child, cls))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+396
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
@@ -243,6 +243,7 @@ class MdViewTest : BasePlatformTestCase() {
|
||||
view.set("```\ncode\n```")
|
||||
val sheet = view.overrideSheet()
|
||||
assertTrue(sheet.contains("#0a0b0c"))
|
||||
assertTrue(sheet.contains("div.code-block"))
|
||||
assertTrue(sheet.contains("#d0e0f0"))
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,16 @@ sealed class ChatEventDto {
|
||||
|
||||
// --- Permission DTOs ---
|
||||
|
||||
@Serializable
|
||||
data class PermissionFileDiffDto(
|
||||
val file: String,
|
||||
val patch: String? = null,
|
||||
val before: String? = null,
|
||||
val after: String? = null,
|
||||
val additions: Int = 0,
|
||||
val deletions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PermissionRequestDto(
|
||||
val id: String,
|
||||
@@ -248,6 +258,11 @@ data class PermissionRequestDto(
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
val always: List<String> = emptyList(),
|
||||
val tool: ToolRefDto? = null,
|
||||
val message: String? = null,
|
||||
val command: String? = null,
|
||||
val rules: List<String> = emptyList(),
|
||||
val filePath: String? = null,
|
||||
val fileDiffs: List<PermissionFileDiffDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"outputs": []
|
||||
},
|
||||
"@kilocode/kilo-jetbrains#test:ci": {
|
||||
"dependsOn": ["@kilocode/kilo-jetbrains#typecheck"],
|
||||
"outputs": [".artifacts/unit/junit.xml"]
|
||||
},
|
||||
"@opencode-ai/ui#test": {
|
||||
|
||||
Reference in New Issue
Block a user