Skip to content

Commit 401bfba

Browse files
authored
Merge pull request #9245 from Kilo-Org/fix/plan-followup-continue-button
fix(vscode): Fix plan continue-here button
2 parents 69b4073 + bc05d36 commit 401bfba

8 files changed

Lines changed: 229 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": patch
3+
---
4+
5+
Fix the "Continue here" button not submitting after a plan is finished. Picking an option on a single-question prompt now sends the reply immediately — matching the CLI behaviour — and the redundant "Type your own answer" row no longer appears on the plan follow-up question.

packages/kilo-vscode/tests/unit/question-dock-utils.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "bun:test"
22
import {
3+
pickOutcome,
34
resolveOptimisticQuestionAgent,
45
resolveQuestionMode,
56
resolveSelectedQuestionMode,
@@ -128,3 +129,25 @@ describe("resolveOptimisticQuestionAgent", () => {
128129
expect(result).toEqual({ base: "ask", agent: "architect" })
129130
})
130131
})
132+
133+
describe("pickOutcome", () => {
134+
it("submits immediately on a single-question single-select option pick", () => {
135+
expect(pickOutcome({ single: true, multi: false, custom: false })).toEqual({ kind: "submit" })
136+
})
137+
138+
it("advances to the next tab on a multi-question single-select option pick", () => {
139+
expect(pickOutcome({ single: false, multi: false, custom: false })).toEqual({ kind: "advance" })
140+
})
141+
142+
it("stays on the current tab for a multi-select pick", () => {
143+
expect(pickOutcome({ single: true, multi: true, custom: false })).toEqual({ kind: "stay" })
144+
})
145+
146+
it("defers submission for a single-select custom-input pick (handleCustomSubmit owns the submit)", () => {
147+
expect(pickOutcome({ single: true, multi: false, custom: true })).toEqual({ kind: "stay" })
148+
})
149+
150+
it("stays on the current tab for a multi-select custom-input pick", () => {
151+
expect(pickOutcome({ single: false, multi: true, custom: true })).toEqual({ kind: "stay" })
152+
})
153+
})

packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import { Icon } from "@kilocode/kilo-ui/icon"
1212
import { useSession } from "../../context/session"
1313
import { useLanguage } from "../../context/language"
1414
import type { QuestionRequest } from "../../types/messages"
15-
import { resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, toggleAnswer } from "./question-dock-utils"
15+
import {
16+
pickOutcome,
17+
resolveOptimisticQuestionAgent,
18+
resolveSelectedQuestionMode,
19+
toggleAnswer,
20+
} from "./question-dock-utils"
1621

1722
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
1823
const session = useSession()
@@ -119,7 +124,14 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
119124

120125
syncAgent(answers, kinds)
121126

122-
if (!single() && !multi()) {
127+
const outcome = pickOutcome({ single: single(), multi: multi(), custom })
128+
if (outcome.kind === "submit") {
129+
// Mirror TUI behaviour: a single-question single-select option pick submits immediately.
130+
// handleCustomSubmit covers the custom-input path via its own submit() call.
131+
reply([[answer]])
132+
return
133+
}
134+
if (outcome.kind === "advance") {
123135
setStore("tab", store.tab + 1)
124136
}
125137
}

packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import type { QuestionOption } from "../../types/messages"
22

3+
export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "stay" }
4+
5+
/**
6+
* Decide what should happen after a user picks an option in the question dock.
7+
*
8+
* - Multi-select prompts: the pick only toggles local state; no tab change, no submit.
9+
* - Single-question single-select, option pick: submit immediately (matches the TUI).
10+
* - Multi-question single-select, option pick: advance to the next tab.
11+
* - Custom-input path for a single-select is handled separately in handleCustomSubmit.
12+
*/
13+
export function pickOutcome(input: { single: boolean; multi: boolean; custom: boolean }): PickOutcome {
14+
if (input.multi) return { kind: "stay" }
15+
if (input.single && input.custom) return { kind: "stay" }
16+
if (input.single) return { kind: "submit" }
17+
return { kind: "advance" }
18+
}
19+
320
export function toggleAnswer(existing: string[], answer: string): string[] {
421
const next = [...existing]
522
const index = next.indexOf(answer)

packages/opencode/src/kilocode/plan-followup.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { MessageV2 } from "@/session/message-v2"
1616
import { Todo } from "@/session/todo"
1717
import { makeRuntime } from "@/effect/run-service"
1818
import { Log } from "@/util/log"
19+
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
1920
import path from "path"
2021
import z from "zod"
2122

@@ -255,6 +256,7 @@ export namespace PlanFollowup {
255256
text: input.text,
256257
synthetic: input.synthetic ?? true,
257258
} satisfies MessageV2.TextPart)
259+
return msg
258260
}
259261

