fix(jetbrains): align question prompt layout

This commit is contained in:
kirillk
2026-05-18 14:09:44 -04:00
parent 4ffcfabe1e
commit ad982ba51e
4 changed files with 233 additions and 85 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains question prompts one question at a time with radio-style option rows.
Show JetBrains question prompts one question at a time with aligned native option rows.
@@ -148,6 +148,7 @@ class SessionUi(
question = QuestionView(
reply = { id, dto -> controller.replyQuestion(id, dto) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
@@ -9,16 +9,21 @@ 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.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.icons.AllIcons
import com.intellij.openapi.util.IconLoader
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 com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Font
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import javax.swing.AbstractButton
import javax.swing.BoxLayout
import javax.swing.ButtonGroup
@@ -37,6 +42,7 @@ import javax.swing.JPanel
class QuestionView(
private val reply: (String, QuestionReplyDto) -> Unit,
private val reject: (String) -> Unit,
private val scroll: () -> Unit = {},
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
@@ -77,8 +83,11 @@ class QuestionView(
refresh()
}
override fun applyStyle(s: SessionEditorStyle) {
style = s
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
if (question == null) return
render()
refresh()
}
// ------ private rendering ------
@@ -103,35 +112,31 @@ class QuestionView(
val root = JPanel()
root.isOpaque = false
root.layout = BoxLayout(root, BoxLayout.Y_AXIS)
root.border = JBUI.Borders.empty(8, 12, 8, 12)
root.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
root.add(header(total))
root.add(body(item, set))
root.add(footer(item, set))
val head = header(total)
val body = body(item, set)
val foot = footer(item, set)
head.alignmentX = Component.LEFT_ALIGNMENT
body.alignmentX = Component.LEFT_ALIGNMENT
foot.alignmentX = Component.LEFT_ALIGNMENT
root.add(head)
root.add(body)
root.add(foot)
return root
}
// ── Header: icon + summary + nav buttons ──────────────────────────────────
// ── Header: summary + nav buttons ─────────────────────────────────────────
private fun header(total: Int): JPanel {
val row = JPanel(BorderLayout())
row.isOpaque = false
row.border = JBUI.Borders.emptyBottom(6)
val left = JPanel()
left.isOpaque = false
left.layout = BoxLayout(left, BoxLayout.X_AXIS)
val icon = JBLabel(AllIcons.General.QuestionDialog)
icon.border = JBUI.Borders.emptyRight(6)
left.add(icon)
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
val summary = JBLabel(KiloBundle.message("session.question.summary", idx + 1, total))
summary.foreground = UIUtil.getContextHelpForeground()
left.add(summary)
row.add(left, BorderLayout.WEST)
summary.foreground = UiStyle.Colors.weak()
row.add(summary, BorderLayout.WEST)
if (total > 1) {
row.add(navButtons(), BorderLayout.EAST)
@@ -146,14 +151,18 @@ class QuestionView(
nav.layout = BoxLayout(nav, BoxLayout.X_AXIS)
val back = HoverIcon().apply {
icon = AllIcons.Actions.Back
val ico = AllIcons.Actions.Back
icon = ico
disabledIcon = IconLoader.getDisabledIcon(ico)
toolTipText = KiloBundle.message("session.question.back")
isEnabled = idx > 0
addActionListener { goBack() }
}
val fwd = HoverIcon().apply {
icon = AllIcons.Actions.Forward
val ico = AllIcons.Actions.Forward
icon = ico
disabledIcon = IconLoader.getDisabledIcon(ico)
toolTipText = KiloBundle.message("session.question.next")
val q = question
isEnabled = q != null && idx < q.items.size - 1 && selections[idx].isNotEmpty()
@@ -172,18 +181,20 @@ class QuestionView(
panel.isOpaque = false
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
val title = JBLabel(item.question)
title.font = title.font.deriveFont(Font.BOLD)
title.border = JBUI.Borders.emptyBottom(2)
val title = text(item.question, UiStyle.Colors.fg(), true)
title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
title.alignmentX = Component.LEFT_ALIGNMENT
panel.add(title)
val hintKey = if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"
val hint = JBLabel(KiloBundle.message(hintKey))
hint.foreground = UIUtil.getContextHelpForeground()
hint.border = JBUI.Borders.emptyBottom(6)
val hint = text(KiloBundle.message(hintKey), UiStyle.Colors.weak())
hint.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
hint.alignmentX = Component.LEFT_ALIGNMENT
panel.add(hint)
panel.add(optionList(item, set))
val opts = optionList(item, set)
opts.alignmentX = Component.LEFT_ALIGNMENT
panel.add(opts)
return panel
}
@@ -211,8 +222,8 @@ class QuestionView(
// ── Option rows ───────────────────────────────────────────────────────────
private fun radioRow(opt: QuestionOption, set: MutableSet<String>, group: ButtonGroup): JPanel {
val radio = JBRadioButton(opt.label)
radio.font = radio.font.deriveFont(Font.BOLD)
val radio = JBRadioButton()
radio.actionCommand = opt.label
radio.isSelected = opt.label in set
radio.isOpaque = false
group.add(radio)
@@ -220,21 +231,21 @@ class QuestionView(
radio.addActionListener {
set.clear()
set.add(opt.label)
refreshNavButtons()
refreshSelection()
}
return optionRow(radio, opt)
}
private fun checkboxRow(opt: QuestionOption, set: MutableSet<String>): JPanel {
val box = JBCheckBox(opt.label)
box.font = box.font.deriveFont(Font.BOLD)
val box = JBCheckBox()
box.actionCommand = opt.label
box.isSelected = opt.label in set
box.isOpaque = false
box.addActionListener {
if (!set.remove(opt.label)) set.add(opt.label)
refreshNavButtons()
refreshSelection()
}
return optionRow(box, opt)
@@ -243,35 +254,90 @@ class QuestionView(
private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel {
val row = JPanel(BorderLayout())
row.isOpaque = false
row.border = JBUI.Borders.emptyBottom(2)
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
row.toolTipText = opt.description.ifBlank { null }
row.alignmentX = Component.LEFT_ALIGNMENT
if (opt.description.isNotBlank()) {
// Stack toggle (with label) on top, description label below
val col = JPanel()
col.isOpaque = false
col.layout = BoxLayout(col, BoxLayout.Y_AXIS)
col.add(toggle)
val desc = JBLabel(opt.description)
desc.foreground = UIUtil.getContextHelpForeground()
desc.border = JBUI.Borders.emptyLeft(toggle.insets.left + 4)
col.add(desc)
row.add(col, BorderLayout.CENTER)
} else {
row.add(toggle, BorderLayout.CENTER)
}
// make clicking anywhere on the row trigger the toggle
row.addMouseListener(object : java.awt.event.MouseAdapter() {
val press = object : java.awt.event.MouseAdapter() {
override fun mouseClicked(e: java.awt.event.MouseEvent) {
if (toggle.isEnabled) toggle.doClick()
}
})
}
val icon = JPanel(BorderLayout())
icon.isOpaque = false
icon.border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
icon.add(toggle, BorderLayout.NORTH)
row.add(icon, BorderLayout.WEST)
val col = JPanel()
col.isOpaque = false
col.layout = BoxLayout(col, BoxLayout.Y_AXIS)
val label = text(opt.label, UiStyle.Colors.fg(), true)
label.alignmentX = Component.LEFT_ALIGNMENT
col.add(label)
if (opt.description.isNotBlank()) {
val desc = text(opt.description, UiStyle.Colors.weak())
desc.alignmentX = Component.LEFT_ALIGNMENT
desc.addMouseListener(press)
col.add(desc)
}
row.add(col, BorderLayout.CENTER)
// make clicking anywhere on the row trigger the toggle
row.addMouseListener(press)
icon.addMouseListener(press)
col.addMouseListener(press)
label.addMouseListener(press)
return row
}
private fun text(value: String, color: Color, bold: Boolean = false): JBTextArea {
return object : JBTextArea(value) {
override fun getPreferredSize(): Dimension {
val width = space()
if (width <= 0) return super.getPreferredSize()
val old = size
setSize(width, Int.MAX_VALUE)
val size = super.getPreferredSize()
setSize(old)
return Dimension(width, size.height)
}
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
private fun space(): 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()
font = if (bold) style.boldEditorFont else style.transcriptFont
}
}
// ── Footer: Dismiss + Next/Submit ─────────────────────────────────────────
private fun footer(item: QuestionItem, set: MutableSet<String>): JPanel {
@@ -280,7 +346,7 @@ class QuestionView(
val row = JPanel(BorderLayout())
row.isOpaque = false
row.border = JBUI.Borders.emptyTop(8)
row.border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
val dismiss = JButton(KiloBundle.message("session.question.dismiss"))
dismiss.addActionListener { doReject() }
@@ -289,6 +355,7 @@ class QuestionView(
val rightLabel = if (last) KiloBundle.message("session.question.submit")
else KiloBundle.message("session.question.next")
val right = JButton(rightLabel)
right.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, last)
right.isEnabled = set.isNotEmpty()
right.addActionListener {
if (last) doReply()
@@ -306,6 +373,7 @@ class QuestionView(
idx--
render()
refresh()
scroll()
}
private fun goForward() {
@@ -315,13 +383,14 @@ class QuestionView(
idx++
render()
refresh()
scroll()
}
/** Refresh only the enabled state of nav buttons without a full re-render. */
private fun refreshNavButtons() {
private fun refreshSelection() {
// Re-render is cheap and keeps state consistent; do a full render.
render()
refresh()
scroll()
}
// ── Submit / reject ───────────────────────────────────────────────────────
@@ -3,14 +3,16 @@ 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.style.SessionEditorStyle
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.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBRadioButton
import com.intellij.ui.components.JBTextArea
import java.awt.Container
import java.awt.Font
import javax.swing.AbstractButton
import javax.swing.JButton
@@ -19,6 +21,7 @@ class QuestionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, QuestionReplyDto>>()
private val rejects = mutableListOf<String>()
private var scrolls = 0
private lateinit var view: QuestionView
override fun setUp() {
@@ -26,6 +29,7 @@ class QuestionViewTest : BasePlatformTestCase() {
view = QuestionView(
reply = { id, dto -> replies.add(id to dto) },
reject = { id -> rejects.add(id) },
scroll = { scrolls++ },
)
}
@@ -87,8 +91,8 @@ class QuestionViewTest : BasePlatformTestCase() {
val radios = findAll<JBRadioButton>(view)
assertEquals(2, radios.size)
assertEquals("Minimal", radios[0].text)
assertEquals("Balanced", radios[1].text)
assertEquals("Minimal", radios[0].actionCommand)
assertEquals("Balanced", radios[1].actionCommand)
assertTrue(findAll<JBCheckBox>(view).isEmpty())
}
@@ -96,7 +100,7 @@ class QuestionViewTest : BasePlatformTestCase() {
view.show(singleSelectQuestion("req_2"))
// Select via radio button
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
@@ -107,7 +111,7 @@ class QuestionViewTest : BasePlatformTestCase() {
// ------ option label and description ------
fun `test option row shows bold label and regular description`() {
fun `test option row aligns label and description beside button`() {
view.show(
Question(
id = "desc_test",
@@ -125,14 +129,33 @@ class QuestionViewTest : BasePlatformTestCase() {
)
)
// The option label is the radio button text (bold)
val radio = findAll<JBRadioButton>(view).first { it.text == "Minimal" }
assertTrue("option label should be bold", radio.font.isBold)
val radio = option<JBRadioButton>(view, "Minimal")
assertTrue("radio should keep text in aligned renderer", radio.text.isNullOrEmpty())
// The description is a JBLabel with regular weight
val desc = findAll<JBLabel>(view).firstOrNull { it.text == "Smallest safe change" }
assertNotNull("description label should be present", desc)
assertFalse("description should not be bold", desc!!.font.style == Font.BOLD)
val label = findAll<JBTextArea>(view).firstOrNull { it.text == "Minimal" }
assertNotNull("option label should be present", label)
assertTrue("option label should be bold", label!!.font.isBold)
assertTrue("option label should wrap", label.lineWrap)
val desc = findAll<JBTextArea>(view).firstOrNull { it.text == "Smallest safe change" }
assertNotNull("description should be present", desc)
assertTrue("description should wrap", desc!!.lineWrap)
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)
}
fun `test question title and hint use editor fonts`() {
view.show(singleSelectQuestion("q_fonts"))
val style = SessionEditorStyle.current()
val title = text(view, "Choose approach")
val hint = text(view, "Select one answer")
assertEquals(style.boldEditorFont, title.font)
assertEquals(style.transcriptFont, hint.font)
}
// ------ multi-question navigation ------
@@ -146,7 +169,7 @@ class QuestionViewTest : BasePlatformTestCase() {
assertLabelsContain(view, "1 of 2 questions")
// Select an answer on first question, then click Next
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
// Second question shown, first not
@@ -155,7 +178,7 @@ class QuestionViewTest : BasePlatformTestCase() {
assertLabelsContain(view, "2 of 2 questions")
// Select answer on second question, submit
findAll<JBRadioButton>(view).first { it.text == "Unit" }.doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
@@ -168,7 +191,7 @@ class QuestionViewTest : BasePlatformTestCase() {
view.show(twoItemQuestion("q_back"))
// Answer first question
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
// Go back via header nav icon
@@ -178,12 +201,12 @@ class QuestionViewTest : BasePlatformTestCase() {
assertLabelsContain(view, "Choose approach")
assertLabelsContain(view, "1 of 2 questions")
val radios = findAll<JBRadioButton>(view)
assertTrue("Minimal should still be selected", radios.first { it.text == "Minimal" }.isSelected)
assertTrue("Minimal should still be selected", radios.first { it.actionCommand == "Minimal" }.isSelected)
// Change selection to Balanced, go forward, submit
findAll<JBRadioButton>(view).first { it.text == "Balanced" }.doClick()
option<JBRadioButton>(view, "Balanced").doClick()
button(view, "Next").doClick()
findAll<JBRadioButton>(view).first { it.text == "Unit" }.doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers)
@@ -195,13 +218,62 @@ class QuestionViewTest : BasePlatformTestCase() {
val next = button(view, "Next")
assertFalse("Next should be disabled before selection", next.isEnabled)
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
option<JBRadioButton>(view, "Minimal").doClick()
// Re-render happened; get fresh reference
val nextAfter = button(view, "Next")
assertTrue("Next should be enabled after selection", nextAfter.isEnabled)
}
fun `test header nav disables unavailable directions`() {
view.show(twoItemQuestion("q_nav_disabled"))
assertFalse("Back should be disabled on first question", navButton(view, "Back").isEnabled)
assertNotNull("Back should have disabled icon", navButton(view, "Back").disabledIcon)
assertFalse("Forward should be disabled before selection", navButton(view, "Next").isEnabled)
assertNotNull("Forward should have disabled icon", navButton(view, "Next").disabledIcon)
option<JBRadioButton>(view, "Minimal").doClick()
assertTrue("Forward should be enabled after selection", navButton(view, "Next").isEnabled)
button(view, "Next").doClick()
assertTrue("Back should be enabled on second question", navButton(view, "Back").isEnabled)
assertFalse("Forward should be disabled on last question", navButton(view, "Next").isEnabled)
}
fun `test submit button uses default style`() {
view.show(singleSelectQuestion("q_default"))
val submit = button(view, "Submit")
assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
}
fun `test single question hides header nav`() {
view.show(singleSelectQuestion("q_single"))
assertTrue(findAll<HoverIcon>(view).isEmpty())
}
fun `test selection requests scroll to bottom`() {
view.show(singleSelectQuestion("q_scroll"))
option<JBRadioButton>(view, "Minimal").doClick()
assertEquals(1, scrolls)
}
fun `test question navigation requests scroll to bottom`() {
view.show(twoItemQuestion("q_nav_scroll"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
navButton(view, "Back").doClick()
assertEquals(3, scrolls)
}
// ------ multi-select checkboxes ------
fun `test multiple selection item uses checkboxes and toggles options`() {
@@ -228,10 +300,10 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals(3, boxes.size)
assertTrue(findAll<JBRadioButton>(view).isEmpty())
boxes.first { it.text == "A" }.doClick()
boxes.first { it.text == "B" }.doClick()
boxes.first { it.actionCommand == "A" }.doClick()
boxes.first { it.actionCommand == "B" }.doClick()
// Toggle B off — need fresh refs after re-render
findAll<JBCheckBox>(view).first { it.text == "B" }.doClick()
option<JBCheckBox>(view, "B").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
@@ -253,6 +325,12 @@ class QuestionViewTest : BasePlatformTestCase() {
private fun navButton(root: Container, tooltip: String): HoverIcon =
findAll<HoverIcon>(root).first { it.toolTipText == tooltip }
private inline fun <reified T : AbstractButton> option(root: Container, label: String): T =
findAll<T>(root).first { it.actionCommand == label }
private fun text(root: Container, value: String): JBTextArea =
findAll<JBTextArea>(root).first { it.text == value }
private fun singleSelectQuestion(id: String) = Question(
id = id,
items = listOf(
@@ -296,12 +374,12 @@ class QuestionViewTest : BasePlatformTestCase() {
)
private fun assertLabelsContain(root: Container, text: String) {
val found = findAll<JBLabel>(root).any { it.text == text }
val found = findAll<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
assertTrue("Expected label '$text' to be present", found)
}
private fun assertLabelsDoNotContain(root: Container, text: String) {
val found = findAll<JBLabel>(root).any { it.text == text }
val found = findAll<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
assertFalse("Expected label '$text' to be absent, but it was found", found)
}