feat(jetbrains): add review step and answered-question card for question tool parity

Multi-question prompts now show a Review pseudo-step before submitting,
listing all questions and selected answers (matching VS Code behavior).
Completed question tool parts render as a structured 'Questions / N answered'
card instead of raw output text.
This commit is contained in:
kirillk
2026-05-18 15:15:32 -04:00
parent 5051a595fe
commit 276276bbc6
8 changed files with 846 additions and 27 deletions
@@ -85,6 +85,10 @@ class MessageView(
}
val existing = parts[content.id]
if (existing != null) {
if (ViewFactory.shouldReplace(existing, content)) {
replacePart(content, existing)
return
}
existing.update(content)
refresh()
return
@@ -97,6 +101,18 @@ class MessageView(
refresh()
}
private fun replacePart(content: Content, existing: PartView) {
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
remove(existing)
val view = ViewFactory.create(content)
view.applyStyle(style)
parts[content.id] = view
add(view, at)
syncBorder()
refresh()
}
/** Remove the renderer for [contentId] if present. */
fun removePart(contentId: String) {
val view = parts.remove(contentId) ?: return
@@ -0,0 +1,301 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
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.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.SwingUtilities
private data class QuestionResult(
val questions: List<String>,
val answers: List<List<String>>,
)
private val json = Json { ignoreUnknownKeys = true }
private fun parseQuestions(raw: String): List<String>? {
val arr = runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return null
val list = arr.mapNotNull { elem ->
elem.jsonObject["question"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
}
return list.takeIf { it.isNotEmpty() }
}
private fun parseAnswers(raw: String?): List<List<String>> {
if (raw.isNullOrBlank()) return emptyList()
val arr = runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
return arr.map { elem ->
runCatching {
elem.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
}.getOrDefault(emptyList())
}
}
private fun parse(tool: Tool): QuestionResult? {
if (tool.name != "question") return null
if (tool.state != ToolExecState.COMPLETED) return null
val raw = tool.input["questions"] ?: return null
val questions = parseQuestions(raw) ?: return null
val answers = parseAnswers(tool.metadata["answers"])
return QuestionResult(questions, answers)
}
/**
* Renders completed `question` tool parts as a structured answered-question card.
*
* Shows each question with its selected answer(s). Hides raw output text.
* Falls back to [ToolView] when structured data cannot be parsed.
*/
class QuestionResultView(tool: Tool) : PartView() {
override val contentId: String = tool.id
private var result = parse(tool) ?: QuestionResult(emptyList(), emptyList())
private var style = SessionEditorStyle.current()
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val glyph = JBLabel(AllIcons.General.Balloon)
private val title = JBLabel()
private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val arrow = JBLabel()
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private val body = JPanel().apply {
isOpaque = true
background = SessionUiStyle.View.surface()
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
// Text components tracked for font updates
private val textComponents = mutableListOf<JBTextArea>()
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { toggle() }
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { setHover(true) }
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(sub, BorderLayout.CENTER)
header.add(glyph, BorderLayout.WEST)
header.add(center, BorderLayout.CENTER)
header.add(arrow, BorderLayout.EAST)
root.add(header, BorderLayout.NORTH)
listOf(header, glyph, title, sub, arrow, center).forEach {
it.addMouseListener(click)
it.addMouseListener(mouse)
it.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
applyStyle(SessionEditorStyle.current())
add(root, BorderLayout.CENTER)
syncLabels()
rebuildBody()
// Default expanded: add body
root.add(body, BorderLayout.CENTER)
syncArrow()
}
override fun update(content: Content) {
if (content !is Tool) return
val parsed = parse(content)
result = parsed ?: QuestionResult(emptyList(), emptyList())
syncLabels()
rebuildBody()
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
setFont(title, style.boldEditorFont)
setFont(sub, style.smallEditorFont)
for (ta in textComponents) {
val bold = ta.font?.isBold == true
ta.font = if (bold) style.boldEditorFont else style.transcriptFont
}
refresh()
}
fun toggle() {
val expanded = isExpanded()
if (expanded) root.remove(body) else root.add(body, BorderLayout.CENTER)
syncArrow()
refresh()
}
fun isExpanded(): Boolean = body.parent === root
/** Label text for test assertions — title + sub joined. */
fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
/** Body text for test assertions — all text areas joined. */
fun bodyText(): String = textComponents.joinToString("\n") { it.text }
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
companion object {
fun canRender(tool: Tool): Boolean = parse(tool) != null
}
// ── Private helpers ───────────────────────────────────────────────────────
private fun syncLabels() {
title.text = KiloBundle.message("session.question.result.title")
val count = result.answers.count { it.isNotEmpty() }
sub.text = KiloBundle.message("session.question.result.answered", count)
}
private fun rebuildBody() {
body.removeAll()
textComponents.clear()
for ((i, q) in result.questions.withIndex()) {
val row = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = Component.LEFT_ALIGNMENT
}
if (i > 0) {
row.border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
}
val qText = makeText(q, UiStyle.Colors.weak(), false)
qText.alignmentX = Component.LEFT_ALIGNMENT
row.add(qText)
val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty()
val aText = makeText(
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
UiStyle.Colors.fg(),
true,
)
aText.alignmentX = Component.LEFT_ALIGNMENT
row.add(aText)
body.add(row)
}
}
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
val ta = 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
}
textComponents.add(ta)
return ta
}
private fun syncArrow() {
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (header.background?.rgb == color.rgb) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun setFont(label: JBLabel, font: Font): Boolean {
if (label.font == font) return false
label.font = font
return true
}
private fun refresh() {
revalidate()
repaint()
}
}
@@ -38,6 +38,10 @@ import javax.swing.JPanel
* 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.
*
* For multi-question prompts (or a single multi-select item), a review pseudo-step
* is inserted after the last real question. The review step lists all questions
* and their selected answers before the final Submit.
*/
class QuestionView(
private val reply: (String, QuestionReplyDto) -> Unit,
@@ -94,9 +98,7 @@ class QuestionView(
private fun render() {
val q = question ?: return
val item = q.items[idx]
val total = q.items.size
val set = selections[idx]
removeAll()
@@ -104,7 +106,9 @@ class QuestionView(
card.isOpaque = true
card.background = SessionUiStyle.View.surface()
card.border = SessionUiStyle.View.card()
card.add(buildContent(item, total, set), BorderLayout.CENTER)
val node = if (review(q)) buildReview(q) else buildContent(q.items[idx], total, selections[idx])
card.add(node, BorderLayout.CENTER)
add(card, BorderLayout.CENTER)
}
@@ -116,7 +120,26 @@ class QuestionView(
val head = header(total)
val body = body(item, set)
val foot = footer(item, set)
val foot = footer()
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
}
private fun buildReview(q: Question): JPanel {
val root = JPanel()
root.isOpaque = false
root.layout = BoxLayout(root, BoxLayout.Y_AXIS)
root.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
val head = header(q.items.size)
val body = reviewBody(q)
val foot = footer()
head.alignmentX = Component.LEFT_ALIGNMENT
body.alignmentX = Component.LEFT_ALIGNMENT
foot.alignmentX = Component.LEFT_ALIGNMENT
@@ -134,7 +157,8 @@ class QuestionView(
row.isOpaque = false
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
val summary = JBLabel(KiloBundle.message("session.question.summary", idx + 1, total))
val shown = minOf(idx + 1, total)
val summary = JBLabel(KiloBundle.message("session.question.summary", shown, total))
summary.foreground = UiStyle.Colors.weak()
row.add(summary, BorderLayout.WEST)
@@ -165,7 +189,7 @@ class QuestionView(
disabledIcon = IconLoader.getDisabledIcon(ico)
toolTipText = KiloBundle.message("session.question.next")
val q = question
isEnabled = q != null && idx < q.items.size - 1 && selections[idx].isNotEmpty()
isEnabled = q != null && idx < q.items.size && selections.getOrNull(idx)?.isNotEmpty() == true
addActionListener { goForward() }
}
@@ -199,6 +223,48 @@ class QuestionView(
return panel
}
// ── Review body ───────────────────────────────────────────────────────────
private fun reviewBody(q: Question): JPanel {
val panel = JPanel()
panel.isOpaque = false
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
val title = text(KiloBundle.message("session.question.review.title"), UiStyle.Colors.fg(), true)
title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
title.alignmentX = Component.LEFT_ALIGNMENT
panel.add(title)
for ((i, item) in q.items.withIndex()) {
val row = reviewRow(item, i)
row.alignmentX = Component.LEFT_ALIGNMENT
panel.add(row)
}
return panel
}
private fun reviewRow(item: QuestionItem, i: Int): JPanel {
val row = JPanel()
row.isOpaque = false
row.layout = BoxLayout(row, BoxLayout.Y_AXIS)
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
val question = text(item.question, UiStyle.Colors.weak())
question.alignmentX = Component.LEFT_ALIGNMENT
row.add(question)
val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty()
val answer = text(
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
UiStyle.Colors.fg(),
true,
)
answer.alignmentX = Component.LEFT_ALIGNMENT
row.add(answer)
return row
}
private fun optionList(item: QuestionItem, set: MutableSet<String>): JPanel {
val panel = JPanel()
panel.isOpaque = false
@@ -338,11 +404,10 @@ class QuestionView(
}
}
// ── Footer: Dismiss + Next/Submit ─────────────────────────────────────────
// ── Footer: Dismiss + Back/Next/Review/Submit ─────────────────────────────
private fun footer(item: QuestionItem, set: MutableSet<String>): JPanel {
private fun footer(): JPanel {
val q = question ?: return JPanel()
val last = idx == q.items.size - 1
val row = JPanel(BorderLayout())
row.isOpaque = false
@@ -352,20 +417,61 @@ class QuestionView(
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.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, last)
right.isEnabled = set.isNotEmpty()
right.addActionListener {
if (last) doReply()
else goForward()
if (review(q)) {
// Review page: Back + Submit
val right = JPanel()
right.isOpaque = false
right.layout = BoxLayout(right, BoxLayout.X_AXIS)
val back = JButton(KiloBundle.message("session.question.back"))
back.addActionListener { goBack() }
right.add(back)
right.add(javax.swing.Box.createHorizontalStrut(JBUI.scale(UiStyle.Gap.sm())))
val submit = JButton(KiloBundle.message("session.question.submit"))
submit.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
submit.addActionListener { doReply() }
right.add(submit)
row.add(right, BorderLayout.EAST)
} 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 primary = direct(q) || lastItem(q)
val right = JButton(label)
right.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, primary)
right.isEnabled = selections.getOrNull(idx)?.isNotEmpty() == true
right.addActionListener {
when {
direct(q) -> doReply()
lastItem(q) -> goReview()
else -> goForward()
}
}
row.add(right, BorderLayout.EAST)
}
row.add(right, BorderLayout.EAST)
return row
}
// ── State predicates ──────────────────────────────────────────────────────
/** True for a single non-multiple question — direct submit, no review step. */
private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple
/** True when we are on the review pseudo-step. */
private fun review(q: Question): Boolean = !single(q) && idx == q.items.size
/** True when we are on the last real question page. */
private fun lastItem(q: Question): Boolean = idx == q.items.size - 1
/** True for a single non-multiple question — direct submit, no review step. */
private fun direct(q: Question): Boolean = single(q)
// ── Navigation ────────────────────────────────────────────────────────────
private fun goBack() {
@@ -378,14 +484,28 @@ class QuestionView(
private fun goForward() {
val q = question ?: return
if (idx >= q.items.size - 1) return
if (selections[idx].isEmpty()) return
if (idx >= q.items.size) return
if (selections.getOrNull(idx)?.isEmpty() != false) return
if (idx == q.items.size - 1 && !direct(q)) {
goReview()
return
}
idx++
render()
refresh()
scroll()
}
private fun goReview() {
val q = question ?: return
if (idx != q.items.size - 1) return
if (selections[idx].isEmpty()) return
idx = q.items.size
render()
refresh()
scroll()
}
private fun refreshSelection() {
// Re-render is cheap and keeps state consistent; do a full render.
render()
@@ -20,9 +20,21 @@ object ViewFactory {
fun create(content: Content): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
is Tool -> ToolView(content)
is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content)
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
}
/**
* Returns true when [view] must be replaced by a new renderer for [content].
* This happens when a running question tool (rendered as [ToolView]) completes
* with structured data and should become a [QuestionResultView].
*/
fun shouldReplace(view: PartView, content: Content): Boolean {
if (content !is Tool) return false
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
if (view is ToolView) return QuestionResultView.canRender(content)
return false
}
}
@@ -24,6 +24,11 @@ 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.question.review=Review
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.status.considering=Considering next steps…
session.status.thinking=Thinking…
@@ -10,6 +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.PermissionView
import ai.kilocode.client.session.views.QuestionResultView
import ai.kilocode.client.session.views.QuestionView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.ToolView
@@ -368,6 +369,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
// completed state — must NOT be suppressed even when callId matches
// No structured input/metadata so it renders as ToolView
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "completed"))
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
@@ -377,6 +379,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.part("tp1") is ToolView)
}
fun `test completed question update replaces generic tool view with question result view`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
// Running question tool — no structured data yet, renders as ToolView
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "running"))
val mv = item.findMessage("a1")!!
assertTrue("Running question tool should be ToolView", mv.part("tp1") is ToolView)
// Complete with structured data — should replace ToolView with QuestionResultView
model.updateContent(
"a1",
toolPart(
"tp1", "a1", "question", "call1", state = "completed",
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
),
)
assertTrue("Completed question with data should be QuestionResultView", mv.part("tp1") is QuestionResultView)
assertEquals(listOf("tp1"), mv.partIds())
}
// ------ helpers ------
private fun panelWithPrompts(): SessionMessageListPanel {
@@ -435,7 +460,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
id = id, sessionID = "ses", messageID = mid, type = type, text = text,
)
private fun toolPart(id: String, mid: String, tool: String, callId: String, state: String = "running") = PartDto(
private fun toolPart(
id: String,
mid: String,
tool: String,
callId: String,
state: String = "running",
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state,
input = input, metadata = metadata,
)
}
@@ -0,0 +1,238 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class QuestionResultViewTest : BasePlatformTestCase() {
// ------ canRender ------
fun `test completed question tool is renderable`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
)
assertTrue(QuestionResultView.canRender(tool))
}
fun `test invalid question data is not renderable`() {
val tool = completedTool(
input = emptyMap(),
metadata = emptyMap(),
)
assertFalse(QuestionResultView.canRender(tool))
}
fun `test non-completed question tool is not renderable`() {
val tool = runningTool("question")
assertFalse(QuestionResultView.canRender(tool))
}
fun `test non-question tool with question json is not renderable`() {
val tool = completedTool(
name = "bash",
input = mapOf("questions" to """[{"question":"Which strategy?"}]"""),
metadata = emptyMap(),
)
assertFalse(QuestionResultView.canRender(tool))
}
fun `test empty questions array is not renderable`() {
val tool = completedTool(
input = mapOf("questions" to "[]"),
metadata = emptyMap(),
)
assertFalse(QuestionResultView.canRender(tool))
}
fun `test invalid json in questions is not renderable`() {
val tool = completedTool(
input = mapOf("questions" to "not json"),
metadata = emptyMap(),
)
assertFalse(QuestionResultView.canRender(tool))
}
// ------ label text ------
fun `test completed question tool renders answer summary`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Which implementation strategy should we use?"},{"question":"Which validation checks should be run?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
output = "User has answered your questions: raw output should not be rendered",
)
val view = QuestionResultView(tool)
assertTrue(view.labelText().contains("Questions"))
assertTrue(view.labelText().contains("2 answered"))
assertTrue(view.bodyText().contains("Which implementation strategy should we use?"))
assertTrue(view.bodyText().contains("Which validation checks should be run?"))
assertTrue(view.bodyText().contains("Comprehensive"))
assertTrue(view.bodyText().contains("Build"))
assertFalse(view.bodyText().contains("raw output should not be rendered"))
}
fun `test missing answer renders not answered`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["Answer1"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Q1"))
assertTrue(view.bodyText().contains("Answer1"))
assertTrue(view.bodyText().contains("Q2"))
assertTrue(view.bodyText().contains("(not answered)"))
}
fun `test no answers metadata renders all not answered`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = emptyMap(),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Q1"))
assertTrue(view.bodyText().contains("(not answered)"))
}
fun `test multi answer row joins with comma`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Select features"}]"""),
metadata = mapOf("answers" to """[["Manual verification","Unit tests"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Manual verification, Unit tests"))
}
fun `test label shows count of non-empty answers`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["A1"],[]]"""),
)
val view = QuestionResultView(tool)
// Only 1 non-empty answer
assertTrue(view.labelText().contains("1 answered"))
}
// ------ toggle expand/collapse ------
fun `test toggle collapses and expands body`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
assertTrue("Default state should be expanded", view.isExpanded())
view.toggle()
assertFalse("Should be collapsed after toggle", view.isExpanded())
view.toggle()
assertTrue("Should be expanded after second toggle", view.isExpanded())
}
// ------ view factory routing ------
fun `test view factory uses question result view for completed parsable question tool`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = ViewFactory.create(tool)
assertTrue(view is QuestionResultView)
}
fun `test view factory falls back to tool view for invalid question result`() {
val tool = completedTool(
input = emptyMap(),
metadata = emptyMap(),
)
val view = ViewFactory.create(tool)
assertTrue(view is ToolView)
}
fun `test view factory falls back to tool view for running question`() {
val tool = runningTool("question")
val view = ViewFactory.create(tool)
assertTrue(view is ToolView)
}
// ------ dumpLabel ------
fun `test dumpLabel format`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.dumpLabel().startsWith("QuestionResultView#"))
assertTrue(view.dumpLabel().contains("Questions"))
}
// ------ applyStyle ------
fun `test applyStyle updates fonts`() {
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)
// Body text areas should use the new font family
assertTrue(view.bodyText().isNotEmpty())
}
// ------ update ------
fun `test update with completed structured tool refreshes content`() {
val initial = completedTool(
input = mapOf("questions" to """[{"question":"Initial Q"}]"""),
metadata = mapOf("answers" to """[["Initial A"]]"""),
)
val view = QuestionResultView(initial)
val updated = completedTool(
id = initial.id,
input = mapOf("questions" to """[{"question":"Updated Q"}]"""),
metadata = mapOf("answers" to """[["Updated A"]]"""),
)
view.update(updated)
assertTrue(view.bodyText().contains("Updated Q"))
assertTrue(view.bodyText().contains("Updated A"))
assertFalse(view.bodyText().contains("Initial Q"))
}
// ------ helpers ------
private fun completedTool(
id: String = "tp1",
name: String = "question",
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
output: String? = null,
): Tool = Tool(id, name, toolKind(name)).apply {
state = ToolExecState.COMPLETED
this.input = input
this.metadata = metadata
this.output = output
}
private fun runningTool(name: String, id: String = "tp1"): Tool =
Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING }
}
@@ -177,8 +177,18 @@ class QuestionViewTest : BasePlatformTestCase() {
assertLabelsDoNotContain(view, "Choose approach")
assertLabelsContain(view, "2 of 2 questions")
// Select answer on second question, submit
// Select answer on second question, click Review
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
// Review page shown
assertLabelsContain(view, "Review your answers")
assertLabelsContain(view, "Choose approach")
assertLabelsContain(view, "Minimal")
assertLabelsContain(view, "Choose test level")
assertLabelsContain(view, "Unit")
// Submit from review page
button(view, "Submit").doClick()
assertFalse(view.isVisible)
@@ -187,6 +197,26 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers)
}
fun `test multi question uses review before submit`() {
view.show(twoItemQuestion("q_review"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
// Clicking Review should NOT submit
button(view, "Review").doClick()
assertTrue("Should still be visible after Review", view.isVisible)
assertTrue("No replies should be sent after Review", replies.isEmpty())
// Now submit from review page
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
}
fun `test back preserves previous selection`() {
view.show(twoItemQuestion("q_back"))
@@ -203,15 +233,71 @@ class QuestionViewTest : BasePlatformTestCase() {
val radios = findAll<JBRadioButton>(view)
assertTrue("Minimal should still be selected", radios.first { it.actionCommand == "Minimal" }.isSelected)
// Change selection to Balanced, go forward, submit
// Change selection to Balanced, go forward, then to review
option<JBRadioButton>(view, "Balanced").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers)
}
fun `test review back preserves answers`() {
view.show(twoItemQuestion("q_review_back"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
// On review page - go back to last question
button(view, "Back").doClick()
// Back on second question — selection should be preserved
assertLabelsContain(view, "Choose test level")
assertLabelsContain(view, "2 of 2 questions")
val radios = findAll<JBRadioButton>(view)
assertTrue("Unit should still be selected", radios.first { it.actionCommand == "Unit" }.isSelected)
}
fun `test review displays multi select answers joined`() {
val q = Question(
id = "q_multi",
items = listOf(
QuestionItem(
question = "Choose features",
header = "Features",
options = listOf(
QuestionOption("A", "Feature A"),
QuestionOption("B", "Feature B"),
),
multiple = true,
custom = false,
),
),
)
view.show(q)
option<JBCheckBox>(view, "A").doClick()
option<JBCheckBox>(view, "B").doClick()
button(view, "Review").doClick()
assertLabelsContain(view, "A, B")
}
fun `test single select question still submits directly`() {
view.show(singleSelectQuestion("q_direct"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Submit").doClick()
// Should submit directly without a review step
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
}
fun `test next is disabled until current question is answered`() {
view.show(twoItemQuestion("q_disabled"))
@@ -234,12 +320,16 @@ class QuestionViewTest : BasePlatformTestCase() {
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)
assertTrue("Forward should be enabled after selection on first question", 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)
// Forward on last question is enabled only after selection (can go to review)
assertFalse("Forward should be disabled on last question before selection", navButton(view, "Next").isEnabled)
option<JBRadioButton>(view, "Unit").doClick()
assertTrue("Forward should be enabled on last question after selection (goes to review)", navButton(view, "Next").isEnabled)
}
fun `test submit button uses default style`() {
@@ -304,6 +394,9 @@ class QuestionViewTest : BasePlatformTestCase() {
boxes.first { it.actionCommand == "B" }.doClick()
// Toggle B off — need fresh refs after re-render
option<JBCheckBox>(view, "B").doClick()
// Single multi-select item gets a Review step (VS Code parity: single() is false when multiple=true)
button(view, "Review").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
@@ -315,7 +408,7 @@ class QuestionViewTest : BasePlatformTestCase() {
// ------ helpers ------
/**
* Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit).
* Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit, Review, Back).
* For icon-only nav buttons (Back/Forward) that use tooltip, use [navButton].
*/
private fun button(root: Container, text: String): JButton =