diff --git a/.changeset/jetbrains-question-carousel.md b/.changeset/jetbrains-question-carousel.md index 0e9b5f72bc2..943811f3f0f 100644 --- a/.changeset/jetbrains-question-carousel.md +++ b/.changeset/jetbrains-question-carousel.md @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 5194fa7dfd4..400a0fd9828 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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) }, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt index bd122b3e7ce..7b148aba512 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt @@ -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, 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): 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): 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 ─────────────────────────────────────────────────────── diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 522fc6f5a26..4b34a42b948 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,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>() private val rejects = mutableListOf() + 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(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(view).isEmpty()) } @@ -96,7 +100,7 @@ class QuestionViewTest : BasePlatformTestCase() { view.show(singleSelectQuestion("req_2")) // Select via radio button - findAll(view).first { it.text == "Minimal" }.doClick() + option(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(view).first { it.text == "Minimal" } - assertTrue("option label should be bold", radio.font.isBold) + val radio = option(view, "Minimal") + assertTrue("radio should keep text in aligned renderer", radio.text.isNullOrEmpty()) - // The description is a JBLabel with regular weight - val desc = findAll(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(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(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(view).first { it.text == "Minimal" }.doClick() + option(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(view).first { it.text == "Unit" }.doClick() + option(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(view).first { it.text == "Minimal" }.doClick() + option(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(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(view).first { it.text == "Balanced" }.doClick() + option(view, "Balanced").doClick() button(view, "Next").doClick() - findAll(view).first { it.text == "Unit" }.doClick() + option(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(view).first { it.text == "Minimal" }.doClick() + option(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(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(view).isEmpty()) + } + + fun `test selection requests scroll to bottom`() { + view.show(singleSelectQuestion("q_scroll")) + + option(view, "Minimal").doClick() + + assertEquals(1, scrolls) + } + + fun `test question navigation requests scroll to bottom`() { + view.show(twoItemQuestion("q_nav_scroll")) + + option(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(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(view).first { it.text == "B" }.doClick() + option(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(root).first { it.toolTipText == tooltip } + private inline fun option(root: Container, label: String): T = + findAll(root).first { it.actionCommand == label } + + private fun text(root: Container, value: String): JBTextArea = + findAll(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(root).any { it.text == text } + val found = findAll(root).any { it.text == text } || findAll(root).any { it.text == text } assertTrue("Expected label '$text' to be present", found) } private fun assertLabelsDoNotContain(root: Container, text: String) { - val found = findAll(root).any { it.text == text } + val found = findAll(root).any { it.text == text } || findAll(root).any { it.text == text } assertFalse("Expected label '$text' to be absent, but it was found", found) }