feat(jetbrains): render question/permission prompts in scrollable transcript

Move active question and permission forms from fixed bottom panels above
the prompt into the scrollable chat transcript. Users can now scroll through
prior messages while a form is active and scroll back down to it.

- Add QuestionView and PermissionView in session/views/ with callback-based
  wiring (no direct RPC access from views)
- Update SessionMessageListPanel to own optional active views, syncing them
  from SessionModelEvent.StateChanged and anchoring before ProgressPanel
- Update SessionUi to create views with controller lambdas, pass them into
  SessionMessageListPanel, and remove them from the SOUTH stack
- Remove unused Dock.neutral() and Dock.warning() from SessionUiStyle
- Replace QuestionPanelTest/PermissionPanelTest with faster callback-based
  QuestionViewTest/PermissionViewTest under session/views/
This commit is contained in:
kirillk
2026-05-17 15:29:11 -04:00
parent e83c804fcb
commit ae31cf218e
13 changed files with 644 additions and 535 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render active question and permission prompts inside the scrollable JetBrains chat transcript.
@@ -12,9 +12,7 @@ import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
@@ -23,6 +21,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.QuestionView
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import com.intellij.ide.ui.LafManagerListener
@@ -35,7 +35,6 @@ import com.intellij.openapi.util.registry.Registry
import kotlinx.coroutines.CoroutineScope
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.BoxLayout.Y_AXIS
import javax.swing.JComponent
import javax.swing.JPanel
@@ -97,8 +96,8 @@ class SessionUi(
internal lateinit var scroll: SessionScroll
private lateinit var question: QuestionPanel
private lateinit var permission: PermissionPanel
private lateinit var question: QuestionView
private lateinit var permission: PermissionView
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
@@ -146,12 +145,17 @@ class SessionUi(
load = LoadingPanel()
progressBody = load
messageBody = SessionMessageListPanel(controller.model, this)
question = QuestionView(
reply = { id, dto -> controller.replyQuestion(id, dto) },
reject = { id -> controller.rejectQuestion(id) },
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
messageBody = SessionMessageListPanel(controller.model, this, question, permission)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
question = QuestionPanel(controller)
permission = PermissionPanel(controller)
connection = ConnectionPanel(this, controller)
prompt = PromptPanel(
@@ -163,12 +167,8 @@ class SessionUi(
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
// Dock panels stay in normal flow so each visible state takes layout space
// above the prompt.
root.content.add(JPanel().apply {
this.layout = BoxLayout(this, Y_AXIS)
add(question)
add(permission)
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(connection)
add(prompt)
}, BorderLayout.SOUTH)
@@ -321,22 +321,6 @@ class SessionUi(
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
when (state) {
is SessionState.AwaitingQuestion -> {
permission.hidePanel()
question.show(state.question)
}
is SessionState.AwaitingPermission -> {
question.hidePanel()
permission.show(state.permission)
}
else -> {
question.hidePanel()
permission.hidePanel()
}
}
refresh()
}
@@ -1,83 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.RowLayout
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Docked permission panel — shown above the prompt when the session is in
* [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
*
* The inner layout is built via Kotlin UI DSL inside [show] so it reflects
* the current permission's tool, patterns, and optional message.
*
* Layout (mirrors VS Code's PermissionDock):
* ```
* ┌─────────────────────────────────────────┐
* │ ⚠ Permission request │
* │ Tool: edit • Patterns: *.kt │
* │ <optional message> │
* │ [Allow] [Deny] │
* └─────────────────────────────────────────┘
* ```
*/
class PermissionPanel(
private val controller: SessionController,
) : BorderLayoutPanel() {
private lateinit var requestId: String
init {
border = Dock.warning()
isVisible = false
}
/** Populate the panel for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" }
removeAll()
add(panel {
row {
icon(AllIcons.General.Warning).gap(RightGap.SMALL)
label(KiloBundle.message("session.permission.title")).bold()
}
row {
label(KiloBundle.message("session.permission.meta", permission.name, patterns))
}
val msg = permission.message
if (!msg.isNullOrBlank()) {
row {
comment(msg)
}
}
row {
button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL)
button(KiloBundle.message("session.permission.deny")) { decide("reject") }
}.layout(RowLayout.INDEPENDENT)
}, BorderLayout.CENTER)
isVisible = true
revalidate()
repaint()
}
/** Hide this panel. */
fun hidePanel() {
isVisible = false
}
private fun decide(reply: String) {
controller.replyPermission(requestId, PermissionReplyDto(reply = reply))
hidePanel()
}
}
@@ -2,10 +2,13 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.QuestionView
import ai.kilocode.client.session.views.TurnView
import com.intellij.openapi.Disposable
import com.intellij.util.ui.JBUI
@@ -29,11 +32,17 @@ import com.intellij.util.ui.JBUI
* bottom of the transcript inside the scroll pane and shows a spinner while
* the session is busy.
*
* Optional [question] and [permission] views are kept immediately before
* [progress] in component order and shown/hidden in response to
* [SessionModelEvent.StateChanged].
*
* All method calls must happen on the EDT.
*/
class SessionMessageListPanel(
private val model: SessionModel,
parent: Disposable,
private val question: QuestionView? = null,
private val permission: PermissionView? = null,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -90,12 +99,16 @@ class SessionMessageListPanel(
is SessionModelEvent.HistoryLoaded -> rebuild()
is SessionModelEvent.Cleared -> clear()
is SessionModelEvent.StateChanged -> {
syncActive(event.state)
anchorFooter()
refresh()
}
// Message events: structural changes are handled via turn events above.
// State/diff/todos changes are handled by other panels in SessionUi.
is SessionModelEvent.MessageAdded,
is SessionModelEvent.MessageUpdated,
is SessionModelEvent.MessageRemoved,
is SessionModelEvent.StateChanged,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
@@ -217,6 +230,7 @@ class SessionMessageListPanel(
add(tv)
}
syncActive(model.state)
anchorFooter()
refresh()
}
@@ -226,13 +240,47 @@ class SessionMessageListPanel(
msgToTurn.clear()
msgToView.clear()
removeAll()
syncActive(model.state)
anchorFooter()
refresh()
}
/** Re-insert [progress] as the last child so it always renders after all turn views. */
/**
* Show or hide active question/permission views based on [state].
* Both views are always kept as children of this panel (added in [anchorFooter]),
* but visibility is controlled here.
*/
private fun syncActive(state: SessionState = model.state) {
when (state) {
is SessionState.AwaitingQuestion -> {
permission?.hideView()
question?.show(state.question)
}
is SessionState.AwaitingPermission -> {
question?.hideView()
permission?.show(state.permission)
}
else -> {
question?.hideView()
permission?.hideView()
}
}
}
/**
* Re-insert [question], [permission], and [progress] as the last children
* so active views always render after all turn views, and progress is last.
*
* Both active views are added even when invisible — [SessionLayout] skips
* invisible children, so no extra space is consumed, and the component tree
* remains stable for tests.
*/
private fun anchorFooter() {
if (question != null) remove(question)
if (permission != null) remove(permission)
remove(progress)
if (question != null) add(question)
if (permission != null) add(permission)
add(progress)
}
@@ -254,6 +302,8 @@ class SessionMessageListPanel(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in turnViews.values) view.applyStyle(style)
question?.applyStyle(style)
permission?.applyStyle(style)
progress.applyStyle(style)
refresh()
}
@@ -105,20 +105,10 @@ object SessionUiStyle {
}
}
/** Border presets for question, permission, and connection dock panels. */
/** Border presets for connection dock panel. */
object Dock {
fun banner(): Border = JBUI.Borders.compound(
JBUI.Borders.customLineTop(SessionUiStyle.View.line()),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()),
)!!
fun neutral(): Border = JBUI.Borders.compound(
JBUI.Borders.customLine(SessionUiStyle.View.line(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
fun warning(): Border = JBUI.Borders.compound(
customLine(UiStyle.Colors.warningLabelForeground(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
}
@@ -0,0 +1,100 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.RowLayout
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
* at the end of the transcript when the session is in
* [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
*
* Unlike the old docked [ai.kilocode.client.session.ui.PermissionPanel], this view lives inside
* the scrollable transcript so the user can scroll through prior messages while a permission is pending.
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
init {
isOpaque = false
isVisible = false
}
/** Populate the view for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" }
removeAll()
val card = BorderLayoutPanel()
card.isOpaque = true
card.background = SessionUiStyle.View.surface()
card.border = SessionUiStyle.View.card()
card.add(panel {
row {
icon(AllIcons.General.Warning).gap(RightGap.SMALL)
label(KiloBundle.message("session.permission.title")).bold()
}
row {
label(KiloBundle.message("session.permission.meta", permission.name, patterns))
}
val msg = permission.message
if (!msg.isNullOrBlank()) {
row {
comment(msg)
}
}
row {
button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL)
button(KiloBundle.message("session.permission.deny")) { decide("reject") }
}.layout(RowLayout.INDEPENDENT)
}.also { it.isOpaque = false }, BorderLayout.CENTER)
add(card, BorderLayout.CENTER)
isVisible = true
refresh()
}
/** Hide this view and clear the active request id. */
fun hideView() {
requestId = null
removeAll()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
}
private fun decide(value: String) {
val id = requestId ?: return
reply(id, PermissionReplyDto(reply = value))
hideView()
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -1,10 +1,12 @@
package ai.kilocode.client.session.ui
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.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
@@ -15,31 +17,31 @@ import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Docked question panel shown above the prompt when the session is in
* 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].
*
* Renders all [Question.items] with per-item option selection.
* For `multiple = false` items, selecting an option replaces the previous choice.
* For `multiple = true` items, option buttons toggle membership.
*
* A single Submit button at the bottom sends all collected answers once each
* item has at least one answer selected.
* 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.
*/
class QuestionPanel(
private val controller: SessionController,
) : BorderLayoutPanel() {
class QuestionView(
private val reply: (String, QuestionReplyDto) -> Unit,
private val reject: (String) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
init {
border = Dock.neutral()
isOpaque = false
isVisible = false
}
/** Populate the panel for all items in [question] and make it visible. */
/** Populate the view for all items in [question] and make it visible. */
fun show(question: Question) {
if (question.items.isEmpty()) {
hidePanel()
hideView()
return
}
requestId = question.id
@@ -48,7 +50,13 @@ class QuestionPanel(
val selections = Array(question.items.size) { mutableSetOf<String>() }
removeAll()
add(panel {
val card = BorderLayoutPanel()
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 {
@@ -71,22 +79,28 @@ class QuestionPanel(
row {
button(KiloBundle.message("session.question.submit")) {
reply(selections.map { it.toList() })
doReply(selections.map { it.toList() })
}.gap(RightGap.SMALL)
button(KiloBundle.message("session.question.dismiss")) { reject() }
button(KiloBundle.message("session.question.dismiss")) { doReject() }
}.layout(RowLayout.INDEPENDENT).topGap(TopGap.SMALL)
}, BorderLayout.CENTER)
}.also { it.isOpaque = false }, BorderLayout.CENTER)
add(card, BorderLayout.CENTER)
isVisible = true
revalidate()
repaint()
refresh()
}
/** Hide this panel. */
fun hidePanel() {
/** Hide this view and clear the active request id. */
fun hideView() {
requestId = null
removeAll()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
}
private fun toggleOption(
@@ -104,15 +118,22 @@ class QuestionPanel(
}
}
private fun reply(answers: List<List<String>>) {
private fun doReply(answers: List<List<String>>) {
val id = requestId ?: return
controller.replyQuestion(id, QuestionReplyDto(answers))
hidePanel()
reply(id, QuestionReplyDto(answers))
hideView()
}
private fun reject() {
private fun doReject() {
val id = requestId ?: return
controller.rejectQuestion(id)
hidePanel()
reject(id)
hideView()
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -10,13 +10,13 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.QuestionView
import ai.kilocode.rpc.dto.MessageWithPartsDto
import com.intellij.ui.components.JBScrollPane
import javax.swing.JLayeredPane
@@ -34,10 +34,8 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
}
fun `test connection panel is docked between permission and prompt`() {
fun `test bottom stack contains connection and prompt only`() {
val root = find<SessionRootPanel>(ui)
val question = find<QuestionPanel>(ui)
val permission = find<PermissionPanel>(ui)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val stack = prompt.parent
@@ -45,7 +43,19 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertSame(root.content, stack.parent)
assertSame(stack, connection.parent)
assertEquals(1, root.overlay.componentCount)
assertEquals(listOf(question, permission, connection, prompt), stack.components.toList())
assertEquals(listOf(connection, prompt), stack.components.toList())
}
fun `test active views are children of message list panel`() {
ui = newUi(id = "ses_test")
settle()
val messages = find<SessionMessageListPanel>(ui)
val qv = find<QuestionView>(ui)
val pv = find<PermissionView>(ui)
assertSame(messages, qv.parent)
assertSame(messages, pv.parent)
}
fun `test header is docked above shared scroll pane and hidden while empty`() {
@@ -80,42 +90,68 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(connection.y + connection.height <= prompt.y)
}
fun `test connection panel moves after visible question panel`() {
val connection = find<ConnectionPanel>(ui)
val question = find<QuestionPanel>(ui)
val prompt = find<PromptPanel>(ui)
fun `test connection panel is unaffected by active question view`() {
ui = newUi(id = "ses_test")
settle()
showConnection()
layout()
assertFalse(question.isVisible)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val top = connection.y
controller().model.setState(questionStateChanged())
layout()
assertTrue(question.isVisible)
assertTrue(question.y < connection.y)
assertTrue(top < connection.y)
assertTrue(find<QuestionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
assertEquals(top, connection.y)
assertTrue(connection.y + connection.height <= prompt.y)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test connection panel moves after visible permission panel`() {
val connection = find<ConnectionPanel>(ui)
val permission = find<PermissionPanel>(ui)
val prompt = find<PromptPanel>(ui)
fun `test connection panel is unaffected by active permission view`() {
ui = newUi(id = "ses_test")
settle()
showConnection()
layout()
assertFalse(permission.isVisible)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val top = connection.y
controller().model.setState(permissionStateChanged())
layout()
assertTrue(permission.isVisible)
assertTrue(permission.y < connection.y)
assertTrue(top < connection.y)
assertTrue(find<PermissionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
assertEquals(top, connection.y)
assertTrue(connection.y + connection.height <= prompt.y)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test active question view renders inside message scroll view`() {
ui = newUi(id = "ses_test")
settle()
controller().model.setState(questionStateChanged())
layout()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(find<QuestionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
assertTrue(find<QuestionView>(ui).parent !== find<PromptPanel>(ui).parent)
}
fun `test active permission view renders inside message scroll view`() {
ui = newUi(id = "ses_test")
settle()
controller().model.setState(permissionStateChanged())
layout()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(find<PermissionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
assertTrue(find<PermissionView>(ui).parent !== find<PromptPanel>(ui).parent)
}
fun `test empty and message bodies share the same scroll pane`() {
@@ -1,115 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionPanelTest : BasePlatformTestCase() {
private lateinit var parent: Disposable
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeSessionRpcApi
private lateinit var app: KiloAppService
private lateinit var workspaces: KiloWorkspaceService
private lateinit var workspace: Workspace
private lateinit var controller: SessionController
private lateinit var panel: PermissionPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("permission-panel")
scope = CoroutineScope(SupervisorJob())
rpc = FakeSessionRpcApi()
val sessions = KiloSessionService(project, scope, rpc)
val api = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }
val work = FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
}
app = KiloAppService(scope, api)
workspaces = KiloWorkspaceService(scope, work)
workspace = workspaces.workspace("/test")
controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, BorderLayoutPanel())
panel = PermissionPanel(controller)
}
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test allow button uses bundle text and replies once`() {
panel.show(permission())
buttons(panel).first { it.text == "Allow" }.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("perm1", rpc.permissionReplies.single().first)
assertEquals("once", rpc.permissionReplies.single().third.reply)
}
fun `test deny button uses bundle text and rejects`() {
panel.show(permission())
buttons(panel).first { it.text == "Deny" }.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("perm1", rpc.permissionReplies.single().first)
assertEquals("reject", rpc.permissionReplies.single().third.reply)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
private fun flush() = runBlocking {
repeat(5) {
delay(100)
ApplicationManager.getApplication().invokeAndWait {
UIUtil.dispatchAllInvocationEvents()
}
}
}
}
@@ -1,240 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.controller.SessionController
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.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class QuestionPanelTest : BasePlatformTestCase() {
private lateinit var parent: Disposable
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeSessionRpcApi
private lateinit var app: KiloAppService
private lateinit var workspaces: KiloWorkspaceService
private lateinit var workspace: Workspace
private lateinit var controller: SessionController
private lateinit var panel: QuestionPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("question-panel")
scope = CoroutineScope(SupervisorJob())
rpc = FakeSessionRpcApi()
val sessions = KiloSessionService(project, scope, rpc)
val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }
val workspaceRpc = FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
}
app = KiloAppService(scope, appRpc)
workspaces = KiloWorkspaceService(scope, workspaceRpc)
workspace = workspaces.workspace("/test")
val root = BorderLayoutPanel()
controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, root)
panel = QuestionPanel(controller)
}
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test empty question hides panel and clears stale request id`() {
panel.show(
Question(
id = "req_old",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
assertTrue(panel.isVisible)
panel.show(Question(id = "req_new", items = emptyList()))
assertFalse(panel.isVisible)
assertEquals(0, panel.componentCount)
assertTrue(rpc.questionReplies.isEmpty())
assertTrue(rpc.questionRejects.isEmpty())
}
fun `test dismiss button uses bundle text and rejects question`() {
panel.show(
Question(
id = "req_1",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
val button = buttons(panel).first { it.text == "Dismiss" }
button.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("req_1", rpc.questionRejects.single().first)
}
fun `test single question submit sends answer`() {
panel.show(
Question(
id = "req_2",
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Keep it simple"),
QuestionOption("Refactor", "Full refactor"),
),
multiple = false,
custom = false,
)
),
)
)
buttons(panel).first { it.text == "Minimal" }.doClick()
buttons(panel).first { it.text == "Submit" }.doClick()
flush()
assertFalse(panel.isVisible)
assertReply("req_2 /test [[Minimal]]", rpc.questionReplies)
}
fun `test multi question submit sends all answers`() {
panel.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,
),
),
)
)
buttons(panel).first { it.text == "Minimal" }.doClick()
buttons(panel).first { it.text == "Unit" }.doClick()
buttons(panel).first { it.text == "Submit" }.doClick()
flush()
assertFalse(panel.isVisible)
assertReply("q_strategy /test [[Minimal],[Unit]]", rpc.questionReplies)
}
fun `test multiple selection item toggles options`() {
panel.show(
Question(
id = "req_3",
items = listOf(
QuestionItem(
question = "Select features",
header = "Features",
options = listOf(
QuestionOption("A", "Feature A"),
QuestionOption("B", "Feature B"),
QuestionOption("C", "Feature C"),
),
multiple = true,
custom = false,
)
),
)
)
buttons(panel).first { it.text == "A" }.doClick()
buttons(panel).first { it.text == "B" }.doClick()
// Toggle B off
buttons(panel).first { it.text == "B" }.doClick()
buttons(panel).first { it.text == "Submit" }.doClick()
flush()
assertFalse(panel.isVisible)
assertReply("req_3 /test [[A]]", rpc.questionReplies)
}
private fun assertReply(expected: String, replies: List<Triple<String, String, ai.kilocode.rpc.dto.QuestionReplyDto>>) {
val act = replies.joinToString("\n") { (id, dir, reply) ->
val answers = reply.answers.joinToString(",", "[", "]") { inner ->
inner.joinToString(",", "[", "]")
}
"$id $dir $answers"
}
assertEquals(expected, act)
}
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
}
private fun flush() = runBlocking {
repeat(5) {
delay(100)
ApplicationManager.getApplication().invokeAndWait {
UIUtil.dispatchAllInvocationEvents()
}
}
}
}
@@ -1,7 +1,15 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
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.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.QuestionView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
@@ -10,6 +18,7 @@ import ai.kilocode.rpc.dto.PartDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
/**
* Tests for [SessionMessageListPanel] — structural and index integrity.
@@ -257,8 +266,110 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(text.md.overrideSheet().contains("25pt"))
}
// ------ active view tests ------
fun `test active question is anchored before progress footer`() {
val item = panelWithPrompts()
model.upsertMessage(msg("u1", "user"))
model.setState(SessionState.AwaitingQuestion(question()))
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
val comps = item.components.toList()
assertTrue(qv.isVisible)
assertFalse(pv.isVisible)
assertSame(item.progress, comps.last())
assertTrue(comps.indexOf(qv) < comps.indexOf(item.progress))
}
fun `test active permission replaces active question`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingQuestion(question()))
model.setState(SessionState.AwaitingPermission(permission()))
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
val comps = item.components.toList()
assertFalse(qv.isVisible)
assertTrue(pv.isVisible)
assertSame(item.progress, comps.last())
}
fun `test idle hides active prompt and keeps progress footer last`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingQuestion(question()))
model.setState(SessionState.Idle)
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
assertFalse(qv.isVisible)
assertFalse(pv.isVisible)
assertSame(item.progress, item.components.last())
}
fun `test cleared hides active prompt`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingPermission(permission()))
model.clear()
val pv = find<PermissionView>(item)!!
assertFalse(pv.isVisible)
assertSame(item.progress, item.components.last())
}
// ------ helpers ------
private fun panelWithPrompts(): SessionMessageListPanel {
val q = QuestionView(
reply = { _, _ -> },
reject = { _ -> },
)
val p = PermissionView(
reply = { _, _ -> },
)
return SessionMessageListPanel(model, parent, q, p)
}
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
private fun <T> findCls(root: Container, cls: Class<T>): T? {
if (cls.isInstance(root)) return cls.cast(root)
for (child in root.components) {
if (cls.isInstance(child)) return cls.cast(child)
if (child is Container) {
val item = findCls(child, cls)
if (item != null) return item
}
}
return null
}
private fun question(id: String = "q1") = Question(
id = id,
items = listOf(
QuestionItem(
question = "Proceed?",
header = "Confirm",
options = listOf(QuestionOption("Yes", "Continue")),
multiple = false,
custom = true,
),
),
)
private fun permission(id: String = "p1") = Permission(
id = id,
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
)
private fun msg(id: String, role: String) = MessageDto(
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
)
@@ -0,0 +1,74 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
private lateinit var view: PermissionView
override fun setUp() {
super.setUp()
view = PermissionView(
reply = { id, dto -> replies.add(id to dto) },
)
}
fun `test allow button uses bundle text and replies once`() {
view.show(permission())
buttons(view).first { it.text == "Allow" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("once", replies.single().second.reply)
}
fun `test deny button uses bundle text and rejects`() {
view.show(permission())
buttons(view).first { it.text == "Deny" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("reject", replies.single().second.reply)
}
fun `test blank patterns display star`() {
view.show(
Permission(
id = "perm2",
sessionId = "ses",
name = "edit",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(),
)
)
assertTrue(view.isVisible)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
}
@@ -0,0 +1,176 @@
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.rpc.dto.QuestionReplyDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class QuestionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, QuestionReplyDto>>()
private val rejects = mutableListOf<String>()
private lateinit var view: QuestionView
override fun setUp() {
super.setUp()
view = QuestionView(
reply = { id, dto -> replies.add(id to dto) },
reject = { id -> rejects.add(id) },
)
}
fun `test empty question hides view and clears stale request id`() {
view.show(
Question(
id = "req_old",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
assertTrue(view.isVisible)
view.show(Question(id = "req_new", items = emptyList()))
assertFalse(view.isVisible)
assertTrue(replies.isEmpty())
assertTrue(rejects.isEmpty())
}
fun `test dismiss button uses bundle text and rejects question`() {
view.show(
Question(
id = "req_1",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
buttons(view).first { it.text == "Dismiss" }.doClick()
assertFalse(view.isVisible)
assertEquals("req_1", rejects.single())
assertTrue(replies.isEmpty())
}
fun `test single question submit sends answer`() {
view.show(
Question(
id = "req_2",
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Keep it simple"),
QuestionOption("Refactor", "Full refactor"),
),
multiple = false,
custom = false,
)
),
)
)
buttons(view).first { it.text == "Minimal" }.doClick()
buttons(view).first { it.text == "Submit" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
}
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,
),
),
)
)
buttons(view).first { it.text == "Minimal" }.doClick()
buttons(view).first { it.text == "Unit" }.doClick()
buttons(view).first { it.text == "Submit" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("q_strategy", replies.single().first)
assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers)
}
fun `test multiple selection item toggles options`() {
view.show(
Question(
id = "req_3",
items = listOf(
QuestionItem(
question = "Select features",
header = "Features",
options = listOf(
QuestionOption("A", "Feature A"),
QuestionOption("B", "Feature B"),
QuestionOption("C", "Feature C"),
),
multiple = true,
custom = false,
)
),
)
)
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()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_3", replies.single().first)
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
}
}