mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
refactor: streamline jetbrains session UI and tests
Keep SessionUi construction phased and align the session test tree with the production package layout so the UI and update flow are easier to navigate. Also pin Kotlin editorconfig defaults so IDE formatting matches repo expectations.
This commit is contained in:
@@ -7,3 +7,8 @@ end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
|
||||
[*.{kt,kts}]
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
|
||||
+100
-89
@@ -24,29 +24,21 @@ import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.awt.Component
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.BoxLayout.Y_AXIS
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/**
|
||||
* Top-level session UI — a thin composition root.
|
||||
* Top-level session UI composition root.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel],
|
||||
* [ConnectionPanel],
|
||||
* [PromptPanel], [QuestionPanel], [PermissionPanel].
|
||||
* - Switches between the status (loading) card and the transcript card via
|
||||
* [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged].
|
||||
* - Keeps [ConnectionPanel] on a transparent overlay layer directly above the
|
||||
* prompt.
|
||||
* - Delegates all transcript and dock updates to the panels themselves via
|
||||
* [SessionModelEvent] listeners (no inline rendering logic here).
|
||||
* - Scrolls to the bottom on new content.
|
||||
* It builds the session panels, wires controller/model listeners, and swaps the
|
||||
* center body between the empty state and the message list.
|
||||
*
|
||||
* Views must never call RPC or services directly; everything goes through
|
||||
* the controller.
|
||||
* The only custom layout logic kept here is the prompt-relative connection
|
||||
* overlay. All user actions still flow through [SessionController].
|
||||
*/
|
||||
class SessionUi(
|
||||
project: Project,
|
||||
@@ -57,108 +49,127 @@ class SessionUi(
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private const val STATUS = "status"
|
||||
private const val MESSAGES = "messages"
|
||||
private val LOG = KiloLog.create(SessionUi::class.java)
|
||||
}
|
||||
|
||||
private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt())
|
||||
.takeIf { it > 0 }
|
||||
?.toLong()
|
||||
?: EVENT_FLUSH_MS
|
||||
private val project = project
|
||||
|
||||
private val flushMs =
|
||||
Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt())
|
||||
.takeIf { it > 0 }
|
||||
?.toLong()
|
||||
?: EVENT_FLUSH_MS
|
||||
|
||||
private val controller = SessionController(
|
||||
this, null, sessions, workspace, app, cs, this,
|
||||
flushMs = flushMs,
|
||||
condense = Registry.`is`("kilo.session.condense", true),
|
||||
this, null, sessions, workspace, app, cs, this,
|
||||
flushMs = flushMs,
|
||||
condense = Registry.`is`("kilo.session.condense", true),
|
||||
)
|
||||
|
||||
// ------ card switch ------
|
||||
|
||||
private val cards = CardLayout()
|
||||
private val center = JPanel(cards)
|
||||
private lateinit var root: SessionRootPanel
|
||||
|
||||
// ------ status (loading) panel ------
|
||||
private lateinit var sessionContent: JPanel
|
||||
|
||||
private val status = EmptySessionPanel(this)
|
||||
private lateinit var emptyBody: EmptySessionPanel
|
||||
|
||||
// ------ transcript ------
|
||||
private lateinit var messageBody: SessionMessageListPanel
|
||||
|
||||
private val transcript = SessionMessageListPanel(controller.model, this)
|
||||
private lateinit var messageScroll: JBScrollPane
|
||||
|
||||
private val scroll = JBScrollPane(transcript).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}
|
||||
private lateinit var question: QuestionPanel
|
||||
private lateinit var permission: PermissionPanel
|
||||
private lateinit var connection: ConnectionPanel
|
||||
|
||||
// ------ dock panels (above prompt) ------
|
||||
private lateinit var prompt: PromptPanel
|
||||
|
||||
private val question = QuestionPanel(controller)
|
||||
private val permission = PermissionPanel(controller)
|
||||
private val connection = ConnectionPanel(this, controller)
|
||||
|
||||
// ------ prompt ------
|
||||
|
||||
private val prompt = PromptPanel(
|
||||
project = project,
|
||||
onSend = { text -> send(text) },
|
||||
onAbort = { controller.abort() },
|
||||
)
|
||||
|
||||
private val root = SessionRootPanel().apply {
|
||||
content.layout = BorderLayout()
|
||||
}
|
||||
|
||||
init {
|
||||
// South area: question dock, permission dock, and prompt stacked vertically.
|
||||
// BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically,
|
||||
// so hiding a dock doesn't leave an empty gap above the prompt.
|
||||
val south = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
buildUi()
|
||||
bindUi()
|
||||
showBody(emptyBody)
|
||||
}
|
||||
|
||||
private fun buildUi() {
|
||||
root = SessionRootPanel()
|
||||
|
||||
sessionContent = JPanel(BorderLayout())
|
||||
|
||||
emptyBody = EmptySessionPanel(this)
|
||||
messageBody = SessionMessageListPanel(controller.model, this)
|
||||
|
||||
messageScroll = JBScrollPane(messageBody).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}
|
||||
question = QuestionPanel(controller)
|
||||
permission = PermissionPanel(controller)
|
||||
connection = ConnectionPanel(this, controller)
|
||||
|
||||
prompt = PromptPanel(
|
||||
project = project,
|
||||
onSend = { text -> sendPrompot(text) },
|
||||
onAbort = { controller.abort() },
|
||||
)
|
||||
|
||||
root.content.add(sessionContent, BorderLayout.CENTER)
|
||||
// Question and permission panels stay in normal flow so the prompt moves
|
||||
// naturally when either dock appears.
|
||||
root.content.add(JPanel().apply {
|
||||
this.layout = BoxLayout(this, Y_AXIS)
|
||||
add(question)
|
||||
add(permission)
|
||||
add(prompt)
|
||||
}
|
||||
}, BorderLayout.SOUTH)
|
||||
|
||||
center.add(status, STATUS)
|
||||
center.add(scroll, MESSAGES)
|
||||
cards.show(center, STATUS)
|
||||
|
||||
root.content.add(center, BorderLayout.CENTER)
|
||||
root.content.add(south, BorderLayout.SOUTH)
|
||||
// Keep connection status visually attached to the prompt without taking
|
||||
// space in the south stack.
|
||||
root.addOverlay(connection) { panel, child ->
|
||||
val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel)
|
||||
val box = SwingUtilities.convertRectangle(
|
||||
prompt.parent,
|
||||
prompt.bounds,
|
||||
panel
|
||||
)
|
||||
val h = child.preferredSize.height
|
||||
java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h)
|
||||
}
|
||||
|
||||
add(root, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
// ------ picker wiring ------
|
||||
|
||||
private fun bindUi() {
|
||||
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
|
||||
prompt.model.onSelect = picker@{ item ->
|
||||
val group = item.group ?: return@picker
|
||||
controller.selectModel(group, item.id)
|
||||
}
|
||||
|
||||
// ------ controller lifecycle events ------
|
||||
|
||||
controller.addListener(this) { event ->
|
||||
when (event) {
|
||||
is SessionControllerEvent.WorkspaceReady -> {
|
||||
val m = controller.model
|
||||
prompt.mode.setItems(m.agents.map { LabelPicker.Item(it.name, it.display) }, m.agent)
|
||||
val items = m.models.map { LabelPicker.Item(it.id, it.display, it.provider) }
|
||||
val selected = m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id }
|
||||
prompt.mode.setItems(m.agents.map {
|
||||
LabelPicker.Item(
|
||||
it.name,
|
||||
it.display
|
||||
)
|
||||
}, m.agent)
|
||||
val items = m.models.map {
|
||||
LabelPicker.Item(
|
||||
it.id,
|
||||
it.display,
|
||||
it.provider
|
||||
)
|
||||
}
|
||||
val selected =
|
||||
m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id }
|
||||
prompt.model.setItems(items, selected)
|
||||
prompt.setReady(m.isReady())
|
||||
}
|
||||
|
||||
is SessionControllerEvent.ViewChanged ->
|
||||
cards.show(center, if (event.show) MESSAGES else STATUS)
|
||||
showBody(if (event.show) messageScroll else emptyBody)
|
||||
|
||||
is SessionControllerEvent.AppChanged,
|
||||
is SessionControllerEvent.WorkspaceChanged ->
|
||||
@@ -166,11 +177,9 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
// ------ model events — prompt state + dock + auto-scroll ------
|
||||
|
||||
controller.model.addListener(this) { event ->
|
||||
when (event) {
|
||||
is SessionModelEvent.StateChanged -> onState(event.state)
|
||||
is SessionModelEvent.StateChanged -> onStateChanged(event.state)
|
||||
|
||||
is SessionModelEvent.TurnAdded,
|
||||
is SessionModelEvent.TurnUpdated,
|
||||
@@ -192,9 +201,7 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
// ------ private helpers ------
|
||||
|
||||
private fun send(text: String) {
|
||||
private fun sendPrompot(text: String) {
|
||||
if (text.isBlank()) return
|
||||
LOG.debug {
|
||||
"${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}"
|
||||
@@ -203,17 +210,19 @@ class SessionUi(
|
||||
prompt.clear()
|
||||
}
|
||||
|
||||
private fun onState(state: SessionState) {
|
||||
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()
|
||||
@@ -224,22 +233,24 @@ class SessionUi(
|
||||
}
|
||||
|
||||
private fun scrollToBottom() {
|
||||
val bar = scroll.verticalScrollBar
|
||||
val bar = messageScroll.verticalScrollBar
|
||||
bar.value = bar.maximum
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
center.revalidate()
|
||||
center.repaint()
|
||||
root.content.revalidate()
|
||||
root.content.repaint()
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
super.doLayout()
|
||||
root.doLayout()
|
||||
private fun showBody(panel: Component) {
|
||||
if (sessionContent.getComponentCount() == 1 && sessionContent.getComponent(
|
||||
0
|
||||
) === panel
|
||||
) return
|
||||
sessionContent.removeAll()
|
||||
sessionContent.add(panel, BorderLayout.CENTER)
|
||||
sessionContent.revalidate()
|
||||
sessionContent.repaint()
|
||||
}
|
||||
|
||||
override fun dispose() {}
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JComponent
|
||||
@@ -8,7 +9,7 @@ import javax.swing.JPanel
|
||||
|
||||
class SessionRootPanel : JLayeredPane() {
|
||||
|
||||
val content = JPanel()
|
||||
val content = JPanel(BorderLayout())
|
||||
|
||||
val overlay = Overlay()
|
||||
|
||||
|
||||
+4
-4
@@ -106,7 +106,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
|
||||
layout()
|
||||
assertFalse(question.isVisible)
|
||||
|
||||
controller().model.setState(questionState())
|
||||
controller().model.setState(questionStateChanged())
|
||||
layout()
|
||||
|
||||
assertTrue(question.isVisible)
|
||||
@@ -125,7 +125,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
|
||||
layout()
|
||||
assertFalse(permission.isVisible)
|
||||
|
||||
controller().model.setState(permissionState())
|
||||
controller().model.setState(permissionStateChanged())
|
||||
layout()
|
||||
|
||||
assertTrue(permission.isVisible)
|
||||
@@ -162,7 +162,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
|
||||
return field.get(ui) as SessionController
|
||||
}
|
||||
|
||||
private fun questionState() = SessionState.AwaitingQuestion(
|
||||
private fun questionStateChanged() = SessionState.AwaitingQuestion(
|
||||
Question(
|
||||
id = "q1",
|
||||
items = listOf(
|
||||
@@ -177,7 +177,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
|
||||
)
|
||||
)
|
||||
|
||||
private fun permissionState() = SessionState.AwaitingPermission(
|
||||
private fun permissionStateChanged() = SessionState.AwaitingPermission(
|
||||
Permission(
|
||||
id = "p1",
|
||||
sessionId = "ses",
|
||||
|
||||
+1
-2
@@ -1,10 +1,9 @@
|
||||
package ai.kilocode.client.session
|
||||
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.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.ProgressPanel
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.views.TextView
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
class ConfigSelectionTest : SessionControllerTestBase() {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.update.SessionControllerEvent
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import com.intellij.openapi.util.Disposer
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
class SessionCreationTest : SessionControllerTestBase() {
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.update.SessionQueueCondenser
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
+1
-2
@@ -1,10 +1,9 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.update.SessionController
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.session
|
||||
package ai.kilocode.client.session.update
|
||||
|
||||
class WorkspaceWatchingTest : SessionControllerTestBase() {
|
||||
|
||||
Reference in New Issue
Block a user