From 6e633692a24be6da6bebbab3e9b1e0e73b597e2c Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 18:31:41 -0400 Subject: [PATCH] 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 00000000000..cadf662fe87 --- /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 3a73539b72e..6588de5530c 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 c4a920cc04a..06044c60aa2 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 0192304ef4a..6346fa097fb 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 c614d14bdb2..07ecabd0715 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 c06995e0ccd..84804330f8d 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 a72f6fe55a3..547f8b65975 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 70ea656fdbc..aba1b54b6b4 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 00000000000..6d89fa43461 --- /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 59c9b360dc2..9036d8928d3 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 2613300e2df..8153e10eb88 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 763099b2a0a..588e26506c6 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 aa095e0a136..e0e38ac65ad 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 51bd2bf2e4e..4b0a377c6d7 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 45959beeb29..fb6333cde47 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 00000000000..261d7d799a8 --- /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 b35c7e1538b..dcd49e0da9c 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 64e5966999e..664f5e22e57 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 e333599e2b3..09993d685cf 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 23038af5975..8045ee881bb 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 c7fdad6ed5f..49803420791 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 f6642684a73..5e6371b71b2 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({