260262
function prompt(input: { sessionID: SessionID; abort: AbortSignal }) {
@@ -264,7 +266,12 @@ export namespace PlanFollowup {
264266
{
265267
question: "Ready to implement?",
266268
header: "Implement",
267-
custom: true,
269+
// On CLI the main prompt input is hidden while a blocking question is active,
270+
// so we need the custom-answer row to allow a free-text reply. On VS Code the
271+
// main prompt input below the dock already routes typed text as a question
272+
// reply, so "Type your own answer" would be redundant (originally hidden in
273+
// 65566af7f8, flipped back during the v1.4.4 upstream merge).
274+
custom: Flag.KILO_CLIENT === "cli",
268275
options: [
269276
{
270277
label: ANSWER_NEW_SESSION,
@@ -398,22 +405,24 @@ export namespace PlanFollowup {
398405
const code = await resolveCodeModel({
399406
model: user.model,
400407
})
401-
await inject({
408+
const msg = await inject({
402409
sessionID: input.sessionID,
403410
agent: "code",
404411
model: code.model,
405412
text: "Implement the plan above.",
406413
})
414+
KiloSessionPromptQueue.retarget(input.sessionID, msg.id)
407415
return "continue"
408416
}
409417

410418
Telemetry.trackPlanFollowup(input.sessionID, "custom")
411-
await inject({
419+
const msg = await inject({
412420
sessionID: input.sessionID,
413421
agent: "plan",
414422
model: user.model,
415423
text: answer,
416424
})
425+
KiloSessionPromptQueue.retarget(input.sessionID, msg.id)
417426
return "continue"
418427
}
419428
}

packages/opencode/src/kilocode/session/prompt-queue.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ type Slot = {
99
readonly tail: Promise<void>
1010
}
1111

12+
type Target = {
13+
readonly base: MessageID
14+
readonly extras: ReadonlySet<MessageID>
15+
}
16+
1217
export namespace KiloSessionPromptQueue {
1318
const tails = new Map<SessionID, Promise<void>>()
1419
const versions = new Map<SessionID, number>()
15-
const targets = new Map<SessionID, MessageID>()
20+
const targets = new Map<SessionID, Target>()
1621

1722
const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0
1823
const settle = (promise: Promise<void>) =>
@@ -27,15 +32,30 @@ export namespace KiloSessionPromptQueue {
2732
})
2833
}
2934

35+
/**
36+
* Exempt an injected user message from being hidden by scope().
37+
* Called after PlanFollowup.inject() so the injected follow-up is visible
38+
* without also unhiding unrelated prompts that were queued mid-turn.
39+
*/
40+
export function retarget(sessionID: SessionID, id: MessageID) {
41+
const current = targets.get(sessionID)
42+
if (!current) return
43+
const extras = new Set(current.extras)
44+
extras.add(id)
45+
targets.set(sessionID, { base: current.base, extras })
46+
}
47+
3048
export function scope(sessionID: SessionID, messages: MessageV2.WithParts[]) {
3149
const target = targets.get(sessionID)
3250
if (!target) return messages
3351

3452
const hidden = new Set(
35-
messages.filter((item) => item.info.role === "user" && item.info.id > target).map((item) => item.info.id),
53+
messages
54+
.filter((item) => item.info.role === "user" && item.info.id > target.base && !target.extras.has(item.info.id))
55+
.map((item) => item.info.id),
3656
)
3757
const visible = messages.filter((item) => {
38-
if (item.info.role === "user") return item.info.id <= target
58+
if (item.info.role === "user") return !hidden.has(item.info.id)
3959
if (item.info.role === "assistant") return !hidden.has(item.info.parentID)
4060
return true
4161
})
@@ -45,12 +65,14 @@ export namespace KiloSessionPromptQueue {
4565
// was written after the queue event). Ordering by time_created alone puts
4666
// the queued prompt before the prior turn's final assistant reply, which
4767
// makes the next request end with an assistant message and trips Anthropic's
48-
// prefill rejection. Move the target user message and any of its own turn's
49-
// assistant messages to the end so the request always ends with the queued
50-
// user prompt (or with its own turn's latest assistant step).
68+
// prefill rejection. Move the target user message (and any injected
69+
// follow-ups) plus their own turn's assistant messages to the end so the
70+
// request always ends with the queued user prompt (or its latest assistant
71+
// step).
72+
const ownsID = (id: MessageID) => id === target.base || target.extras.has(id)
5173
const owns = (item: MessageV2.WithParts) => {
52-
if (item.info.role === "user") return item.info.id === target
53-
if (item.info.role === "assistant") return item.info.parentID === target
74+
if (item.info.role === "user") return ownsID(item.info.id)
75+
if (item.info.role === "assistant") return ownsID(item.info.parentID)
5476
return false
5577
}
5678
const before: MessageV2.WithParts[] = []
@@ -81,12 +103,12 @@ export namespace KiloSessionPromptQueue {
81103
if (slot.version !== version(sessionID)) return cancelled
82104
return Effect.acquireUseRelease(
83105
Effect.sync(() => {
84-
targets.set(sessionID, target)
106+
targets.set(sessionID, { base: target, extras: new Set() })
85107
}),
86108
() => work,
87109
() =>
88110
Effect.sync(() => {
89-
if (targets.get(sessionID) === target) targets.delete(sessionID)
111+
if (targets.get(sessionID)?.base === target) targets.delete(sessionID)
90112
}),
91113
)
92114
}),

packages/opencode/test/kilocode/plan-followup.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,65 @@ describe("plan follow-up", () => {
266266
await expect(pending).resolves.toBe("break")
267267
}))
268268

269+
test("ask - emits a single-select question with the canonical answers and custom enabled on CLI", () =>
270+
withInstance(async () => {
271+
const seeded = await seed({ text: "1. Build" })
272+
const pending = PlanFollowup.ask({
273+
sessionID: seeded.sessionID,
274+
messages: seeded.messages,
275+
abort: AbortSignal.any([]),
276+
})
277+
278+
const item = await waitQuestion(seeded.sessionID)
279+
expect(item).toBeDefined()
280+
if (!item) return
281+
const q = item.questions[0]
282+
expect(q).toBeDefined()
283+
if (!q) return
284+
285+
// On CLI the main prompt input is hidden while a blocking question is active, so
286+
// "Type your own answer" must remain available — i.e. custom must not be false.
287+
expect(q.custom).not.toBe(false)
288+
expect(q.multiple).not.toBe(true)
289+
expect(q.options.map((item) => item.label)).toEqual([
290+
PlanFollowup.ANSWER_NEW_SESSION,
291+
PlanFollowup.ANSWER_CONTINUE,
292+
])
293+
294+
await question.reject(item.id)
295+
await expect(pending).resolves.toBe("break")
296+
}))
297+
298+
test("ask - hides custom answer row on VS Code where the main prompt input handles typed replies", () =>
299+
withInstance(async () => {
300+
const prev = process.env.KILO_CLIENT
301+
try {
302+
process.env.KILO_CLIENT = "vscode"
303+
const seeded = await seed({ text: "1. Build" })
304+
const pending = PlanFollowup.ask({
305+
sessionID: seeded.sessionID,
306+
messages: seeded.messages,
307+
abort: AbortSignal.any([]),
308+
})
309+
310+
const item = await waitQuestion(seeded.sessionID)
311+
expect(item).toBeDefined()
312+
if (!item) return
313+
const q = item.questions[0]
314+
expect(q).toBeDefined()
315+
if (!q) return
316+
317+
// On VS Code the dock's main prompt input already accepts free text as a reply,
318+
// so the "Type your own answer" row is redundant and must be hidden.
319+
expect(q.custom).toBe(false)
320+
321+
await question.reject(item.id)
322+
await expect(pending).resolves.toBe("break")
323+
} finally {
324+
process.env.KILO_CLIENT = prev
325+
}
326+
}))
327+
269328
test("ask - returns continue and creates code message on Continue here", () =>
270329
withInstance(async () => {
271330
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
@@ -349,6 +408,40 @@ describe("plan follow-up", () => {
349408
expect(part.synthetic).toBe(true)
350409
}))
351410

411+
test("ask - retargets prompt queue so injected message is visible in scope", () =>
412+
withInstance(async () => {
413+
const { KiloSessionPromptQueue } = await import("../../src/kilocode/session/prompt-queue")
414+
const seeded = await seed({ text: "1. Refactor\n2. Ship" })
415+
416+
// Simulate the prompt queue having a target set (like during a running loop)
417+
const original = seeded.messages.find((m) => m.info.role === "user")!.info.id
418+
KiloSessionPromptQueue.retarget(seeded.sessionID, original)
419+
420+
const pending = PlanFollowup.ask({
421+
sessionID: seeded.sessionID,
422+
messages: seeded.messages,
423+
abort: AbortSignal.any([]),
424+
})
425+
426+
const item = await waitQuestion(seeded.sessionID)
427+
expect(item).toBeDefined()
428+
if (!item) return
429+
await question.reply({
430+
requestID: item.id,
431+
answers: [[PlanFollowup.ANSWER_CONTINUE]],
432+
})
433+
434+
await expect(pending).resolves.toBe("continue")
435+
436+
// The injected user message must be visible when scoped
437+
const all = await Session.messages({ sessionID: seeded.sessionID })
438+
const scoped = KiloSessionPromptQueue.scope(seeded.sessionID, all)
439+
const injected = scoped.findLast((m) => m.info.role === "user")
440+
expect(injected).toBeDefined()
441+
const part = injected!.parts.find((p) => p.type === "text")
442+
expect(part?.type === "text" && part.text).toBe("Implement the plan above.")
443+
}))
444+
352445
test("ask - creates a new session on Start new session with handover and todos", () =>
353446
withInstance(async () => {
354447
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {

packages/opencode/test/kilocode/session-prompt-queue.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,39 @@ describe("session prompt queue", () => {
188188
expect(ids).toEqual([m1, a1, a1tail, m2, a2step1])
189189
})
190190

191+
test("retarget keeps older queued prompts hidden", async () => {
192+
// Regression: retargeting used to move the visible-message boundary forward,
193+
// which unhid any user prompts queued between the base and the injected
194+
// follow-up. Exempt the follow-up without reopening the boundary.
195+
const sessionID = SessionID.make("session_retarget_hide")
196+
const base = MessageID.make("message_b1")
197+
const ans = MessageID.make("message_b2")
198+
const queued = MessageID.make("message_b3") // queued while base was running
199+
const injected = MessageID.make("message_b4") // injected follow-up
200+
const messages = [
201+
user(sessionID, base),
202+
assistant(sessionID, ans, base),
203+
user(sessionID, queued),
204+
user(sessionID, injected),
205+
]
206+
207+
const ids = await Effect.runPromise(
208+
KiloSessionPromptQueue.enqueue(
209+
sessionID,
210+
base,
211+
Effect.sync(() => {
212+
KiloSessionPromptQueue.retarget(sessionID, injected)
213+
return KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)
214+
}),
215+
Effect.succeed([]),
216+
),
217+
)
218+
219+
expect(ids).not.toContain(queued)
220+
expect(ids).toContain(injected)
221+
expect(ids[ids.length - 1]).toBe(injected)
222+
})
223+
191224
test("continues a queued prompt after the active run finishes", async () => {
192225
const ready = Promise.withResolvers<void>()
193226
const release = Promise.withResolvers<void>()

0 commit comments

Comments
 (0)