From 6e633692a24be6da6bebbab3e9b1e0e73b597e2c Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 18:31:41 -0400 Subject: [PATCH 1/8] feat(jetbrains): support plan follow-up implementation --- .changeset/jetbrains-plan-exit.md | 5 + .../backend/app/KiloBackendChatManager.kt | 1 + .../kilocode/backend/cli/KiloCliDataParser.kt | 46 +++++-- .../backend/rpc/KiloSessionRpcApiImpl.kt | 3 +- .../backend/cli/KiloCliDataParserTest.kt | 73 ++++++++++- .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../session/controller/SessionController.kt | 66 +++++++++- .../kilocode/client/session/model/Question.kt | 6 + .../client/session/views/PlanExitView.kt | 89 +++++++++++++ .../client/session/views/ViewFactory.kt | 8 +- .../session/views/question/QuestionView.kt | 7 +- .../resources/messages/KiloBundle.properties | 1 + .../session/controller/PromptLifecycleTest.kt | 124 ++++++++++++++++++ .../controller/SessionControllerTestBase.kt | 5 +- .../session/ui/SessionMessageListPanelTest.kt | 2 +- .../client/session/views/PlanExitViewTest.kt | 38 ++++++ .../client/session/views/QuestionViewTest.kt | 36 ++++- .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 7 + .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 13 ++ .../opencode/src/kilocode/plan-followup.ts | 2 +- .../opencode/src/kilocode/session/prompt.ts | 2 +- .../test/kilocode/plan-exit-detection.test.ts | 43 ++++++ 22 files changed, 549 insertions(+), 30 deletions(-) create mode 100644 .changeset/jetbrains-plan-exit.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt diff --git a/.changeset/jetbrains-plan-exit.md b/.changeset/jetbrains-plan-exit.md new file mode 100644 index 0000000000..cadf662fe8 --- /dev/null +++ b/.changeset/jetbrains-plan-exit.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support starting implementation from completed planning sessions in JetBrains. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 3a73539b72..6588de5530 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -48,6 +48,7 @@ class KiloBackendChatManager( "message.part.removed", "session.turn.open", "session.turn.close", + "session.created", "session.error", "session.status", "session.updated", diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index c4a920cc04..06044c60aa 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -132,6 +132,13 @@ object KiloCliDataParser { ChatEventDto.TurnClose(sid, reason) } + "session.created" -> { + val info = props["info"]?.jsonObject ?: return null + val dto = parseSessionObject(info) + val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null + ChatEventDto.SessionCreated(sid, dto) + } + "session.error" -> { val sid = props.str("sessionID") val err = props["error"]?.jsonObject?.let { parseError(it) } @@ -540,20 +547,28 @@ object KiloCliDataParser { val sid = obj.str("sessionID") ?: return null val questions = obj["questions"]?.jsonArray?.map { q -> val qo = q.jsonObject - val options = qo["options"]?.jsonArray?.map { o -> - val oo = o.jsonObject - QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "") + val options = qo["options"]?.jsonArray?.map { o -> + val oo = o.jsonObject + QuestionOptionDto( + label = oo.str("label") ?: "", + description = oo.str("description") ?: "", + labelKey = oo.str("labelKey"), + descriptionKey = oo.str("descriptionKey"), + mode = oo.str("mode"), + ) + } ?: emptyList() + QuestionInfoDto( + question = qo.str("question") ?: "", + header = qo.str("header") ?: "", + options = options, + multiple = qo.flag("multiple", false), + custom = qo.flag("custom", true), + questionKey = qo.str("questionKey"), + headerKey = qo.str("headerKey"), + ) } ?: emptyList() - QuestionInfoDto( - question = qo.str("question") ?: "", - header = qo.str("header") ?: "", - options = options, - multiple = qo.str("multiple") == "true", - custom = qo.str("custom") != "false", - ) - } ?: emptyList() - val ref = toolRef(obj) - return QuestionRequestDto(id, sid, questions, ref) + val ref = toolRef(obj) + return QuestionRequestDto(id, sid, questions, ref, blocking = obj.flag("blocking", false)) } internal fun parseModelFavorites(raw: JsonElement?): List { @@ -869,6 +884,11 @@ private fun JsonObject.long(key: String): Long? = private fun JsonObject?.bool(key: String): Boolean = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false +private fun JsonObject.flag(key: String, default: Boolean): Boolean { + val prim = this[key]?.jsonPrimitive ?: return default + return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default +} + private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() private fun JsonObject?.map(key: String): Map { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 0192304ef4..6346fa097f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -116,6 +116,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.SessionCreated -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID is ChatEventDto.PermissionAsked -> event.sessionID @@ -130,7 +131,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID } - val passes = sid == null || sid == id + val passes = event is ChatEventDto.SessionCreated || sid == null || sid == id if (passes) LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" } else LOG.debug { "${ChatLogSummary.sid(id)} pass=false srcSid=$sid ${ChatLogSummary.eventBody(event)}" } passes diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index c614d14bdb..07ecabd071 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -415,6 +415,30 @@ class KiloCliDataParserTest { assertEquals(2, result.session.summary?.files) } + @Test + fun `parseChatEvent - session created`() { + val data = globalEvent(""" + "type": "session.created", + "properties": { + "sessionID": "ses_new", + "info": { + "id": "ses_new", + "projectID": "proj_1", + "directory": "/test", + "title": "Implementation", + "version": "1", + "time": { "created": 1.0, "updated": 2.0 } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.created", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCreated) + assertEquals("ses_new", result.sessionID) + assertEquals("/test", result.info.directory) + } + @Test fun `parseChatEvent - session diff`() { val data = globalEvent(""" @@ -597,6 +621,48 @@ class KiloCliDataParserTest { assertEquals("A", result.request.questions[0].options[0].label) } + @Test + fun `parseChatEvent - plan follow-up question preserves fields`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_plan", + "sessionID": "ses_1", + "blocking": true, + "questions": [{ + "question": "Ready to implement?", + "questionKey": "plan.followup.question", + "header": "Implement", + "headerKey": "plan.followup.header", + "multiple": false, + "custom": true, + "options": [{ + "label": "Continue here", + "labelKey": "plan.followup.answer.continue", + "description": "Implement the plan in this session", + "descriptionKey": "plan.followup.answer.continue.description", + "mode": "code" + }] + }], + "tool": null + } + """) + + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals(true, result.request.blocking) + val item = result.request.questions.single() + assertEquals("plan.followup.question", item.questionKey) + assertEquals("plan.followup.header", item.headerKey) + assertEquals(false, item.multiple) + assertEquals(true, item.custom) + val opt = item.options.single() + assertEquals("plan.followup.answer.continue", opt.labelKey) + assertEquals("plan.followup.answer.continue.description", opt.descriptionKey) + assertEquals("code", opt.mode) + } + @Test fun `parseChatEvent - question replied`() { val data = globalEvent(""" @@ -732,11 +798,16 @@ class KiloCliDataParserTest { @Test fun `parseQuestionRequests - parses list`() { val raw = """[ - {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + {"id": "q1", "sessionID": "s1", "blocking": true, "questions": [{"question": "pick", "questionKey": "q.key", "header": "h", "headerKey": "h.key", "multiple": true, "custom": false, "options": [{"label": "A", "description": "B", "mode": "code"}]}]} ]""" val result = KiloCliDataParser.parseQuestionRequests(raw) assertEquals(1, result.size) assertEquals("q1", result[0].id) + assertEquals(true, result[0].blocking) + assertEquals("q.key", result[0].questions[0].questionKey) + assertEquals(true, result[0].questions[0].multiple) + assertEquals(false, result[0].questions[0].custom) + assertEquals("code", result[0].questions[0].options[0].mode) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index c06995e0cc..84804330f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -177,7 +177,7 @@ class SessionUi( progressBody = load question = QuestionView( project = project, - reply = { id, dto -> controller.replyQuestion(id, dto) }, + reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) }, reject = { id -> controller.rejectQuestion(id) }, scroll = { scroll.followBottom(true) }, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a72f6fe55a..547f8b6597 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import java.awt.Component +import java.nio.file.Path /** * Session lifecycle orchestrator for a single session. @@ -82,11 +83,14 @@ class SessionController( ) : Disposable { private data class OrganizationTarget(val org: String?) + private data class Followup(val dir: String, val time: Long) companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 internal const val DISPLAY_DELAY_MS = 1_000L + private const val FOLLOWUP_TTL_MS = 30_000L + private const val FOLLOWUP_NEW_SESSION = "Start new session" } init { @@ -126,6 +130,7 @@ class SessionController( private var lastProfile: ProfileDto? = null private var target: OrganizationTarget? = null private var loginRetry: PromptDto? = null + private var followup: Followup? = null val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession @@ -350,14 +355,23 @@ class SessionController( updateModel { model.setState(SessionState.AwaitingPermission(perm)) } } - fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + fun replyQuestion(requestId: String, answers: QuestionReplyDto, options: List> = answers.answers) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } + val current = model.state + val mode = if (current is SessionState.AwaitingQuestion && current.question.id == requestId) { + selectedMode(current.question, options) + } else null + if (!mode.isNullOrBlank() && mode != model.agent) selectAgent(mode) + followup = if (answers.answers.firstOrNull()?.firstOrNull()?.trim() == FOLLOWUP_NEW_SESSION) { + Followup(directory, System.currentTimeMillis()) + } else null cs.launch { try { sessions.replyQuestion(requestId, directory, answers) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { + edt { followup = null } LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } @@ -365,6 +379,7 @@ class SessionController( fun rejectQuestion(requestId: String) { assertEdt() + followup = null LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { @@ -737,13 +752,14 @@ class SessionController( // Other reasons: don't clobber a more specific terminal state (Error, // AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close. val current = model.state - val clobberOk = event.reason == "completed" - || current is SessionState.Busy + val clobberOk = current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline if (clobberOk) model.setState(SessionState.Idle) } + is ChatEventDto.SessionCreated -> adoptFollowup(event.info) + is ChatEventDto.Error -> { partType = null tool = null @@ -955,6 +971,29 @@ class SessionController( } } + private fun selectedMode(question: Question, options: List>): String? { + for ((idx, labels) in options.withIndex()) { + val item = question.items.getOrNull(idx) ?: continue + for (label in labels) { + val mode = item.options.firstOrNull { it.label == label }?.mode + if (!mode.isNullOrBlank()) return mode + } + } + return null + } + + private fun adoptFollowup(session: SessionDto) { + assertEdt() + val item = followup ?: return + if (System.currentTimeMillis() - item.time > FOLLOWUP_TTL_MS) { + followup = null + return + } + if (pathKey(item.dir) != pathKey(session.directory)) return + followup = null + open(SessionRef.Local(session)) + } + private fun updateModel(block: () -> Unit) { assertEdt() if (disposed) return @@ -1345,6 +1384,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.PartRemoved -> event.sessionID == id is ChatEventDto.TurnOpen -> event.sessionID == id is ChatEventDto.TurnClose -> event.sessionID == id + is ChatEventDto.SessionCreated -> true is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id is ChatEventDto.MessageRemoved -> event.sessionID == id is ChatEventDto.PermissionAsked -> event.sessionID == id @@ -1413,6 +1453,12 @@ private fun parseModel(value: String): Pair? { return value.substring(0, slash) to value.substring(slash + 1) } +private fun pathKey(value: String): String = runCatching { + Path.of(value).normalize().toString().trimEnd('/', '\\') +}.getOrElse { + value.replace('\\', '/').trimEnd('/') +} + private sealed interface RecentsState { data object Idle : RecentsState data class Loading(val id: Any = Any()) : RecentsState @@ -1506,12 +1552,22 @@ private fun toQuestion(dto: QuestionRequestDto): Question { QuestionItem( question = it.question, header = it.header, - options = it.options.map { opt -> QuestionOption(opt.label, opt.description) }, + options = it.options.map { opt -> + QuestionOption( + label = opt.label, + description = opt.description, + labelKey = opt.labelKey, + descriptionKey = opt.descriptionKey, + mode = opt.mode, + ) + }, multiple = it.multiple, custom = it.custom, + questionKey = it.questionKey, + headerKey = it.headerKey, ) } - return Question(id = dto.id, items = items, tool = ref) + return Question(id = dto.id, items = items, tool = ref, blocking = dto.blocking) } private fun String.toDumpText(): String { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt index 70ea656fdb..aba1b54b6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt @@ -7,6 +7,7 @@ data class Question( val items: List, val tool: ToolCallRef? = null, val state: QuestionRequestState = QuestionRequestState.PENDING, + val blocking: Boolean = false, ) data class QuestionItem( @@ -15,9 +16,14 @@ data class QuestionItem( val options: List, val multiple: Boolean, val custom: Boolean, + val questionKey: String? = null, + val headerKey: String? = null, ) data class QuestionOption( val label: String, val description: String, + val labelKey: String? = null, + val descriptionKey: String? = null, + val mode: String? = null, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt new file mode 100644 index 0000000000..6d89fa4346 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -0,0 +1,89 @@ +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.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.BoxLayout +import javax.swing.JPanel + +class PlanExitView(tool: Tool) : PartView() { + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED + } + + override val contentId: String = tool.id + + private var item = tool + + private val title = JBLabel(KiloBundle.message("session.part.plan.ready"), AllIcons.Actions.Checked, JBLabel.LEFT) + private val path = JBLabel().apply { + foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED + setCopyable(true) + } + private val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + } + private val root = JPanel(BorderLayout()).apply { + isOpaque = true + background = SessionUiStyle.View.surface() + border = SessionUiStyle.View.card() + } + + init { + layout = BorderLayout() + isOpaque = false + body.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + body.add(title) + body.add(path) + root.add(body, BorderLayout.CENTER) + add(root, BorderLayout.CENTER) + applyStyle(SessionEditorStyle.current()) + sync() + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + sync() + } + + override fun applyStyle(style: SessionEditorStyle) { + title.font = style.boldEditorFont + path.font = style.smallEditorFont + } + + fun labelText(): String = listOf(title.text, path.text).filter { it.isNotBlank() }.joinToString(" ") + + private fun sync() { + title.foreground = UiStyle.Colors.fg() + val plan = plan(item) + path.text = plan + path.isVisible = plan.isNotBlank() + } + + override fun dumpLabel() = "PlanExitView#$contentId(${labelText()})" +} + +private fun plan(tool: Tool): String { + tool.metadata["plan"]?.takeIf { it.isNotBlank() }?.let { return it } + val out = tool.output ?: return "" + return Regex("Plan is ready at (.+?)(?:\\. Ending planning turn\\.|$)") + .find(out) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?: "" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 59c9b360dc..9036d8928d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -23,7 +23,11 @@ object ViewFactory { fun create(content: Content): PartView = when (content) { is Text -> TextView(content) is Reasoning -> ReasoningView(content) - is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content) + is Tool -> when { + PlanExitView.canRender(content) -> PlanExitView(content) + QuestionResultView.canRender(content) -> QuestionResultView(content) + else -> ToolView(content) + } is Compaction -> CompactionView(content) is StepFinish -> error("step-finish is timeline-only") is Generic -> GenericView(content) @@ -36,6 +40,8 @@ object ViewFactory { */ fun shouldReplace(view: PartView, content: Content): Boolean { if (content !is Tool) return false + if (view is PlanExitView) return !PlanExitView.canRender(content) + if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) if (view is ToolView) return QuestionResultView.canRender(content) return false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 2613300e2d..8153e10eb8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -42,7 +42,7 @@ import com.intellij.openapi.editor.event.DocumentListener /** Question tool form rendered inside the session transcript. */ class QuestionView( private val project: Project, - private val reply: (String, QuestionReplyDto) -> Unit, + private val reply: (String, QuestionReplyDto, List>) -> Unit, private val reject: (String) -> Unit, private val scroll: () -> Unit = {}, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { @@ -255,6 +255,8 @@ class QuestionView( } } + private fun optionAnswers(i: Int): List = selections.getOrNull(i)?.toList() ?: emptyList() + private fun addContent(item: QuestionItem, set: MutableSet) { val opts = optionList(item, set) opts.alignmentX = Component.LEFT_ALIGNMENT @@ -709,7 +711,8 @@ class QuestionView( val id = request ?: return if ((question?.items?.indices ?: return).any { !isReady(it) }) return val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) } - reply(id, QuestionReplyDto(answers)) + val opts = (question?.items?.indices ?: return).map { optionAnswers(it) } + reply(id, QuestionReplyDto(answers), opts) hideView() } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 763099b2a0..588e26506c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -81,6 +81,7 @@ session.part.tool.read=Read session.part.tool.running=Running session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. +session.part.plan.ready=Plan is ready session.error.prompt=Prompt failed session.error.compact=Session compact failed diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index aa095e0a13..e0e38ac65a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.SessionRef import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -169,6 +170,112 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("q1", rpc.questionReplies[0].first) } + fun `test plan follow-up question enters awaiting state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + assertSession( + """ + question#q_plan + tool: + header: Implement + prompt: Ready to implement? + option: Start new session - Implement in a fresh session with a clean context + option: Continue here - Implement the plan in this session + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, + ) + } + + fun `test continue here switches mode and sends canonical reply`() { + val (m, _, _) = prompted() + edt { m.model.agent = "plan" } + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Continue here"))), + listOf(listOf("Continue here")), + ) + } + flush() + + assertEquals("code", m.model.agent) + assertEquals("code", rpc.configs.last().second.agent) + assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) + } + + fun `test custom plan follow-up answer does not switch mode`() { + val (m, _, _) = prompted() + edt { m.model.agent = "plan" } + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Need to adjust scope"))), + listOf(emptyList()), + ) + } + flush() + + assertEquals("plan", m.model.agent) + assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) + } + + fun `test start new session adopts matching created session`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Start new session"))), + listOf(listOf("Start new session")), + ) + } + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test"))) + flush() + + assertEquals("ses_new", (opened.last() as SessionRef.Local).id) + assertEquals(1, rpc.prompts.size) + } + + fun `test unrelated session created is ignored`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Start new session"))), + listOf(listOf("Start new session")), + ) + } + + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/other"))) + + assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" }) + } + fun `test rejectQuestion calls RPC`() { val (m, _, _) = prompted() emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) @@ -348,4 +455,21 @@ class PromptLifecycleTest : SessionControllerTestBase() { ), tool = ToolRefDto("msg1", "call1"), ) + + private fun planQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Ready to implement?", + header = "Implement", + options = listOf( + QuestionOptionDto("Start new session", "Implement in a fresh session with a clean context"), + QuestionOptionDto("Continue here", "Implement the plan in this session", mode = "code"), + ), + multiple = false, + custom = true, + ), + ), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 51bd2bf2e4..4b0a377c6d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -128,8 +128,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { id: String? = null, flushMs: Long = Long.MAX_VALUE, displayMs: Long = Long.MAX_VALUE, + open: (SessionRef) -> Unit = {}, ): SessionController { - return controller(id, flushMs, true, displayMs = displayMs) + return controller(id, flushMs, true, displayMs = displayMs, open = open) } protected fun controller( @@ -148,6 +149,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { session: SessionDto? = null, beforeUpdate: () -> Boolean = { false }, afterUpdate: (Boolean) -> Unit = {}, + open: (SessionRef) -> Unit = {}, ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id), ): SessionController { val root = Root() @@ -162,6 +164,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { flushMs, condense, displayMs, + open = open, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 45959beeb2..fb6333cde4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -454,7 +454,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private fun panelWithPrompts(): SessionMessageListPanel { val q = QuestionView( project = project, - reply = { _, _ -> }, + reply = { _, _, _ -> }, reject = { _ -> }, ) val p = PermissionView( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt new file mode 100644 index 0000000000..261d7d799a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -0,0 +1,38 @@ +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 com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class PlanExitViewTest : BasePlatformTestCase() { + fun `test completed plan exit renders ready text and path`() { + val tool = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/x.md") + } + + val view = PlanExitView(tool) + + assertTrue(view.labelText().contains("Plan is ready")) + assertTrue(view.labelText().contains(".kilo/plans/x.md")) + } + + fun `test view factory replaces running tool with plan exit view when completed`() { + val running = tool(ToolExecState.RUNNING) + val existing = ViewFactory.create(running) + assertTrue(existing is ToolView) + + val done = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/x.md") + } + + assertTrue(ViewFactory.shouldReplace(existing, done)) + assertTrue(ViewFactory.create(done) is PlanExitView) + } + + private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { + this.state = state + output = "Plan is ready at .kilo/plans/x.md. Ending planning turn." + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index b35c7e1538..dcd49e0da9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -25,7 +25,7 @@ import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class QuestionViewTest : BasePlatformTestCase() { - private val replies = mutableListOf>() + private val replies = mutableListOf>>>() private val rejects = mutableListOf() private var scrolls = 0 private lateinit var view: QuestionView @@ -34,7 +34,7 @@ class QuestionViewTest : BasePlatformTestCase() { super.setUp() view = QuestionView( project = project, - reply = { id, dto -> replies.add(id to dto) }, + reply = { id, dto, opts -> replies.add(Triple(id, dto, opts)) }, reject = { id -> rejects.add(id) }, scroll = { scrolls++ }, ) @@ -139,6 +139,7 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(1, replies.size) assertEquals("req_2", replies.single().first) assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + assertEquals(listOf(listOf("Minimal")), replies.single().third) } fun `test submit is disabled until question is answered`() { @@ -594,6 +595,37 @@ class QuestionViewTest : BasePlatformTestCase() { assertFalse(view.isVisible) assertEquals(1, replies.size) assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers) + assertEquals(listOf(emptyList()), replies.single().third) + } + + fun `test plan follow-up sends selected option labels separately`() { + view.show( + Question( + id = "q_plan", + items = listOf( + QuestionItem( + question = "Ready to implement?", + header = "Implement", + options = listOf( + QuestionOption("Start new session", "Implement in a fresh session with a clean context"), + QuestionOption("Continue here", "Implement the plan in this session", mode = "code"), + ), + multiple = false, + custom = true, + ) + ), + ) + ) + + assertLabelsContain(view, "Ready to implement?") + assertLabelsContain(view, "Start new session") + assertLabelsContain(view, "Continue here") + assertLabelsContain(view, "Add your own response") + option(view, "Continue here").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("Continue here")), replies.single().second.answers) + assertEquals(listOf(listOf("Continue here")), replies.single().third) } fun `test custom editor grows for wrapped input`() { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 64e5966999..664f5e22e5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -21,6 +21,7 @@ object ChatLogSummary { is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.SessionCreated -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID is ChatEventDto.PermissionAsked -> event.sessionID @@ -133,6 +134,12 @@ object ChatLogSummary { "reason=${event.reason}", ) + is ChatEventDto.SessionCreated -> join( + sid(event.sessionID), + "evt=session.created", + "title=${event.info.title.length}", + ) + is ChatEventDto.Error -> join( sid(event.sessionID), "evt=session.error", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index e333599e2b..09993d685c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -147,6 +147,13 @@ sealed class ChatEventDto { val reason: String, ) : ChatEventDto() + @Serializable + @SerialName("session.created") + data class SessionCreated( + val sessionID: String, + val info: SessionDto, + ) : ChatEventDto() + @Serializable @SerialName("session.error") data class Error( @@ -291,6 +298,7 @@ data class QuestionRequestDto( val sessionID: String, val questions: List, val tool: ToolRefDto? = null, + val blocking: Boolean = false, ) @Serializable @@ -300,12 +308,17 @@ data class QuestionInfoDto( val options: List = emptyList(), val multiple: Boolean = false, val custom: Boolean = true, + val questionKey: String? = null, + val headerKey: String? = null, ) @Serializable data class QuestionOptionDto( val label: String, val description: String, + val labelKey: String? = null, + val descriptionKey: String? = null, + val mode: String? = null, ) @Serializable diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 23038af597..8045ee881b 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -285,7 +285,7 @@ export namespace PlanFollowup { // main prompt input below the dock already routes typed text as a question // reply, so "Type your own answer" would be redundant (originally hidden in // 65566af7f8, flipped back during the v1.4.4 upstream merge). - custom: Flag.KILO_CLIENT === "cli", + custom: Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "jetbrains", options: [ { label: ANSWER_NEW_SESSION, diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index c7fdad6ed5..4980342079 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -28,7 +28,7 @@ export namespace KiloSessionPrompt { */ export function shouldAskPlanFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) { if (input.abort.aborted) return false - if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false + if (!["cli", "vscode", "jetbrains"].includes(Flag.KILO_CLIENT)) return false const idx = input.messages.findLastIndex((m) => m.info.role === "user") return input.messages .slice(idx + 1) diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index f6642684a7..5e6371b71b 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -145,6 +145,49 @@ describe("plan_exit detection", () => { await expect(pending).resolves.toBe("break") })) + test("JetBrains client enables plan follow-up with custom answer", () => + withInstance(async () => { + const prev = process.env.KILO_CLIENT + try { + process.env.KILO_CLIENT = "jetbrains" + const seeded = await seed({ + text: "Here is the plan", + tools: [ + { + tool: "plan_exit", + input: {}, + output: "Plan is ready. Ending planning turn.", + }, + ], + }) + + expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true) + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const question = await waitQuestion(seeded.sessionID) + expect(question).toBeDefined() + if (!question) return + expect(question.questions[0].question).toBe("Ready to implement?") + expect(question.questions[0].header).toBe("Implement") + expect(question.questions[0].custom).toBe(true) + expect(question.questions[0].options.map((item) => item.label)).toEqual([ + PlanFollowup.ANSWER_NEW_SESSION, + PlanFollowup.ANSWER_CONTINUE, + ]) + expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code") + await Question.reject(question.id) + await expect(pending).resolves.toBe("break") + } finally { + if (prev === undefined) delete process.env.KILO_CLIENT + else process.env.KILO_CLIENT = prev + } + })) + test("PlanFollowup.ask triggers and continue works with plan_exit", () => withInstance(async () => { const seeded = await seed({ From 7de669ee4b3d8f4226c6a20c5235b036e179fe14 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 21:34:14 -0400 Subject: [PATCH 2/8] fix(jetbrains): render plan ready as transcript text --- .../client/session/views/PlanExitView.kt | 64 ++++++++----------- .../client/session/views/PlanExitViewTest.kt | 5 +- 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 6d89fa4346..6c5c31f1cf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -5,15 +5,9 @@ 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.session.views.base.PartView -import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI +import ai.kilocode.client.ui.md.MdView import java.awt.BorderLayout -import javax.swing.BoxLayout -import javax.swing.JPanel class PlanExitView(tool: Tool) : PartView() { companion object { @@ -23,33 +17,12 @@ class PlanExitView(tool: Tool) : PartView() { override val contentId: String = tool.id private var item = tool - - private val title = JBLabel(KiloBundle.message("session.part.plan.ready"), AllIcons.Actions.Checked, JBLabel.LEFT) - private val path = JBLabel().apply { - foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED - setCopyable(true) - } - private val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - } - private val root = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } + private val md = MdView.html() init { layout = BorderLayout() isOpaque = false - body.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - body.add(title) - body.add(path) - root.add(body, BorderLayout.CENTER) - add(root, BorderLayout.CENTER) + add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) sync() } @@ -61,20 +34,30 @@ class PlanExitView(tool: Tool) : PartView() { } override fun applyStyle(style: SessionEditorStyle) { - title.font = style.boldEditorFont - path.font = style.smallEditorFont + val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily + if (md.font != style.transcriptFont) md.font = style.transcriptFont + if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily + if (!changed) return + refresh() } - fun labelText(): String = listOf(title.text, path.text).filter { it.isNotBlank() }.joinToString(" ") + fun markdown(): String = md.markdown() private fun sync() { - title.foreground = UiStyle.Colors.fg() val plan = plan(item) - path.text = plan - path.isVisible = plan.isNotBlank() + val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan)) + .filterNotNull() + .joinToString(" ") + md.set(text) + refresh() } - override fun dumpLabel() = "PlanExitView#$contentId(${labelText()})" + private fun refresh() { + revalidate() + repaint() + } + + override fun dumpLabel() = "PlanExitView#$contentId" } private fun plan(tool: Tool): String { @@ -87,3 +70,10 @@ private fun plan(tool: Tool): String { ?.trim() ?: "" } + +private fun link(plan: String): String? { + if (plan.isBlank()) return null + val text = plan.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + val href = plan.replace(" ", "%20").replace("(", "%28").replace(")", "%29") + return "[$text]($href)" +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 261d7d799a..c6b104c442 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -7,15 +7,14 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") class PlanExitViewTest : BasePlatformTestCase() { - fun `test completed plan exit renders ready text and path`() { + fun `test completed plan exit renders ready transcript text and path`() { val tool = tool(ToolExecState.COMPLETED).apply { metadata = mapOf("plan" to ".kilo/plans/x.md") } val view = PlanExitView(tool) - assertTrue(view.labelText().contains("Plan is ready")) - assertTrue(view.labelText().contains(".kilo/plans/x.md")) + assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown()) } fun `test view factory replaces running tool with plan exit view when completed`() { From c81397044c36e5f08ab083a920c238f15a5743db Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 21:43:06 -0400 Subject: [PATCH 3/8] fix(jetbrains): hide single question progress --- .../kilocode/client/session/views/question/QuestionView.kt | 2 ++ .../ai/kilocode/client/session/views/QuestionViewTest.kt | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 8153e10eb8..2079d2ba23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -185,7 +185,9 @@ class QuestionView( val shown = minOf(idx + 1, total) summary.text = KiloBundle.message("session.question.summary", shown, total) summary.foreground = UiStyle.Colors.weak() + summary.isVisible = total > 1 nav.isVisible = total > 1 + topPanel.isVisible = total > 1 } private fun syncFooter(q: Question) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index dcd49e0da9..705a5d0834 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -128,6 +128,12 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(findAll(view).isEmpty()) } + fun `test single question hides progress summary`() { + view.show(singleSelectQuestion("req_summary")) + + assertTrue(findAll(view).none { it.text == "1 of 1 questions" && it.isVisible }) + } + fun `test single question submit sends selected answer`() { view.show(singleSelectQuestion("req_2")) From 0851f4024ad9e525d39c43ff6f2b29b6d206ab37 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 25 May 2026 13:17:07 -0400 Subject: [PATCH 4/8] fix(jetbrains): bound app load recovery --- .../backend/app/KiloBackendAppService.kt | 35 ++++-- .../app/KiloBackendConnectionService.kt | 26 ++++- .../backend/cli/KiloBackendHttpClients.kt | 15 ++- .../backend/app/KiloBackendAppServiceTest.kt | 102 +++++++++++++++++- .../kilocode/backend/testing/MockCliServer.kt | 4 + .../client/actions/ReinstallKiloAction.kt | 4 +- .../client/actions/RestartKiloAction.kt | 4 +- .../client/actions/KiloRecoveryActionsTest.kt | 34 ++++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 6 ++ 9 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index fb0f6d8430..81492b4799 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -34,7 +34,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -68,6 +70,7 @@ class KiloBackendAppService private constructor( private val cs: CoroutineScope, private val server: CliServer, private val log: KiloLog, + private val loadTimeoutMs: Long, ) : Disposable { /** IntelliJ service injection entry point. */ @@ -75,24 +78,27 @@ class KiloBackendAppService private constructor( cs, KiloBackendCliManager(), KiloLog.create(KiloBackendAppService::class.java), + APP_LOAD_TIMEOUT_MS, ) companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L + private const val APP_LOAD_TIMEOUT_MS = 30_000L /** Test factory — no IntelliJ deps needed. */ internal fun create( cs: CoroutineScope, server: CliServer, log: KiloLog, - ) = KiloBackendAppService(cs, server, log) + loadTimeoutMs: Long = APP_LOAD_TIMEOUT_MS, + ) = KiloBackendAppService(cs, server, log, loadTimeoutMs) } private val mutex = Mutex() private val connection = KiloConnectionService(cs, server, onReconnect = { cs.launch { reconnect() } - }, log = log) + }, appLoadTimeoutMs = loadTimeoutMs, log = log) private var watcher: Job? = null private var eventWatcher: Job? = null @@ -250,7 +256,8 @@ class KiloBackendAppService private constructor( var warns: List = emptyList() try { - coroutineScope { + withTimeout(loadTimeoutMs) { + coroutineScope { launch { val result = fetchProfile() val status = when { @@ -291,11 +298,11 @@ class KiloBackendAppService private constructor( throw LoadFailure(err) } } - launch { - warns = fetchWarnings() } } + warns = fetchWarnings() + ensureActive() profile = prof config = cfg @@ -313,6 +320,16 @@ class KiloBackendAppService private constructor( ) log.info("Application started — config, profile, notifications loaded") startWatchingGlobalSseEvents() + } catch (e: TimeoutCancellationException) { + val err = LoadError( + resource = "app", + detail = "Timed out loading app data after ${loadTimeoutMs}ms", + ) + log.warn("Application start timed out after ${loadTimeoutMs}ms") + setAppError( + message = "Failed to load required data", + errors = errors.toList() + err, + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -336,7 +353,7 @@ class KiloBackendAppService private constructor( * as failures. */ private suspend fun fetchProfile(): FetchResult { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.ok(null) return try { val response = client.kiloProfile() @@ -364,7 +381,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchConfig(): FetchResult { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.fail("config", detail = "Not connected") return try { FetchResult.ok(client.globalConfigGet()) @@ -376,7 +393,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchNotifications(): FetchResult> { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.fail("notifications", detail = "Not connected") return try { FetchResult.ok(client.kiloNotifications()) @@ -388,7 +405,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchWarnings(): List { - val client = connection.api ?: return emptyList() + val client = connection.appLoadApi ?: return emptyList() return try { client.configWarnings().map(::warning) } catch (e: Exception) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index d9a891cbfc..4eccd0b4c7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -44,6 +44,7 @@ data class SseEvent(val type: String, val data: String) * * Uses two separate OkHttp clients mirroring the VS Code architecture: * - [apiClient]: no call/read timeout — used for the generated API client and SSE + * - app-load client: bounded timeout — used for startup REST calls * - [healthClient]: 3 s timeout — used only for `/global/health` polling * * The generated [DefaultApi] is configured with [apiClient] and exposed via [api] @@ -59,9 +60,23 @@ class KiloConnectionService( private val cs: CoroutineScope, private val server: CliServer, private val onReconnect: () -> Unit, - private val log: KiloLog = KiloLog.create(KiloConnectionService::class.java), + private val log: KiloLog, + private val appLoadTimeoutMs: Long, ) { + constructor( + cs: CoroutineScope, + server: CliServer, + onReconnect: () -> Unit, + ) : this(cs, server, onReconnect, KiloLog.create(KiloConnectionService::class.java), 30_000L) + + constructor( + cs: CoroutineScope, + server: CliServer, + onReconnect: () -> Unit, + log: KiloLog, + ) : this(cs, server, onReconnect, log, 30_000L) + companion object { private const val HEARTBEAT_TIMEOUT_MS = 15_000L private const val HEALTH_POLL_INTERVAL_MS = 10_000L @@ -81,6 +96,9 @@ class KiloConnectionService( /** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */ var apiClient: OkHttpClient? = null private set + var appLoadApi: DefaultApi? = null + private set + private var appLoadClient: OkHttpClient? = null private var healthClient: OkHttpClient? = null /** Port the CLI server is listening on. Zero when disconnected. */ var port = 0 @@ -175,12 +193,15 @@ class KiloConnectionService( // Create dual OkHttp clients (bundled — no IntelliJ platform deps) val ac = KiloBackendHttpClients.api(password) + val lc = KiloBackendHttpClients.appLoad(password, appLoadTimeoutMs) val hc = KiloBackendHttpClients.health(password) apiClient = ac + appLoadClient = lc healthClient = hc // Configure generated API client with the no-timeout api client api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac) + appLoadApi = DefaultApi(basePath = "http://127.0.0.1:$port", client = lc) startSse() startHeartbeatWatcher() @@ -327,8 +348,11 @@ class KiloConnectionService( private fun close() { api = null + appLoadApi = null apiClient?.let { KiloBackendHttpClients.shutdown(it) } apiClient = null + appLoadClient?.let { KiloBackendHttpClients.shutdown(it) } + appLoadClient = null healthClient?.let { KiloBackendHttpClients.shutdown(it) } healthClient = null } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt index 3e71cf2bf3..b305c8f4a2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt @@ -7,10 +7,11 @@ import java.util.Base64 import java.util.concurrent.TimeUnit /** - * Factory for the two OkHttp clients used by the plugin. + * Factory for the OkHttp clients used by the plugin. * * Mirrors the VS Code architecture: * - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long) + * - [appLoad] client has a bounded timeout for startup REST calls * - [health] client has a short 3 s timeout and a small dedicated connection pool * * Both clients bundle Basic Auth via an interceptor and are fully independent @@ -30,6 +31,18 @@ object KiloBackendHttpClients { .readTimeout(0, TimeUnit.MILLISECONDS) .build() + /** App-load client — bounded timeout for required startup REST calls. */ + fun appLoad(password: String, timeoutMs: Long): OkHttpClient { + val timeout = timeoutMs.coerceAtLeast(1L) + return OkHttpClient.Builder() + .addInterceptor(auth(password)) + .connectTimeout(CONNECT_TIMEOUT_MS.coerceAtMost(timeout), TimeUnit.MILLISECONDS) + .callTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .connectionPool(ConnectionPool(2, 30, TimeUnit.SECONDS)) + .build() + } + /** Health client — short timeout, dedicated connection pool. */ fun health(password: String): OkHttpClient = OkHttpClient.Builder() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 6864fb20f0..f63b25cb5d 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -37,8 +37,8 @@ class KiloBackendAppServiceTest { mock.close() } - private fun create(): KiloBackendAppService = - KiloBackendAppService.create(scope, FakeCliServer(mock), log) + private fun create(loadTimeoutMs: Long = 30_000L): KiloBackendAppService = + KiloBackendAppService.create(scope, FakeCliServer(mock), log, loadTimeoutMs) @Test fun `full lifecycle reaches Ready`() = runBlocking { @@ -404,6 +404,104 @@ class KiloBackendAppServiceTest { } } + @Test + fun `hung app load transitions from Loading to Error`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 300L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + val err = withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Error } + } as KiloAppState.Error + + assertEquals("Failed to load required data", err.message) + assertTrue(err.errors.any { it.detail?.contains("timeout", ignoreCase = true) == true }) + } finally { + gate.countDown() + } + } + + @Test + fun `hung warnings do not prevent Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.warningsGate = gate + val svc = create(loadTimeoutMs = 300L) + + try { + svc.connect() + + val ready = withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } as KiloAppState.Ready + + assertTrue(ready.data.warnings.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + } finally { + gate.countDown() + } + } + + @Test + fun `restart during Loading cancels stale load and reaches Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 500L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + gate.countDown() + svc.restart() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertFalse(log.messages.any { it.contains("Application start timed out") }) + } finally { + gate.countDown() + } + } + + @Test + fun `reinstall during Loading cancels stale load and reaches Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 500L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + gate.countDown() + svc.reinstall() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertFalse(log.messages.any { it.contains("Application start timed out") }) + } finally { + gate.countDown() + } + } + @Test fun `SSE config updated event refreshes config`() = runBlocking { mock.config = """{"model":"initial"}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index bdde10d818..12ff956be7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -94,6 +94,9 @@ class MockCliServer : AutoCloseable { /** Optional gate for REST responses; SSE stays unblocked so the app can enter Loading. */ @Volatile var responseGate: CountDownLatch? = null + /** Optional gate for config warnings only. */ + @Volatile var warningsGate: CountDownLatch? = null + /** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */ private val counts = ConcurrentHashMap() @@ -223,6 +226,7 @@ class MockCliServer : AutoCloseable { val delay = responseDelay if (delay > 0) Thread.sleep(delay) if (bare != "/global/event") responseGate?.await() + if (bare.startsWith("/config/warnings")) warningsGate?.await() when { path == "/global/health" -> respond(output, 200, health) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index 7eab2842c6..c5bd9574cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -1,7 +1,6 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService -import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -13,7 +12,6 @@ class ReinstallKiloAction : AnAction(), DumbAware { } override fun update(e: AnActionEvent) { - val status = service().state.value.status - e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING + e.presentation.isEnabled = true } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index 335cf611f0..1df776223e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -1,7 +1,6 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService -import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -13,7 +12,6 @@ class RestartKiloAction : AnAction(), DumbAware { } override fun update(e: AnActionEvent) { - val status = service().state.value.status - e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING + e.presentation.isEnabled = true } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt new file mode 100644 index 0000000000..f99429a167 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class KiloRecoveryActionsTest : BasePlatformTestCase() { + fun `test restart action stays enabled for all app states`() { + val action = RestartKiloAction() + val event = event(action) + + ActionUtil.updateAction(action, event) + + assertTrue("Restart should force-enable recovery action", event.presentation.isEnabled) + } + + fun `test reinstall action stays enabled for all app states`() { + val action = ReinstallKiloAction() + val event = event(action) + + ActionUtil.updateAction(action, event) + + assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) + } + + private fun event(action: AnAction): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + presentation.isEnabled = false + return AnActionEvent.createFromDataContext("", presentation) { null } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index a91623f165..6ebca7de30 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -35,6 +35,10 @@ class FakeAppRpcApi : KiloAppRpcApi { private set var retries = 0 private set + var restarts = 0 + private set + var reinstalls = 0 + private set override suspend fun connect() { assertNotEdt("connect") @@ -58,10 +62,12 @@ class FakeAppRpcApi : KiloAppRpcApi { override suspend fun restart() { assertNotEdt("restart") + restarts += 1 } override suspend fun reinstall() { assertNotEdt("reinstall") + reinstalls += 1 } override suspend fun modelState(): ModelStateDto { From a9b90cae3ac7044947a15a5ef3de250b7dcc644b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 25 May 2026 14:14:01 -0400 Subject: [PATCH 5/8] fix(jetbrains): recover plan follow-up prefs from messages --- .../session/controller/SessionController.kt | 100 +++++++++++++++--- .../session/controller/HistoryLoadingTest.kt | 69 ++++++++++++ .../session/controller/PromptLifecycleTest.kt | 84 +++++++++++++-- 3 files changed, 227 insertions(+), 26 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 547f8b6597..37cebe7640 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -26,6 +26,8 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileStatusDto @@ -84,6 +86,7 @@ class SessionController( private data class OrganizationTarget(val org: String?) private data class Followup(val dir: String, val time: Long) + private data class Pref(val agent: String?, val model: String?, val variants: List, val variant: String?, val reset: Boolean) companion object { private val LOG = KiloLog.create(SessionController::class.java) @@ -131,6 +134,10 @@ class SessionController( private var target: OrganizationTarget? = null private var loginRetry: PromptDto? = null private var followup: Followup? = null + private var agentTime: Double? = null + private var prefModel: String? = null + private var prefAgent: String? = null + private var modelTime: Double? = null val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession @@ -276,6 +283,10 @@ class SessionController( fun selectAgent(name: String) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" } + agentTime = null + modelTime = null + prefModel = null + prefAgent = null cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -295,6 +306,9 @@ class SessionController( val agent = model.agent ?: return val key = "$provider/$id" if (item(key) == null && model.workspace.providers != null) return + modelTime = null + prefModel = null + prefAgent = null app.selectModel(agent, provider, id) selectResolvedModel(key) model.modelOverride = model.defaultModel != model.model @@ -359,11 +373,10 @@ class SessionController( assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } val current = model.state - val mode = if (current is SessionState.AwaitingQuestion && current.question.id == requestId) { - selectedMode(current.question, options) - } else null - if (!mode.isNullOrBlank() && mode != model.agent) selectAgent(mode) - followup = if (answers.answers.firstOrNull()?.firstOrNull()?.trim() == FOLLOWUP_NEW_SESSION) { + followup = if (current is SessionState.AwaitingQuestion + && current.question.id == requestId + && options.any { labels -> labels.any { it.trim() == FOLLOWUP_NEW_SESSION } } + ) { Followup(directory, System.currentTimeMillis()) } else null cs.launch { @@ -504,6 +517,7 @@ class SessionController( if (sid != id) return@runEdt updateModel { this@SessionController.model.loadHistory(items) + syncHistoryAgent(items) if (session != null) this@SessionController.model.setSession(session) } } @@ -551,6 +565,7 @@ class SessionController( setRecentSessionsState(RecentsState.Idle) updateModel { this@SessionController.model.loadHistory(items) + syncHistoryAgent(items) this@SessionController.model.setSession(session) } } @@ -716,6 +731,7 @@ class SessionController( when (event) { is ChatEventDto.MessageUpdated -> { val added = model.upsertMessage(event.info) + syncMessagePrefs(event.info) if (added) showSession() } @@ -916,10 +932,11 @@ class SessionController( val selected = selectedModel(agent, auto) model.defaultModel = auto selectResolvedModel(selected) - model.modelOverride = selected != auto + model.modelOverride = messageSelection(agent) == null && selected != auto } private fun selectedModel(agent: String, auto: String?): String? { + messageSelection(agent)?.let { return it.key } val saved = app.models.value.model[agent] val cfg = model.app.config if (cfg != null) return resolveModelSelection( @@ -965,23 +982,17 @@ class SessionController( private fun item(key: String): ModelItem? = model.models.firstOrNull { it.key == key } + private fun messageSelection(agent: String): ModelSelectionDto? { + if (prefAgent != null && prefAgent != agent) return null + return valid(model.workspace.providers, prefModel?.let(::selection)) + } + private fun handle(events: List) { updateModel { for (event in events) handle(event) } } - private fun selectedMode(question: Question, options: List>): String? { - for ((idx, labels) in options.withIndex()) { - val item = question.items.getOrNull(idx) ?: continue - for (label in labels) { - val mode = item.options.firstOrNull { it.label == label }?.mode - if (!mode.isNullOrBlank()) return mode - } - } - return null - } - private fun adoptFollowup(session: SessionDto) { assertEdt() val item = followup ?: return @@ -994,6 +1005,61 @@ class SessionController( open(SessionRef.Local(session)) } + private fun syncHistoryAgent(items: List) { + val before = model.prefs() + val agent = items + .map { it.info } + .filter { messageAgent(it) != null } + .maxByOrNull { it.time.created } + val msg = items + .map { it.info } + .filter { it.role == "user" && messageModel(it) != null } + .maxByOrNull { it.time.created } + agentTime = agent?.time?.created + modelTime = msg?.time?.created + messageAgent(agent)?.let { model.agent = it } + prefModel = messageModel(msg) + prefAgent = messageAgent(msg) ?: model.agent + syncModelSelection() + if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady) + } + + private fun syncMessagePrefs(info: MessageDto) { + val before = model.prefs() + val agent = messageAgent(info) + val prior = agentTime + if (agent != null && (info.time.created >= (prior ?: Double.NEGATIVE_INFINITY))) { + agentTime = info.time.created + model.agent = agent + } + val key = messageModel(info) + val last = modelTime + if (info.role == "user" && key != null && (info.time.created >= (last ?: Double.NEGATIVE_INFINITY))) { + modelTime = info.time.created + prefModel = key + prefAgent = agent ?: model.agent + } + syncModelSelection() + if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady) + } + + private fun messageAgent(info: MessageDto?): String? { + val agent = info?.agent?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (model.agents.isNotEmpty() && model.agents.none { it.name == agent }) return null + return agent + } + + private fun messageModel(info: MessageDto?): String? { + val msg = info ?: return null + val provider = msg.providerID?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val id = msg.modelID?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val key = "$provider/$id" + if (item(key) == null && model.workspace.providers != null) return null + return key + } + + private fun SessionModel.prefs(): Pref = Pref(agent, model, variants, variant, modelOverride) + private fun updateModel(block: () -> Unit) { assertEdt() if (disposed) return diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index baefa04e2b..da883cdbda 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -1,7 +1,14 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto class HistoryLoadingTest : SessionControllerTestBase() { @@ -76,4 +83,66 @@ class HistoryLoadingTest : SessionControllerTestBase() { c, ) } + + fun `test loaded history derives agent from latest message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy(agent = "plan", time = MessageTimeDto(1.0)), emptyList())) + rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant").copy(agent = "code", time = MessageTimeDto(2.0)), emptyList())) + + val c = controller("ses_test") + flush() + + assertEquals("code", c.model.agent) + } + + fun `test loaded history derives model from latest user message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady( + agents = agents(), + default = "plan", + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")), + ), + ), + connected = listOf("kilo", "anthropic"), + defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"), + ) + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy( + agent = "code", + providerID = "anthropic", + modelID = "claude", + time = MessageTimeDto(1.0), + ), emptyList())) + + val c = controller("ses_test") + flush() + + assertEquals("code", c.model.agent) + assertEquals("anthropic/claude", c.model.model) + assertFalse(c.model.modelOverride) + } + + fun `test empty loaded history keeps workspace default agent`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") + + val c = controller("ses_test") + flush() + + assertEquals("plan", c.model.agent) + } + + private fun agents() = listOf( + AgentDto(name = "plan", displayName = "Plan", mode = "plan"), + AgentDto(name = "code", displayName = "Code", mode = "code"), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index e0e38ac65a..a2f4037030 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -4,12 +4,15 @@ import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.SessionRef +import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto @@ -192,8 +195,15 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } - fun `test continue here switches mode and sends canonical reply`() { - val (m, _, _) = prompted() + fun `test continue here reflects CLI mode after canonical reply`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = planWorkspace() + val m = controller() + val events = collect(m) + flush() + edt { m.prompt("go") } + flush() + events.clear() edt { m.model.agent = "plan" } emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) @@ -206,9 +216,21 @@ class PromptLifecycleTest : SessionControllerTestBase() { } flush() - assertEquals("code", m.model.agent) - assertEquals("code", rpc.configs.last().second.agent) + assertEquals("plan", m.model.agent) + assertTrue(rpc.configs.none { it.second.agent == "code" }) assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy( + agent = "code", + providerID = "anthropic", + modelID = "claude", + ))) + + assertEquals("code", m.model.agent) + assertEquals("anthropic/claude", m.model.model) + assertFalse(m.model.modelOverride) + assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertControllerEvents("WorkspaceReady", events) } fun `test custom plan follow-up answer does not switch mode`() { @@ -230,7 +252,31 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) } - fun `test start new session adopts matching created session`() { + fun `test start new session adopts matching created session from selected option`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Use a fresh implementation session"))), + listOf(listOf("Start new session")), + ) + } + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test"))) + flush() + + assertEquals("ses_new", (opened.last() as SessionRef.Local).id) + assertEquals(1, rpc.prompts.size) + } + + fun `test start new session reply text without selected option is ignored`() { val opened = mutableListOf() val m = controller(open = { opened.add(it) }) appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) @@ -244,14 +290,12 @@ class PromptLifecycleTest : SessionControllerTestBase() { m.replyQuestion( "q_plan", QuestionReplyDto(listOf(listOf("Start new session"))), - listOf(listOf("Start new session")), + listOf(emptyList()), ) } emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test"))) - flush() - assertEquals("ses_new", (opened.last() as SessionRef.Local).id) - assertEquals(1, rpc.prompts.size) + assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" }) } fun `test unrelated session created is ignored`() { @@ -472,4 +516,26 @@ class PromptLifecycleTest : SessionControllerTestBase() { ), ), ) + + private fun planWorkspace() = workspaceReady( + agents = listOf( + AgentDto(name = "plan", displayName = "Plan", mode = "plan"), + AgentDto(name = "code", displayName = "Code", mode = "code"), + ), + default = "plan", + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")), + ), + ), + connected = listOf("kilo", "anthropic"), + defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"), + ) } From 435232d5bab08bafaacd12e4dd94756ee832d816 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 25 May 2026 15:00:52 -0400 Subject: [PATCH 6/8] fix(jetbrains): open completed plan links --- .changeset/jolly-nebula.md | 5 ++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 84 +++++++++++++++++++ .../client/app/KiloWorkspaceService.kt | 20 +++++ .../ai/kilocode/client/session/SessionUi.kt | 12 ++- .../session/ui/SessionMessageListPanel.kt | 5 +- .../client/session/views/MessageView.kt | 11 +-- .../client/session/views/PlanExitView.kt | 5 +- .../kilocode/client/session/views/TurnView.kt | 5 +- .../client/session/views/ViewFactory.kt | 4 +- .../client/app/KiloWorkspaceServiceTest.kt | 70 ++++++++++++++++ .../session/SessionSidePanelManagerTest.kt | 2 +- .../client/session/SessionUiFactoryTest.kt | 9 +- .../client/session/SessionUiLayoutTest.kt | 7 +- .../client/session/SessionUiTestBase.kt | 8 +- .../session/ui/SessionMessageListPanelTest.kt | 29 ++++++- .../client/session/ui/SessionUiUpdateTest.kt | 2 +- .../client/session/views/PlanExitViewTest.kt | 18 +++- .../session/views/QuestionResultViewTest.kt | 6 +- .../client/session/views/TurnViewTest.kt | 41 ++++----- .../client/testing/FakeWorkspaceRpcApi.kt | 17 ++++ .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 7 ++ .../ai/kilocode/rpc/dto/WorkspaceFileDto.kt | 10 +++ 22 files changed, 328 insertions(+), 49 deletions(-) create mode 100644 .changeset/jolly-nebula.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt diff --git a/.changeset/jolly-nebula.md b/.changeset/jolly-nebula.md new file mode 100644 index 0000000000..2dda00499c --- /dev/null +++ b/.changeset/jolly-nebula.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open completed plan file links from JetBrains session transcripts. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index eb1dcd02a7..82fe660596 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.backend.workspace.ModelInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto @@ -28,14 +29,28 @@ import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project import com.intellij.openapi.components.service import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.LocalFileSystem import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.InvalidPathException +import java.nio.file.Path +import kotlin.coroutines.resume /** * Backend implementation of [KiloWorkspaceRpcApi]. @@ -45,6 +60,9 @@ import kotlinx.coroutines.flow.map * directory (including worktrees) can get a workspace. */ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { + companion object { + private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) + } private val app: KiloBackendAppService get() = service() @@ -83,6 +101,72 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { manager.get(directory).reload() } + override suspend fun files(directory: String, path: String): List { + val item = clean(path) ?: return emptyList() + val file = file(item) ?: return emptyList() + val bases = listOf(directory) + ProjectManager.getInstance().openProjects + .asSequence() + .filter { !it.isDefault } + .mapNotNull { it.basePath } + .filter { it != directory } + .toList() + val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base -> + file(base)?.resolve(file)?.normalize() + } + val found = linkedMapOf() + for (target in paths) { + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue + found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory) + } + return found.values.toList() + } + + override suspend fun openFile(path: String): Boolean { + val item = clean(path) ?: return false + val target = file(item)?.takeIf { it.isAbsolute } ?: return false + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false + val project = project(target) ?: run { + LOG.warn("No project available to open file: $path") + return false + } + navigate(project, vf) + return true + } + + private fun clean(path: String): String? { + val raw = path.trim().takeIf { it.isNotBlank() } ?: return null + return try { + val cut = raw.substringBefore('#').substringBefore('?') + val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8) + Path.of(decoded.replace('\\', '/')).normalize().toString() + } catch (e: Exception) { + LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" } + null + } + } + + private fun file(path: String): Path? = try { + Path.of(path).normalize() + } catch (e: InvalidPathException) { + LOG.debug { "Invalid workspace file path: $path (${e.message})" } + null + } + + private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont -> + ApplicationManager.getApplication().invokeLater({ + OpenFileDescriptor(project, file).navigate(true) + if (cont.isActive) cont.resume(Unit) + }, ModalityState.any()) + } + + private fun project(path: Path): Project? { + val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault } + return projects.firstOrNull { item -> + val base = item.basePath?.let(::file) ?: return@firstOrNull false + path.startsWith(base) + } ?: projects.firstOrNull() + } + // ------ mapping: domain model → DTO ------ private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index b63964f745..ab818b48ea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -5,6 +5,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog import fleet.rpc.client.durable @@ -98,4 +99,23 @@ class KiloWorkspaceService internal constructor( } } } + + suspend fun files(directory: String, path: String): List { + return try { + call { files(directory, path) } + } catch (e: Exception) { + LOG.warn("workspace file lookup failed for directory=$directory path=$path", e) + emptyList() + } + } + + suspend fun openPath(directory: String, path: String): Boolean { + val match = files(directory, path).firstOrNull() ?: return false + return try { + call { openFile(match.path) } + } catch (e: Exception) { + LOG.warn("workspace file open failed for path=${match.path}", e) + false + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 84804330f8..28f7762a8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session 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.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -35,6 +36,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil @@ -64,6 +66,7 @@ class SessionUi( ref: SessionRef? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, private val manager: SessionManager? = null, + private val workspaces: KiloWorkspaceService = service(), ) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { companion object { @@ -71,6 +74,7 @@ class SessionUi( } private val project = project + private val workspace = workspace private val app = app private var opening = ref != null private var pending = false @@ -185,7 +189,7 @@ class SessionUi( reply = { id, dto -> controller.replyPermission(id, dto) }, ) login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login) + messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) @@ -354,6 +358,12 @@ class SessionUi( prompt.clear() } + private fun openFile(path: String) { + cs.launch { + workspaces.openPath(workspace.directory, path) + } + } + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) refresh() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 4c5e49f4ae..cad61f48fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -46,6 +46,7 @@ class SessionMessageListPanel( private val question: QuestionView? = null, private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, + private val openFile: (String) -> Unit, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -173,7 +174,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -224,7 +225,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index e2db58c0c7..287f23833f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -27,12 +27,13 @@ import com.intellij.util.ui.JBUI */ class MessageView( val msg: Message, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), SessionEditorStyleTarget, SessionView { - constructor(msg: Message) : this(msg, SessionEditorStyle.current()) + constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) val role: String get() = msg.info.role @@ -54,7 +55,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) @@ -94,7 +95,7 @@ class MessageView( refresh() return } - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) @@ -106,7 +107,7 @@ class MessageView( val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) remove(existing) - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view, at) @@ -144,7 +145,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 6c5c31f1cf..aefb8d5fcb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -9,7 +9,7 @@ import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import java.awt.BorderLayout -class PlanExitView(tool: Tool) : PartView() { +class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { companion object { fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED } @@ -22,6 +22,7 @@ class PlanExitView(tool: Tool) : PartView() { init { layout = BorderLayout() isOpaque = false + md.addLinkListener { openFile(it.href) } add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) sync() @@ -43,6 +44,8 @@ class PlanExitView(tool: Tool) : PartView() { fun markdown(): String = md.markdown() + internal fun simulateLink(href: String) = md.simulateLink(href) + private fun sync() { val plan = plan(item) val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 57538a8f19..2bea4ff421 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -18,10 +18,11 @@ import com.intellij.util.ui.JBUI */ class TurnView( val id: String, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { - constructor(id: String) : this(id, SessionEditorStyle.current()) + constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) private val messages = LinkedHashMap() @@ -31,7 +32,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, style) + val view = MessageView(msg, openFile, style) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 9036d8928d..8d861e02c0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -20,11 +20,11 @@ import ai.kilocode.client.session.model.Tool * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. */ object ViewFactory { - fun create(content: Content): PartView = when (content) { + fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) { is Text -> TextView(content) is Reasoning -> ReasoningView(content) is Tool -> when { - PlanExitView.canRender(content) -> PlanExitView(content) + PlanExitView.canRender(content) -> PlanExitView(content, openFile) QuestionResultView.canRender(content) -> QuestionResultView(content) else -> ToolView(content) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt new file mode 100644 index 0000000000..b996f0c815 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Suppress("UnstableApiUsage") +class KiloWorkspaceServiceTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var service: KiloWorkspaceService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeWorkspaceRpcApi() + service = KiloWorkspaceService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test openPath opens first file match`() = runBlocking { + rpc.fileMatches = listOf( + WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"), + WorkspaceFileDto("/other/.kilo/plans/a.md", "a.md"), + ) + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertTrue(ok) + assertEquals(listOf("/test" to ".kilo/plans/a.md"), rpc.fileCalls) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } + + fun `test openPath returns false when no match exists`() = runBlocking { + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/missing.md") + } + + assertFalse(ok) + assertEquals(listOf("/test" to ".kilo/plans/missing.md"), rpc.fileCalls) + assertTrue(rpc.opened.isEmpty()) + } + + fun `test openPath returns false when backend open fails`() = runBlocking { + rpc.fileMatches = listOf(WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md")) + rpc.openResult = false + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertFalse(ok) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 74272ac98c..4d7abbc232 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -382,7 +382,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } created.add(workspace.directory to id) refs.add(ref) - SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also { + SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner, workspaces = workspaces).also { ui.add(it) Disposer.register(it) { ui.remove(it) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 75b3c17d39..9fe2e4ef30 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.cancel class SessionUiFactoryTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var workspace: Workspace + private lateinit var workspaces: KiloWorkspaceService private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService @@ -33,7 +34,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) - val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace("/test") @@ -56,7 +57,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test factory wires open callback`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { @@ -69,7 +70,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel opens through SessionRef via controller`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) @@ -81,7 +82,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel show history routes through manager`() { val manager = FakeManager() - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) val panel = ai.kilocode.client.session.ui.EmptySessionPanel( testRootDisposable, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 4b101d140c..951f15fd5b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -280,7 +280,12 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test existing session history shows header above scroll pane`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply { + ui = SessionUi( + project, workspace, sessions, app, scope, + ref = SessionRef.Local("ses_test"), + displayMs = 0, + workspaces = workspaces, + ).apply { setSize(800, 600) } settle() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 289094a1fe..63b4fa7000 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -88,7 +88,13 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun openSession(ref: SessionRef) = fn(ref) } } - return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply { + return SessionUi( + project, workspace, sessions, app, scope, + ref = SessionRef.from(id), + displayMs = displayMs, + manager = manager, + workspaces = workspaces, + ).apply { setSize(800, 600) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index fb6333cde4..f58c869fb1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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.LoginRequiredView +import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -37,12 +38,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable private lateinit var panel: SessionMessageListPanel + private val openFile: (String) -> Unit = {} override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = openFile) } override fun tearDown() { @@ -449,6 +451,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf("tp1"), mv.partIds()) } + fun `test completed plan update replaces tool view and keeps open file action`() { + val opened = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running")) + + val mv = item.findMessage("a1")!! + assertTrue(mv.part("tp1") is ToolView) + + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "plan_exit", "call1", state = "completed", + metadata = mapOf("plan" to ".kilo/plans/x.md"), + ), + ) + + val view = mv.part("tp1") as PlanExitView + view.simulateLink(".kilo/plans/x.md") + + assertEquals(listOf(".kilo/plans/x.md"), opened) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { @@ -461,7 +486,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { reply = { _, _ -> }, ) val l = LoginRequiredView(openProfile = {}, dismiss = {}) - return SessionMessageListPanel(model, parent, q, p, l) + return SessionMessageListPanel(model, parent, q, p, l, openFile) } private inline fun find(root: Container): T? = findCls(root, T::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 161fa55311..e030a76188 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -28,7 +28,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = {}) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index c6b104c442..69e69bdf97 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -12,14 +12,14 @@ class PlanExitViewTest : BasePlatformTestCase() { metadata = mapOf("plan" to ".kilo/plans/x.md") } - val view = PlanExitView(tool) + val view = PlanExitView(tool) {} assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown()) } fun `test view factory replaces running tool with plan exit view when completed`() { val running = tool(ToolExecState.RUNNING) - val existing = ViewFactory.create(running) + val existing = ViewFactory.create(running) {} assertTrue(existing is ToolView) val done = tool(ToolExecState.COMPLETED).apply { @@ -27,7 +27,19 @@ class PlanExitViewTest : BasePlatformTestCase() { } assertTrue(ViewFactory.shouldReplace(existing, done)) - assertTrue(ViewFactory.create(done) is PlanExitView) + assertTrue(ViewFactory.create(done) {} is PlanExitView) + } + + fun `test clicking plan link opens href`() { + val opened = mutableListOf() + val tool = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/my%20plan.md") + } + + val view = PlanExitView(tool) { opened.add(it) } + view.simulateLink(".kilo/plans/my%20plan.md") + + assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) } private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 8d03559f92..97182fe0fc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -129,7 +129,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), ) - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool) {} assertTrue(view is QuestionResultView) } @@ -139,14 +139,14 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = emptyMap(), metadata = emptyMap(), ) - val view = ViewFactory.create(tool) + 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) + val view = ViewFactory.create(tool) {} assertTrue(view is ToolView) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 8427cce067..729f876e1f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -17,23 +17,24 @@ import com.intellij.util.ui.JBUI */ @Suppress("UnstableApiUsage") class TurnViewTest : BasePlatformTestCase() { + private val openFile: (String) -> Unit = {} // ------ TurnView ------ fun `test new TurnView is empty`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertTrue(tv.messageIds().isEmpty()) } fun `test addMessage appends and returns view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) val mv = tv.addMessage(msg("u1", "user")) assertEquals("u1", mv.msg.info.id) assertEquals(listOf("u1"), tv.messageIds()) } fun `test addMessage preserves insertion order`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) tv.addMessage(msg("a2", "assistant")) @@ -41,7 +42,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns the view for a given id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) val mv = tv.messageView("u1") assertNotNull(mv) @@ -49,12 +50,12 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns null for unknown id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertNull(tv.messageView("missing")) } fun `test removeMessage removes the view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) @@ -65,14 +66,14 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removeMessage unknown id is noop`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.removeMessage("nope") assertEquals(listOf("u1"), tv.messageIds()) } fun `test dump produces correct format`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) assertEquals("user#u1, assistant#a1", tv.dump()) @@ -81,22 +82,22 @@ class TurnViewTest : BasePlatformTestCase() { // ------ MessageView ------ fun `test new MessageView is empty`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertTrue(mv.partIds().isEmpty()) } fun `test MessageView for user message has user role`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertEquals("user", mv.role) } fun `test MessageView for assistant message has assistant role`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) assertEquals("assistant", mv.role) } fun `test upsertPart adds a new TextView for Text content`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") text.content.append("hello") mv.upsertPart(text) @@ -106,7 +107,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test upsertPart updates existing part rather than adding duplicate`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") } mv.upsertPart(t1) @@ -119,7 +120,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart removes the renderer`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") }) mv.removePart("p1") @@ -128,13 +129,13 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart unknown id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.removePart("none") assertTrue(mv.partIds().isEmpty()) } fun `test appendDelta reaches TextView`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") }) mv.appendDelta("p1", "world") @@ -144,7 +145,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test appendDelta for unknown part id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw mv.appendDelta("unknown", "delta") } @@ -154,7 +155,7 @@ class TurnViewTest : BasePlatformTestCase() { val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } message.parts["p1"] = text - val mv = MessageView(message) + val mv = MessageView(message, openFile) assertEquals(listOf("p1"), mv.partIds()) assertTrue(mv.part("p1") is TextView) @@ -166,7 +167,7 @@ class TurnViewTest : BasePlatformTestCase() { val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool - val mv = MessageView(message) + val mv = MessageView(message, openFile) mv.setSize(400, 200) mv.doLayout() @@ -178,7 +179,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test consecutive messages use shared compact gap`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user").also { msg -> msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index c218008a5b..5b9c6a8c20 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) var reloads = 0 private set + var fileMatches = emptyList() + var openResult = true + val fileCalls = mutableListOf>() + val opened = mutableListOf() override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -35,4 +40,16 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { assertNotEdt("reload") reloads += 1 } + + override suspend fun files(directory: String, path: String): List { + assertNotEdt("files") + fileCalls.add(directory to path) + return fileMatches + } + + override suspend fun openFile(path: String): Boolean { + assertNotEdt("openFile") + opened.add(path) + return openResult + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f0f8429506..f5c476e5ae 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -36,4 +37,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Trigger a full reload of workspace data. */ suspend fun reload(directory: String) + + /** Resolve [path] to matching files, scoped primarily to [directory]. */ + suspend fun files(directory: String, path: String): List + + /** Open an absolute backend file path in the IDE. */ + suspend fun openFile(path: String): Boolean } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt new file mode 100644 index 0000000000..1f3a1ac5b7 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class WorkspaceFileDto( + val path: String, + val name: String, + val directory: Boolean = false, +) From 351291d259fc3cce22becae143c4454d8baf7c8a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 25 May 2026 17:40:28 -0400 Subject: [PATCH 7/8] feat(jetbrains): render todo checklists --- .changeset/jetbrains-todo-checklist.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 53 ++++- .../backend/cli/KiloCliDataParserTest.kt | 91 +++++++- .../kilocode/client/session/model/Message.kt | 3 + .../client/session/model/SessionModel.kt | 4 + .../session/ui/header/SessionHeaderPanel.kt | 77 ++++++- .../client/session/views/MessageView.kt | 4 +- .../client/session/views/ViewFactory.kt | 4 + .../session/views/todo/TodoListPanel.kt | 140 ++++++++++++ .../session/views/todo/TodoWriteView.kt | 209 ++++++++++++++++++ .../resources/messages/KiloBundle.properties | 6 + .../client/session/model/SessionModelTest.kt | 22 +- .../session/ui/SessionMessageListPanelTest.kt | 26 ++- .../ui/header/SessionHeaderPanelTest.kt | 46 ++++ .../session/views/todo/TodoWriteViewTest.kt | 95 ++++++++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 12 + 16 files changed, 781 insertions(+), 16 deletions(-) create mode 100644 .changeset/jetbrains-todo-checklist.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt diff --git a/.changeset/jetbrains-todo-checklist.md b/.changeset/jetbrains-todo-checklist.md new file mode 100644 index 0000000000..14308b7f42 --- /dev/null +++ b/.changeset/jetbrains-todo-checklist.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show todo updates as checklist cards in JetBrains session transcripts. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 06044c60aa..c2ac1c1a6e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -32,6 +32,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.ToolRefDto import kotlinx.serialization.json.Json @@ -223,14 +224,7 @@ object KiloCliDataParser { "todo.updated" -> { val sid = props.str("sessionID") ?: return null - val todos = props["todos"]?.jsonArray?.map { elem -> - val t = elem.jsonObject - TodoDto( - content = t.str("content") ?: "", - status = t.str("status") ?: "pending", - priority = t.str("priority") ?: "medium", - ) - } ?: emptyList() + val todos = parseTodos(props["todos"]) ChatEventDto.TodoUpdated(sid, todos) } @@ -467,6 +461,15 @@ object KiloCliDataParser { val tokens = obj["tokens"]?.jsonObject val top = obj.map("metadata") val meta = state.map("metadata") + top + val input = state?.get("input").obj() + val stateMeta = state?.get("metadata").obj() + val topMeta = obj["metadata"].obj() + val todos = sequenceOf(topMeta?.get("todos"), stateMeta?.get("todos"), input?.get("todos")) + .firstNotNullOfOrNull(::parseTodosOrNull) + ?: emptyList() + val view = sequenceOf(topMeta?.get("view"), stateMeta?.get("view")) + .mapNotNull(::parseTodoView) + .firstOrNull() return PartDto( id = obj.str("id") ?: "", sessionID = obj.str("sessionID") ?: "", @@ -482,12 +485,46 @@ object KiloCliDataParser { output = state?.str("output"), error = state?.str("error"), time = obj.time("time") ?: state.time("time"), + todos = todos, + todoView = view, reason = obj.str("reason"), cost = obj.num("cost"), tokens = tokens?.let(::parseTokens), ) } + internal fun parseTodos(raw: JsonElement?): List { + return parseTodosOrNull(raw) ?: emptyList() + } + + private fun parseTodosOrNull(raw: JsonElement?): List? { + val arr = runCatching { raw?.jsonArray }.getOrNull() ?: return null + return arr.mapNotNull { elem -> + val obj = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + parseTodo(obj) + } + } + + private fun parseTodo(obj: JsonObject) = TodoDto( + content = obj.str("content") ?: "", + status = obj.str("status") ?: "pending", + priority = obj.str("priority") ?: "medium", + changed = obj.flag("changed", false), + ) + + internal fun parseTodoView(raw: JsonElement?): TodoViewDto? { + val obj = runCatching { raw?.jsonObject }.getOrNull() ?: return null + val rawTodos = runCatching { obj["todos"]?.jsonArray }.getOrNull() ?: return null + val todos = parseTodos(rawTodos) + return TodoViewDto( + mode = obj.str("mode") ?: "full", + todos = todos, + hiddenBefore = obj.long("hiddenBefore")?.safeInt() ?: 0, + hiddenAfter = obj.long("hiddenAfter")?.safeInt() ?: 0, + changed = obj.long("changed")?.safeInt() ?: 0, + ) + } + private fun parseTokens(obj: JsonObject): TokensDto { val cache = obj["cache"]?.jsonObject return TokensDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 07ecabd071..b73e923eef 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -199,6 +199,93 @@ class KiloCliDataParserTest { assertEquals(12.0, result.part.time?.end) } + @Test + fun `parseChatEvent - todowrite part parses typed todo metadata`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_todo", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "todowrite", + "callID": "call_todo", + "metadata": { + "todos": [ + {"content": "Top wins", "status": "completed", "priority": "high", "changed": true} + ], + "view": { + "mode": "compact", + "hiddenBefore": 1, + "hiddenAfter": 2, + "changed": 1, + "todos": [ + {"content": "Visible", "status": "pending", "priority": "medium", "changed": true} + ] + } + }, + "state": { + "status": "completed", + "input": { + "todos": [ + {"content": "Input fallback", "status": "pending", "priority": "low"} + ] + }, + "metadata": { + "todos": [ + {"content": "State fallback", "status": "in_progress", "priority": "medium"} + ] + } + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("Top wins", result.part.todos.single().content) + assertEquals(true, result.part.todos.single().changed) + assertEquals("compact", result.part.todoView?.mode) + assertEquals(1, result.part.todoView?.hiddenBefore) + assertEquals(2, result.part.todoView?.hiddenAfter) + assertEquals(1, result.part.todoView?.changed) + assertEquals("Visible", result.part.todoView?.todos?.single()?.content) + assertEquals(true, result.part.todoView?.todos?.single()?.changed) + assertEquals("[{\"content\":\"Input fallback\",\"status\":\"pending\",\"priority\":\"low\"}]", result.part.input["todos"]) + assertTrue(result.part.metadata["view"]?.contains("compact") == true) + } + + @Test + fun `parseChatEvent - empty top metadata todos overrides fallback todos`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_todo", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "todowrite", + "metadata": { "todos": [] }, + "state": { + "status": "completed", + "metadata": { + "todos": [ + {"content": "Fallback", "status": "pending", "priority": "medium"} + ] + } + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + + assertEquals(emptyList(), result.part.todos) + } + @Test fun `parseChatEvent - bash tool part preserves command output and error`() { val data = globalEvent(""" @@ -479,7 +566,7 @@ class KiloCliDataParserTest { "properties": { "sessionID": "ses_1", "todos": [ - {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Write tests", "status": "in_progress", "priority": "high", "changed": true}, {"content": "Review PR", "status": "pending", "priority": "medium"} ] } @@ -492,6 +579,8 @@ class KiloCliDataParserTest { assertEquals(2, result.todos.size) assertEquals("Write tests", result.todos[0].content) assertEquals("high", result.todos[0].priority) + assertEquals(true, result.todos[0].changed) + assertEquals(false, result.todos[1].changed) } // ---- session status events ---- diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index 7c9a610ed2..a8169f7749 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto data class SessionHeaderSnapshot( @@ -75,6 +76,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var output: String? = null var error: String? = null var time: PartTimeDto? = null + var todos: List = emptyList() + var todoView: TodoViewDto? = null } /** Context compaction marker. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 47bf784e45..79bf8eba3d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -359,6 +359,8 @@ class SessionModel { existing.output = dto.output existing.error = dto.error existing.time = dto.time + existing.todos = dto.todos + existing.todoView = dto.todoView } is Compaction -> return is StepFinish -> { @@ -391,6 +393,8 @@ class SessionModel { output = dto.output error = dto.error time = dto.time + todos = dto.todos + todoView = dto.todoView } "compaction" -> Compaction(dto.id) "step-finish" -> StepFinish(dto.id).apply { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index cc7f31d107..c92b1a255b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -6,9 +6,13 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.todo.TodoListPanel import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.Disposable import com.intellij.openapi.util.IconLoader @@ -52,6 +56,8 @@ class SessionHeaderPanel( private val cost = JBLabel() private val context = JBLabel() private val todos = JBLabel() + private val todoArrow = JBLabel(AllIcons.General.ArrowRight) + private val todoList = TodoListPanel() private val compact = HoverIcon().apply { icon = COMPRESS_ICON toolTipText = KiloBundle.message("session.header.compact.description") @@ -111,11 +117,20 @@ class SessionHeaderPanel( add(Box.createHorizontalStrut(UiStyle.Gap.sm())) add(cacheWrite) } - private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply { + private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { isOpaque = false border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.header.todos.toggle") + accessibleContext.accessibleName = KiloBundle.message("session.header.todos.toggle") + add(todoArrow) add(todos) } + private val todoBox = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(todoRow) + } private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -123,7 +138,7 @@ class SessionHeaderPanel( add(viewport) add(tokens) add(bar) - add(todoRow) + add(todoBox) } private var style = SessionEditorStyle.current() @@ -151,6 +166,15 @@ class SessionHeaderPanel( }) timeline.addMouseWheelListener { scroll(it) } viewport.addMouseWheelListener { scroll(it) } + val todoClick = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + toggleTodos() + } + } + listOf(todoRow, todoArrow, todos).forEach { + it.cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + it.addMouseListener(todoClick) + } controller.model.addListener(parent) { event -> when (event) { @@ -207,8 +231,7 @@ class SessionHeaderPanel( set(context, contextText(header.context)) context.toolTipText = contextTip(header.context) setTokens(header.tokens) - set(todos, todo(header.todos.completed, header.todos.total)) - todoRow.isVisible = todos.isVisible + syncTodos(header.todos.items) compact.isEnabled = header.canCompact val appended = timeline.setItems(header.timeline) @@ -227,6 +250,7 @@ class SessionHeaderPanel( right.background = style.editorBackground tokens.background = style.editorBackground todoRow.background = style.editorBackground + todoBox.background = style.editorBackground body.background = style.editorBackground viewport.background = style.editorBackground title.font = style.boldFont @@ -237,6 +261,8 @@ class SessionHeaderPanel( context.foreground = style.editorForeground todos.font = style.smallFont todos.foreground = style.editorForeground + todoArrow.foreground = style.editorForeground + todoList.applyStyle(style) tokenTitle.font = style.smallFont tokenTitle.foreground = style.editorForeground input.font = style.smallFont @@ -278,6 +304,14 @@ class SessionHeaderPanel( internal fun todoVisible() = todoRow.isVisible && todos.isVisible + internal fun todoListVisible() = todoList.parent === todoBox + + internal fun todoRowPanel() = todoRow + + internal fun todoLabel() = todos + + internal fun todoListPanel() = todoList + internal fun compactButton() = compact internal fun expandButton() = expand @@ -358,6 +392,41 @@ class SessionHeaderPanel( tokens.isVisible = total > 0 } + private fun syncTodos(items: List) { + val total = items.size + val done = items.count { it.status == "completed" } + set(todos, todo(done, total)) + todos.foreground = if (total > 0 && done == total) SessionUiStyle.Timeline.SUCCESS else style.editorForeground + todoArrow.isVisible = total > 0 + todoBox.isVisible = total > 0 + todoRow.isVisible = total > 0 + todoList.update(items) + if (total == 0) collapseTodos() + } + + private fun toggleTodos() { + if (!todoBox.isVisible) return + if (todoListVisible()) collapseTodos() else expandTodos() + refresh() + } + + private fun expandTodos(): Boolean { + if (todoListVisible()) return false + todoBox.add(todoList) + todoArrow.icon = AllIcons.General.ArrowDown + return true + } + + private fun collapseTodos(): Boolean { + if (!todoListVisible()) { + todoArrow.icon = AllIcons.General.ArrowRight + return false + } + todoBox.remove(todoList) + todoArrow.icon = AllIcons.General.ArrowRight + return true + } + private fun toggle() { val next = !isExpanded() syncExpanded(next) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 287f23833f..886437a11e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -128,8 +128,10 @@ class MessageView( * pending/running question tool part linked to the active question. */ private fun isHidden(content: Content): Boolean { - val ref = hidden ?: return false if (content !is Tool) return false + if (content.name == "todoread") return true + if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true + val ref = hidden ?: return false if (content.name != "question") return false if (content.state != ToolExecState.PENDING && content.state != ToolExecState.RUNNING) return false return msg.info.id == ref.messageId && content.callId == ref.callId diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 8d861e02c0..5ee3c959cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.views.todo.TodoWriteView /** * Creates the appropriate [PartView] for a given [Content] subtype. @@ -24,6 +25,7 @@ object ViewFactory { is Text -> TextView(content) is Reasoning -> ReasoningView(content) is Tool -> when { + TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile) QuestionResultView.canRender(content) -> QuestionResultView(content) else -> ToolView(content) @@ -40,6 +42,8 @@ object ViewFactory { */ fun shouldReplace(view: PartView, content: Content): Boolean { if (content !is Tool) return false + if (view is TodoWriteView) return !TodoWriteView.canRender(content) + if (view !is TodoWriteView && TodoWriteView.canRender(content)) return true if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt new file mode 100644 index 0000000000..b38b613cab --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -0,0 +1,140 @@ +package ai.kilocode.client.session.views.todo + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.TodoDto +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import javax.swing.BoxLayout +import javax.swing.JPanel + +class TodoListPanel( + todos: List = emptyList(), + private var before: Int = 0, + private var after: Int = 0, +) : JPanel() { + + private var items = todos + private var style = SessionEditorStyle.current() + private val rows = mutableListOf() + private val prior = JBLabel() + private val later = JBLabel() + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()) + add(prior) + add(later) + applyStyle(style) + sync() + } + + fun update(todos: List, hiddenBefore: Int = 0, hiddenAfter: Int = 0) { + val size = todos.size != items.size + items = todos + before = hiddenBefore + after = hiddenAfter + if (size) sync() + rows.forEachIndexed { index, row -> row.update(items[index], style) } + syncHidden() + revalidate() + repaint() + } + + fun applyStyle(style: SessionEditorStyle) { + this.style = style + prior.font = style.smallFont + later.font = style.smallFont + prior.foreground = UiStyle.Colors.weak() + later.foreground = UiStyle.Colors.weak() + rows.forEachIndexed { index, row -> row.update(items[index], style) } + syncHidden() + } + + internal fun rowCount() = rows.size + + internal fun rowText(index: Int) = rows[index].text.text + + internal fun rowChecked(index: Int) = rows[index].check.isSelected + + internal fun rowCheckboxOpaque(index: Int) = rows[index].check.isOpaque + + internal fun rowFont(index: Int) = rows[index].text.font + + internal fun rowForeground(index: Int) = rows[index].text.foreground + + internal fun hiddenText() = listOf(prior, later).filter { it.isVisible }.joinToString(" ") { it.text } + + private fun sync() { + removeAll() + rows.clear() + add(prior) + items.forEach { todo -> + val row = Row(todo, style) + rows.add(row) + add(row.panel) + } + add(later) + syncHidden() + } + + private fun syncHidden() { + prior.text = hidden(before, true) + prior.isVisible = before > 0 + later.text = hidden(after, false) + later.isVisible = after > 0 + } + + private fun hidden(count: Int, earlier: Boolean): String { + if (count <= 0) return "" + val key = when { + earlier && count == 1 -> "session.part.todo.hidden.earlier.one" + earlier -> "session.part.todo.hidden.earlier.many" + count == 1 -> "session.part.todo.hidden.later.one" + else -> "session.part.todo.hidden.later.many" + } + return KiloBundle.message(key, count) + } + + private class Row(todo: TodoDto, style: SessionEditorStyle) { + val check = JBCheckBox().apply { + isFocusable = false + isEnabled = false + isOpaque = false + } + val text = JBLabel() + val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0) + add(check, BorderLayout.WEST) + add(text, BorderLayout.CENTER) + } + + init { + update(todo, style) + } + + fun update(todo: TodoDto, style: SessionEditorStyle) { + val done = todo.status == "completed" + check.isSelected = done + text.text = label(todo.content, done) + text.font = if (todo.changed) style.boldFont else style.regularFont + text.foreground = when { + !done -> style.editorForeground + todo.changed -> style.editorForeground + else -> UiStyle.Colors.weak() + } + } + + private fun label(value: String, done: Boolean): String { + val text = XmlStringUtil.escapeString(value) + if (!done) return "$text" + return "$text" + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt new file mode 100644 index 0000000000..e1a57c2624 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -0,0 +1,209 @@ +package ai.kilocode.client.session.views.todo + +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.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Box +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +class TodoWriteView(tool: Tool) : PartView() { + + override val contentId = tool.id + + private var item = tool + 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.Actions.Checked) + private val title = JBLabel(KiloBundle.message("session.part.todo.title")) + private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + private val arrow = JBLabel(AllIcons.General.ArrowDown) + private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + } + private val controls: JComponent = Box.createHorizontalBox().apply { add(arrow) } + private val list = TodoListPanel() + + 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(controls, BorderLayout.EAST) + root.add(header, BorderLayout.NORTH) + root.add(list, BorderLayout.CENTER) + list.border = JBUI.Borders.compound( + SessionUiStyle.View.cardTop(), + JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), + ) + listOf(header, glyph, title, sub, arrow, center, controls).forEach { + bind(it) + it.addMouseListener(click) + } + applyStyle(style) + add(root, BorderLayout.CENTER) + sync() + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + sync() + } + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(title, style.boldEditorFont) || changed + changed = setFont(sub, style.transcriptFont) || changed + list.applyStyle(style) + if (changed) refresh() + } + + fun toggle() { + val changed = if (isExpanded()) detach() else attach() + if (!changed) return + syncArrow() + refresh() + } + + fun isExpanded() = list.parent === root + + fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") + + internal fun rowCount() = list.rowCount() + + internal fun rowText(index: Int) = list.rowText(index) + + internal fun rowChecked(index: Int) = list.rowChecked(index) + + internal fun rowCheckboxOpaque(index: Int) = list.rowCheckboxOpaque(index) + + internal fun rowForeground(index: Int) = list.rowForeground(index) + + internal fun hiddenText() = list.hiddenText() + + internal fun titleFont() = title.font + + internal fun subtitleFont() = sub.font + + override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})" + + private fun sync() { + sub.text = subtitle(item) + val view = item.todoView + val compact = view?.mode == "compact" + val rows = if (compact) view.todos else item.todos + list.update( + rows, + hiddenBefore = if (compact) view.hiddenBefore else 0, + hiddenAfter = if (compact) view.hiddenAfter else 0, + ) + syncArrow() + refresh() + } + + private fun attach(): Boolean { + if (isExpanded()) return false + root.add(list, BorderLayout.CENTER) + return true + } + + private fun detach(): Boolean { + if (!isExpanded()) return false + root.remove(list) + return true + } + + 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 (same(header.background, color)) 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 bind(component: Component) { + component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + component.addMouseListener(mouse) + } + + private fun refresh() { + revalidate() + repaint() + } + + companion object { + fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED + } +} + +private fun subtitle(tool: Tool): String { + val total = tool.todos.size + if (total == 0) return "" + val done = tool.todos.count { it.status == "completed" } + return "$done/$total" +} + +private fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} + +private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 588e26506c..15420a12cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -82,6 +82,11 @@ session.part.tool.running=Running session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden session.error.prompt=Prompt failed session.error.compact=Session compact failed @@ -104,6 +109,7 @@ session.header.expand=Show session metrics session.header.collapse=Hide session metrics session.header.todos.progress={0}/{1} todos complete session.header.todos.done=All {0} todos complete +session.header.todos.toggle=Toggle to-dos session.header.context.tooltip.percent={0} tokens ({1}% of context) session.header.context.tooltip.tokens={0} tokens session.header.context.used={0} / {1} tokens used diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index fd8eea0853..fcdf758661 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -171,6 +172,8 @@ class SessionModelTest : UsefulTestCase() { fun `test updateContent tool stores rich fields`() { model.addMessage(msg("m1", "assistant")) + val todos = listOf(TodoDto("Write tests", "completed", "high", changed = true)) + val view = TodoViewDto("compact", todos, hiddenBefore = 1, hiddenAfter = 2, changed = 1) model.updateContent( "m1", @@ -184,6 +187,8 @@ class SessionModelTest : UsefulTestCase() { output = "abc123 init", error = "failed", time = PartTimeDto(1.0, 2.0), + todos = todos, + todoView = view, ), ) @@ -195,6 +200,8 @@ class SessionModelTest : UsefulTestCase() { assertEquals("failed", p.error) assertEquals(1.0, p.time?.start) assertEquals(2.0, p.time?.end) + assertEquals(todos, p.todos) + assertEquals(view, p.todoView) } fun `test updateContent tool updates lifecycle`() { @@ -214,15 +221,24 @@ class SessionModelTest : UsefulTestCase() { model.addMessage(msg("m1", "assistant")) model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending")) events.clear() + val todos = listOf(TodoDto("Review", "pending", "medium")) model.updateContent( "m1", - part("p1", "m1", "tool", tool = "bash", state = "completed", input = mapOf("command" to "git remote -v"), output = "origin"), + part( + "p1", "m1", "tool", + tool = "bash", + state = "completed", + input = mapOf("command" to "git remote -v"), + output = "origin", + todos = todos, + ), ) val p = model.message("m1")!!.parts["p1"] as Tool assertEquals("git remote -v", p.input["command"]) assertEquals("origin", p.output) + assertEquals(todos, p.todos) assertTrue(events.single() is SessionModelEvent.ContentUpdated) } @@ -808,6 +824,8 @@ class SessionModelTest : UsefulTestCase() { reason: String? = null, cost: Double? = null, tokens: TokensDto? = null, + todos: List = emptyList(), + todoView: TodoViewDto? = null, ) = PartDto( id = id, sessionID = "ses", @@ -822,6 +840,8 @@ class SessionModelTest : UsefulTestCase() { output = output, error = error, time = time, + todos = todos, + todoView = todoView, reason = reason, cost = cost, tokens = tokens, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index f58c869fb1..659d85d3ce 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -16,10 +16,12 @@ import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TextView import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -428,6 +430,27 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("tp1") is ToolView) } + fun `test todo tools are suppressed until todowrite completes`() { + val item = panelWithPrompts() + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("read", "a1", "todoread", "call1", state = "completed")) + model.updateContent("a1", toolPart("write", "a1", "todowrite", "call2", state = "running")) + + val mv = item.findMessage("a1")!! + assertEquals(emptyList(), mv.partIds()) + + model.updateContent( + "a1", + toolPart( + "write", "a1", "todowrite", "call2", state = "completed", + todos = listOf(TodoDto("Done", "completed", "high")), + ), + ) + + assertEquals(listOf("write"), mv.partIds()) + assertTrue(mv.part("write") is TodoWriteView) + } + fun `test completed question update replaces generic tool view with question result view`() { val item = panelWithPrompts() model.upsertMessage(msg("a1", "assistant")) @@ -542,8 +565,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { state: String = "running", input: Map = emptyMap(), metadata: Map = emptyMap(), + todos: List = emptyList(), ) = PartDto( id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, - input = input, metadata = metadata, + input = input, metadata = metadata, todos = todos, ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 361d1846e1..100d1bb12a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -90,6 +90,39 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(1, rpc.compacts.size) } + fun `test todo list starts collapsed and toggles independently`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + panel.expandButton().doClick() + assertTrue(panel.isExpanded()) + assertTrue(panel.todoVisible()) + assertFalse(panel.todoListVisible()) + + click(panel.todoRowPanel()) + + assertTrue(panel.isExpanded()) + assertTrue(panel.todoListVisible()) + assertEquals(2, panel.todoListPanel().rowCount()) + assertTrue(panel.todoListPanel().rowText(0).contains("Write tests")) + assertTrue(panel.todoListPanel().rowChecked(0)) + assertFalse(panel.todoListPanel().rowChecked(1)) + + click(panel.todoLabel()) + assertTrue(panel.isExpanded()) + assertFalse(panel.todoListVisible()) + } + + fun `test all done todos use success foreground`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + emit(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("Done", "completed", "high")))) + + assertEquals("All 1 todos complete", panel.todoText()) + assertEquals(ai.kilocode.client.session.ui.style.SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + } + fun `test retained labels update on later header event`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) @@ -571,6 +604,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { )) } + private fun click(component: java.awt.Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + false, + )) + } + private fun reset() { PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt new file mode 100644 index 0000000000..636dbb9f36 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -0,0 +1,95 @@ +package ai.kilocode.client.session.views.todo + +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 ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color + +@Suppress("UnstableApiUsage") +class TodoWriteViewTest : BasePlatformTestCase() { + + fun `test canRender only completed todowrite`() { + assertTrue(TodoWriteView.canRender(tool("todowrite", ToolExecState.COMPLETED))) + assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.PENDING))) + assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.RUNNING))) + assertFalse(TodoWriteView.canRender(tool("bash", ToolExecState.COMPLETED))) + } + + fun `test renders title subtitle and rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + ) + }) + + assertTrue(view.labelText().contains("To-dos")) + assertTrue(view.labelText().contains("1/2")) + assertTrue(view.isExpanded()) + assertEquals(2, view.rowCount()) + assertTrue(view.rowChecked(0)) + assertFalse(view.rowChecked(1)) + assertTrue(view.rowText(0).contains("Done")) + assertFalse(view.rowCheckboxOpaque(0)) + assertFalse(view.rowCheckboxOpaque(1)) + } + + fun `test pending rows keep normal foreground`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + ) + }) + val style = SessionEditorStyle.current().copy(editorForeground = Color(1, 2, 3)) + + view.applyStyle(style) + + assertEquals(style.editorForeground, view.rowForeground(1)) + } + + fun `test compact view renders hidden labels and visible rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + TodoDto("Later", "pending", "low"), + ) + it.todoView = TodoViewDto( + mode = "compact", + todos = listOf(TodoDto("Changed", "pending", "high", changed = true)), + hiddenBefore = 1, + hiddenAfter = 1, + changed = 1, + ) + }) + + assertTrue(view.labelText().contains("1/3")) + assertEquals(1, view.rowCount()) + assertTrue(view.rowText(0).contains("Changed")) + assertTrue(view.hiddenText().contains("earlier to-do hidden")) + assertTrue(view.hiddenText().contains("later to-do hidden")) + } + + fun `test update reuses root and updates rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Old", "pending", "medium")) + }) + val root = view.components.single() + + view.update(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("New", "completed", "high")) + }) + + assertSame(root, view.components.single()) + assertTrue(view.labelText().contains("1/1")) + assertTrue(view.rowChecked(0)) + assertTrue(view.rowText(0).contains("New")) + } + + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 09993d685c..e5c85cb0ab 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -67,6 +67,8 @@ data class PartDto( val output: String? = null, val error: String? = null, val time: PartTimeDto? = null, + val todos: List = emptyList(), + val todoView: TodoViewDto? = null, val reason: String? = null, val cost: Double? = null, val tokens: TokensDto? = null, @@ -333,6 +335,16 @@ data class TodoDto( val content: String, val status: String, val priority: String, + val changed: Boolean = false, +) + +@Serializable +data class TodoViewDto( + val mode: String = "full", + val todos: List = emptyList(), + val hiddenBefore: Int = 0, + val hiddenAfter: Int = 0, + val changed: Int = 0, ) // --- Diff DTO --- From 12bb821487fb611a7d22c8177803948ab81b10d3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 26 May 2026 12:48:20 -0400 Subject: [PATCH 8/8] fix(jetbrains): preserve follow-up question state --- .../kilocode/backend/cli/KiloCliDataParser.kt | 38 +++++++++---------- .../session/controller/SessionController.kt | 8 ++-- .../session/controller/TurnLifecycleTest.kt | 16 ++++++++ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index c2ac1c1a6e..a31df2b715 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -584,27 +584,27 @@ object KiloCliDataParser { val sid = obj.str("sessionID") ?: return null val questions = obj["questions"]?.jsonArray?.map { q -> val qo = q.jsonObject - val options = qo["options"]?.jsonArray?.map { o -> - val oo = o.jsonObject - QuestionOptionDto( - label = oo.str("label") ?: "", - description = oo.str("description") ?: "", - labelKey = oo.str("labelKey"), - descriptionKey = oo.str("descriptionKey"), - mode = oo.str("mode"), - ) - } ?: emptyList() - QuestionInfoDto( - question = qo.str("question") ?: "", - header = qo.str("header") ?: "", - options = options, - multiple = qo.flag("multiple", false), - custom = qo.flag("custom", true), - questionKey = qo.str("questionKey"), - headerKey = qo.str("headerKey"), + val options = qo["options"]?.jsonArray?.map { o -> + val oo = o.jsonObject + QuestionOptionDto( + label = oo.str("label") ?: "", + description = oo.str("description") ?: "", + labelKey = oo.str("labelKey"), + descriptionKey = oo.str("descriptionKey"), + mode = oo.str("mode"), ) } ?: emptyList() - val ref = toolRef(obj) + QuestionInfoDto( + question = qo.str("question") ?: "", + header = qo.str("header") ?: "", + options = options, + multiple = qo.flag("multiple", false), + custom = qo.flag("custom", true), + questionKey = qo.str("questionKey"), + headerKey = qo.str("headerKey"), + ) + } ?: emptyList() + val ref = toolRef(obj) return QuestionRequestDto(id, sid, questions, ref, blocking = obj.flag("blocking", false)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 37cebe7640..4dc2967fd1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -764,11 +764,11 @@ class SessionController( is ChatEventDto.TurnClose -> { partType = null tool = null - // "completed" always transitions to idle. - // Other reasons: don't clobber a more specific terminal state (Error, - // AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close. + // Keep pending questions visible for follow-up flows that arrive just before close. val current = model.state - val clobberOk = current is SessionState.Busy + if (current is SessionState.AwaitingQuestion) return + val clobberOk = event.reason == "completed" + || current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline if (clobberOk) model.setState(SessionState.Idle) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index 841d2cc096..0151333baa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto class TurnLifecycleTest : SessionControllerTestBase() { @@ -72,6 +74,20 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test TurnClose completed preserves AwaitingQuestion state`() { + val (m, _, _) = prompted() + + emit( + ChatEventDto.QuestionAsked( + "ses_test", + QuestionRequestDto("q1", "ses_test", listOf(QuestionInfoDto("Pick one", "Choice"))), + ), + ) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + fun `test Error fires StateChanged to Error`() { val (m, _, _) = prompted()