feat(jetbrains): show questions one at a time with radio/checkbox rows

Refactor QuestionView from a flat all-at-once layout to a carousel that
shows one question per screen, matching the VS Code question dock pattern:

- Shows 'X of Y questions' summary in the card header
- Back/forward HoverIcon nav buttons in the header; forward disabled until
  the current question has an answer
- Single-select items render as JBRadioButton rows (bold label + description)
- Multi-select items render as JBCheckBox rows
- Footer: Dismiss on the left; Next (middle questions) or Submit (last)
- Selections survive back/forward navigation
- Tests updated: radio/checkbox discovery, navigation, back/selection
  preservation, and disabled-next guard
This commit is contained in:
kirillk
2026-05-18 11:29:51 -04:00
parent ae31cf218e
commit 4ffcfabe1e
4 changed files with 490 additions and 121 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains question prompts one question at a time with radio-style option rows.
@@ -3,26 +3,36 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
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.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.rpc.dto.QuestionReplyDto
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.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBRadioButton
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 javax.swing.AbstractButton
import javax.swing.BoxLayout
import javax.swing.ButtonGroup
import javax.swing.JButton
import javax.swing.JPanel
/**
* Transcript-style question 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.AwaitingQuestion].
*
* Unlike the old docked [ai.kilocode.client.session.ui.QuestionPanel], this view lives inside
* the scrollable transcript so the user can scroll through prior messages while a question is active.
* Shows one [QuestionItem] at a time as a carousel with back/forward navigation.
* Single-select items render as radio-button rows; multi-select as checkbox rows.
* Each option shows a bold label and a regular description.
*/
class QuestionView(
private val reply: (String, QuestionReplyDto) -> Unit,
@@ -31,6 +41,9 @@ class QuestionView(
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var question: Question? = null
private var idx = 0
private var selections = emptyList<MutableSet<String>>()
private var style = SessionEditorStyle.current()
init {
@@ -38,16 +51,43 @@ class QuestionView(
isVisible = false
}
/** Populate the view for all items in [question] and make it visible. */
fun show(question: Question) {
if (question.items.isEmpty()) {
/** Populate the view for [q] and make it visible, starting at the first question. */
fun show(q: Question) {
if (q.items.isEmpty()) {
hideView()
return
}
requestId = question.id
requestId = q.id
question = q
idx = 0
selections = List(q.items.size) { mutableSetOf() }
render()
isVisible = true
refresh()
}
// Per-item selected answers: index → mutable set of selected labels
val selections = Array(question.items.size) { mutableSetOf<String>() }
/** Hide this view and clear all active request state. */
fun hideView() {
requestId = null
question = null
idx = 0
selections = emptyList()
removeAll()
isVisible = false
refresh()
}
override fun applyStyle(s: SessionEditorStyle) {
style = s
}
// ------ private rendering ------
private fun render() {
val q = question ?: return
val item = q.items[idx]
val total = q.items.size
val set = selections[idx]
removeAll()
@@ -55,72 +95,240 @@ class QuestionView(
card.isOpaque = true
card.background = SessionUiStyle.View.surface()
card.border = SessionUiStyle.View.card()
card.add(panel {
for ((idx, item) in question.items.withIndex()) {
if (idx > 0) row { }.topGap(TopGap.SMALL)
row {
icon(AllIcons.General.QuestionDialog).gap(RightGap.SMALL)
label(item.header).bold()
}
row {
label(item.question)
}
row {
for (opt in item.options) {
button(opt.label) {
toggleOption(selections, idx, item, opt.label)
}
.gap(RightGap.SMALL)
.applyToComponent { toolTipText = opt.description }
}
}.layout(RowLayout.INDEPENDENT)
}
row {
button(KiloBundle.message("session.question.submit")) {
doReply(selections.map { it.toList() })
}.gap(RightGap.SMALL)
button(KiloBundle.message("session.question.dismiss")) { doReject() }
}.layout(RowLayout.INDEPENDENT).topGap(TopGap.SMALL)
}.also { it.isOpaque = false }, BorderLayout.CENTER)
card.add(buildContent(item, total, set), 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()
private fun buildContent(item: QuestionItem, total: Int, set: MutableSet<String>): JPanel {
val root = JPanel()
root.isOpaque = false
root.layout = BoxLayout(root, BoxLayout.Y_AXIS)
root.border = JBUI.Borders.empty(8, 12, 8, 12)
root.add(header(total))
root.add(body(item, set))
root.add(footer(item, set))
return root
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
}
// ── Header: icon + summary + nav buttons ──────────────────────────────────
private fun toggleOption(
selections: Array<MutableSet<String>>,
idx: Int,
item: QuestionItem,
label: String,
) {
val set = selections[idx]
if (item.multiple) {
if (!set.remove(label)) set.add(label)
} else {
set.clear()
set.add(label)
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)
val summary = JBLabel(KiloBundle.message("session.question.summary", idx + 1, total))
summary.foreground = UIUtil.getContextHelpForeground()
left.add(summary)
row.add(left, BorderLayout.WEST)
if (total > 1) {
row.add(navButtons(), BorderLayout.EAST)
}
return row
}
private fun doReply(answers: List<List<String>>) {
private fun navButtons(): JPanel {
val nav = JPanel()
nav.isOpaque = false
nav.layout = BoxLayout(nav, BoxLayout.X_AXIS)
val back = HoverIcon().apply {
icon = AllIcons.Actions.Back
toolTipText = KiloBundle.message("session.question.back")
isEnabled = idx > 0
addActionListener { goBack() }
}
val fwd = HoverIcon().apply {
icon = AllIcons.Actions.Forward
toolTipText = KiloBundle.message("session.question.next")
val q = question
isEnabled = q != null && idx < q.items.size - 1 && selections[idx].isNotEmpty()
addActionListener { goForward() }
}
nav.add(back)
nav.add(fwd)
return nav
}
// ── Body: question text + hint + options ──────────────────────────────────
private fun body(item: QuestionItem, set: MutableSet<String>): JPanel {
val panel = JPanel()
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)
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)
panel.add(hint)
panel.add(optionList(item, set))
return panel
}
private fun optionList(item: QuestionItem, set: MutableSet<String>): JPanel {
val panel = JPanel()
panel.isOpaque = false
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
if (item.multiple) {
for (opt in item.options) {
panel.add(checkboxRow(opt, set))
}
} else {
val group = ButtonGroup()
for (opt in item.options) {
val row = radioRow(opt, set, group)
panel.add(row)
}
}
return panel
}
// ── 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)
radio.isSelected = opt.label in set
radio.isOpaque = false
group.add(radio)
radio.addActionListener {
set.clear()
set.add(opt.label)
refreshNavButtons()
}
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)
box.isSelected = opt.label in set
box.isOpaque = false
box.addActionListener {
if (!set.remove(opt.label)) set.add(opt.label)
refreshNavButtons()
}
return optionRow(box, opt)
}
private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel {
val row = JPanel(BorderLayout())
row.isOpaque = false
row.border = JBUI.Borders.emptyBottom(2)
row.toolTipText = opt.description.ifBlank { null }
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() {
override fun mouseClicked(e: java.awt.event.MouseEvent) {
if (toggle.isEnabled) toggle.doClick()
}
})
return row
}
// ── Footer: Dismiss + Next/Submit ─────────────────────────────────────────
private fun footer(item: QuestionItem, set: MutableSet<String>): JPanel {
val q = question ?: return JPanel()
val last = idx == q.items.size - 1
val row = JPanel(BorderLayout())
row.isOpaque = false
row.border = JBUI.Borders.emptyTop(8)
val dismiss = JButton(KiloBundle.message("session.question.dismiss"))
dismiss.addActionListener { doReject() }
row.add(dismiss, BorderLayout.WEST)
val rightLabel = if (last) KiloBundle.message("session.question.submit")
else KiloBundle.message("session.question.next")
val right = JButton(rightLabel)
right.isEnabled = set.isNotEmpty()
right.addActionListener {
if (last) doReply()
else goForward()
}
row.add(right, BorderLayout.EAST)
return row
}
// ── Navigation ────────────────────────────────────────────────────────────
private fun goBack() {
if (idx <= 0) return
idx--
render()
refresh()
}
private fun goForward() {
val q = question ?: return
if (idx >= q.items.size - 1) return
if (selections[idx].isEmpty()) return
idx++
render()
refresh()
}
/** Refresh only the enabled state of nav buttons without a full re-render. */
private fun refreshNavButtons() {
// Re-render is cheap and keeps state consistent; do a full render.
render()
refresh()
}
// ── Submit / reject ───────────────────────────────────────────────────────
private fun doReply() {
val id = requestId ?: return
reply(id, QuestionReplyDto(answers))
reply(id, QuestionReplyDto(selections.map { it.toList() }))
hideView()
}
@@ -19,6 +19,11 @@ session.permission.allow=Allow
session.permission.deny=Deny
session.question.dismiss=Dismiss
session.question.submit=Submit
session.question.next=Next
session.question.back=Back
session.question.summary={0} of {1} questions
session.question.hint.single=Select one answer
session.question.hint.multi=Select one or more answers
session.status.considering=Considering next steps…
session.status.thinking=Thinking…
@@ -3,9 +3,15 @@ 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.ui.HoverIcon
import ai.kilocode.rpc.dto.QuestionReplyDto
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 java.awt.Container
import java.awt.Font
import javax.swing.AbstractButton
import javax.swing.JButton
@Suppress("UnstableApiUsage")
@@ -23,6 +29,8 @@ class QuestionViewTest : BasePlatformTestCase() {
)
}
// ------ empty question ------
fun `test empty question hides view and clears stale request id`() {
view.show(
Question(
@@ -47,6 +55,8 @@ class QuestionViewTest : BasePlatformTestCase() {
assertTrue(rejects.isEmpty())
}
// ------ dismiss ------
fun `test dismiss button uses bundle text and rejects question`() {
view.show(
Question(
@@ -63,24 +73,50 @@ class QuestionViewTest : BasePlatformTestCase() {
)
)
buttons(view).first { it.text == "Dismiss" }.doClick()
button(view, "Dismiss").doClick()
assertFalse(view.isVisible)
assertEquals("req_1", rejects.single())
assertTrue(replies.isEmpty())
}
fun `test single question submit sends answer`() {
// ------ radio options ------
fun `test single question renders radio options`() {
view.show(singleSelectQuestion("req_r"))
val radios = findAll<JBRadioButton>(view)
assertEquals(2, radios.size)
assertEquals("Minimal", radios[0].text)
assertEquals("Balanced", radios[1].text)
assertTrue(findAll<JBCheckBox>(view).isEmpty())
}
fun `test single question submit sends selected answer`() {
view.show(singleSelectQuestion("req_2"))
// Select via radio button
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
}
// ------ option label and description ------
fun `test option row shows bold label and regular description`() {
view.show(
Question(
id = "req_2",
id = "desc_test",
items = listOf(
QuestionItem(
question = "Choose approach",
question = "How to proceed?",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Keep it simple"),
QuestionOption("Refactor", "Full refactor"),
QuestionOption("Minimal", "Smallest safe change"),
),
multiple = false,
custom = false,
@@ -89,55 +125,86 @@ class QuestionViewTest : BasePlatformTestCase() {
)
)
buttons(view).first { it.text == "Minimal" }.doClick()
buttons(view).first { it.text == "Submit" }.doClick()
// 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)
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
// 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)
}
fun `test multi question submit sends all answers`() {
view.show(
Question(
id = "q_strategy",
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Keep it simple"),
QuestionOption("Refactor", "Full refactor"),
),
multiple = false,
custom = false,
),
QuestionItem(
question = "Choose test level",
header = "Test Level",
options = listOf(
QuestionOption("Unit", "Unit tests"),
QuestionOption("Integration", "Integration tests"),
),
multiple = false,
custom = false,
),
),
)
)
// ------ multi-question navigation ------
buttons(view).first { it.text == "Minimal" }.doClick()
buttons(view).first { it.text == "Unit" }.doClick()
buttons(view).first { it.text == "Submit" }.doClick()
fun `test multi question shows one question at a time and navigates`() {
view.show(twoItemQuestion("q_nav"))
// First question shown, second not
assertLabelsContain(view, "Choose approach")
assertLabelsDoNotContain(view, "Choose test level")
assertLabelsContain(view, "1 of 2 questions")
// Select an answer on first question, then click Next
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
button(view, "Next").doClick()
// Second question shown, first not
assertLabelsContain(view, "Choose test level")
assertLabelsDoNotContain(view, "Choose approach")
assertLabelsContain(view, "2 of 2 questions")
// Select answer on second question, submit
findAll<JBRadioButton>(view).first { it.text == "Unit" }.doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("q_strategy", replies.single().first)
assertEquals("q_nav", replies.single().first)
assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers)
}
fun `test multiple selection item toggles options`() {
fun `test back preserves previous selection`() {
view.show(twoItemQuestion("q_back"))
// Answer first question
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
button(view, "Next").doClick()
// Go back via header nav icon
navButton(view, "Back").doClick()
// First question visible again, selection preserved
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)
// Change selection to Balanced, go forward, submit
findAll<JBRadioButton>(view).first { it.text == "Balanced" }.doClick()
button(view, "Next").doClick()
findAll<JBRadioButton>(view).first { it.text == "Unit" }.doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers)
}
fun `test next is disabled until current question is answered`() {
view.show(twoItemQuestion("q_disabled"))
val next = button(view, "Next")
assertFalse("Next should be disabled before selection", next.isEnabled)
findAll<JBRadioButton>(view).first { it.text == "Minimal" }.doClick()
// Re-render happened; get fresh reference
val nextAfter = button(view, "Next")
assertTrue("Next should be enabled after selection", nextAfter.isEnabled)
}
// ------ multi-select checkboxes ------
fun `test multiple selection item uses checkboxes and toggles options`() {
view.show(
Question(
id = "req_3",
@@ -157,11 +224,15 @@ class QuestionViewTest : BasePlatformTestCase() {
)
)
buttons(view).first { it.text == "A" }.doClick()
buttons(view).first { it.text == "B" }.doClick()
// Toggle B off
buttons(view).first { it.text == "B" }.doClick()
buttons(view).first { it.text == "Submit" }.doClick()
val boxes = findAll<JBCheckBox>(view)
assertEquals(3, boxes.size)
assertTrue(findAll<JBRadioButton>(view).isEmpty())
boxes.first { it.text == "A" }.doClick()
boxes.first { it.text == "B" }.doClick()
// Toggle B off — need fresh refs after re-render
findAll<JBCheckBox>(view).first { it.text == "B" }.doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
@@ -169,8 +240,88 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals(listOf(listOf("A")), replies.single().second.answers)
}
private fun buttons(root: Container): List<JButton> = root.components.flatMap { comp ->
val item = if (comp is JButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
// ------ helpers ------
/**
* Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit).
* For icon-only nav buttons (Back/Forward) that use tooltip, use [navButton].
*/
private fun button(root: Container, text: String): JButton =
findAll<JButton>(root).first { it.text == text }
/** Find a [HoverIcon] nav button by tooltip text (Back / Next nav arrows). */
private fun navButton(root: Container, tooltip: String): HoverIcon =
findAll<HoverIcon>(root).first { it.toolTipText == tooltip }
private fun singleSelectQuestion(id: String) = Question(
id = id,
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Smallest safe change"),
QuestionOption("Balanced", "Focused implementation with tests"),
),
multiple = false,
custom = false,
)
),
)
private fun twoItemQuestion(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 = false,
),
QuestionItem(
question = "Choose test level",
header = "Test Level",
options = listOf(
QuestionOption("Unit", "Unit tests"),
QuestionOption("Integration", "Integration tests"),
),
multiple = false,
custom = false,
),
),
)
private fun assertLabelsContain(root: Container, text: String) {
val found = findAll<JBLabel>(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 }
assertFalse("Expected label '$text' to be absent, but it was found", found)
}
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
/**
* Recursively find all components of type [cls], but do NOT recurse into
* [AbstractButton] subtypes — buttons may have internal sub-components
* (e.g. IntelliJ UI delegate children) that would produce spurious matches.
*/
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))
// Do not recurse into button internals to avoid double-counting
if (child is Container && child !is AbstractButton) {
result.addAll(findAllCls(child, cls))
}
}
return result
}
}