diff --git a/.changeset/fix-question-multiselect-space-toggle.md b/.changeset/fix-question-multiselect-space-toggle.md new file mode 100644 index 00000000000..4720dc5ade7 --- /dev/null +++ b/.changeset/fix-question-multiselect-space-toggle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Bind `Space` to toggle and `Enter` to continue in multi-select questions from the `kilo run` footer and the interactive TUI, matching the convention used by other CLI tools (`fzf`, `lazygit`, `btop`, ...). \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/opencode/src/cli/cmd/run/footer.question.tsx index bafb2c4676b..9177e9e8ab7 100644 --- a/packages/opencode/src/cli/cmd/run/footer.question.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.question.tsx @@ -38,6 +38,7 @@ import { questionSubmit, questionSync, questionTabs, + questionToggleAction, // kilocode_change questionTotal, } from "./question.shared" import type { RunFooterTheme } from "./theme" @@ -65,7 +66,7 @@ export function RunQuestionBody(props: { } if (info()?.multiple) { - return "toggle" + return "continue" // kilocode_change } if (single()) { @@ -125,7 +126,10 @@ export function RunQuestionBody(props: { const choose = (selected: number) => { const base = state() const cur = questionSetSelected(base, selected) - const next = questionSelect(cur, props.request) + // kilocode_change start - digit shortcuts toggle in multi-select + const info = questionInfo(props.request, cur) + const next = info?.multiple ? questionToggleAction(cur, props.request) : questionSelect(cur, props.request) + // kilocode_change end if (next.state !== base) { setState(next.state) } @@ -217,6 +221,17 @@ export function RunQuestionBody(props: { return } + // kilocode_change start - space toggles in multi-select + if (event.name === "space") { + const next = questionToggleAction(cur, props.request) + if (next.state !== cur) { + setState(next.state) + } + event.preventDefault() + return + } + // kilocode_change end + const total = questionTotal(props.request, cur) const max = Math.min(total, 9) const digit = Number(event.name) @@ -562,6 +577,13 @@ export function RunQuestionBody(props: { {"⇆"} tab + {/* kilocode_change start - multi-select space toggle hint */} + + + space toggle + + + {/* kilocode_change end */} {"↑↓"} select diff --git a/packages/opencode/src/cli/cmd/run/question.shared.ts b/packages/opencode/src/cli/cmd/run/question.shared.ts index 575c24f9662..5519f7be0b8 100644 --- a/packages/opencode/src/cli/cmd/run/question.shared.ts +++ b/packages/opencode/src/cli/cmd/run/question.shared.ts @@ -216,6 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest, } } +// kilocode_change start - space toggles, enter advances in multi-select export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep { const info = questionInfo(request, state) if (!info) { @@ -229,8 +230,38 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques } } + const last = request.questions.length - 1 + const next = state.tab >= last ? last + 1 : state.tab + 1 + return { + state: questionSetTab(state, next), + } + } + + const option = info.options[state.selected] + if (!option) { + return { state } + } + + if (info.multiple) { + const last = request.questions.length - 1 + const next = state.tab >= last ? last + 1 : state.tab + 1 + return { + state: questionSetTab(state, next), + } + } + + return questionPick(state, request, option.label) +} + +export function questionToggleAction(state: QuestionBodyState, request: QuestionRequest): QuestionStep { + const info = questionInfo(request, state) + if (!info || !info.multiple) { + return { state } + } + + if (questionOther(request, state)) { const value = questionInput(state) - if (value && questionPicked(state)) { + if (value) { return { state: questionToggle(state, value), } @@ -246,14 +277,11 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques return { state } } - if (info.multiple) { - return { - state: questionToggle(state, option.label), - } + return { + state: questionToggle(state, option.label), } - - return questionPick(state, request, option.label) } +// kilocode_change end export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep { const info = questionInfo(request, state) @@ -332,9 +360,15 @@ export function questionHint(request: QuestionRequest, state: QuestionBodyState) } const info = questionInfo(request, state) + // kilocode_change start - multi-select hint uses space/continue if (questionSingle(request)) { - return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss` + return `↑↓ select enter submit esc dismiss` + } + + if (info?.multiple) { + return `⇆ tab ↑↓ select space toggle enter continue esc dismiss` } - return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss` + return `⇆ tab ↑↓ select enter confirm esc dismiss` + // kilocode_change end } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 1cba4fc92b9..979dec906b2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -128,6 +128,26 @@ export function QuestionPrompt(props: { pick(opt.label) } + // kilocode_change start - space toggles, enter advances in multi-select + function continueOption() { + if (other()) { + if (multi()) { + selectTab(Math.min(store.tab + 1, tabs() - 1)) + return + } + setStore("editing", true) + return + } + if (multi()) { + selectTab(Math.min(store.tab + 1, tabs() - 1)) + return + } + const opt = options()[store.selected] + if (!opt) return + pick(opt.label) + } + // kilocode_change end + onMount(() => { const popMode = modeStack.push(QUESTION_MODE) onCleanup(popMode) @@ -259,15 +279,36 @@ export function QuestionPrompt(props: { ...tuiConfig.keybinds.get("app.exit"), ] : [ + // kilocode_change start - space toggles, enter advances in multi-select ...Array.from({ length: max }, (_, index) => ({ key: String(index + 1), - desc: `Select answer ${index + 1}`, + desc: question()?.multiple ? `Toggle answer ${index + 1}` : `Select answer ${index + 1}`, group: "Question", cmd: () => { moveTo(index) - selectOption() + if (index === options().length) { + if (question()?.multiple) { + const value = input() + if (value) { + toggle(value) + } else { + setStore("editing", true) + } + } else { + setStore("editing", true) + } + return + } + const opt = options()[index] + if (!opt) return + if (question()?.multiple) { + toggle(opt.label) + } else { + pick(opt.label) + } }, })), + // kilocode_change end { key: "up", desc: "Previous answer", @@ -282,7 +323,39 @@ export function QuestionPrompt(props: { }, { key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, - { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, + // kilocode_change start - multi-select space toggle binding + ...(question()?.multiple + ? [ + { + key: "space", + desc: "Toggle answer", + group: "Question", + cmd: () => { + if (other()) { + const value = input() + if (value) { + toggle(value) + } else { + setStore("editing", true) + } + return + } + const opt = options()[store.selected] + if (!opt) return + toggle(opt.label) + }, + }, + ] + : []), + // kilocode_change end + // kilocode_change start - enter advances in multi-select + { + key: "return", + desc: question()?.multiple ? "Continue" : "Select answer", + group: "Question", + cmd: () => continueOption(), + }, + // kilocode_change end { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, ...tuiConfig.keybinds.get("app.exit"), ]), @@ -497,16 +570,24 @@ export function QuestionPrompt(props: { {"⇆"} tab + {/* kilocode_change start - multi-select space toggle hint */} + + + space toggle + + + {/* kilocode_change end */} {"↑↓"} select - enter{" "} + enter {/* kilocode_change start - multi-select uses continue verb */} - {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"} + {confirm() ? "submit" : multi() ? "continue" : single() ? "submit" : "confirm"} + {/* kilocode_change end */} diff --git a/packages/opencode/test/cli/run/question.shared.test.ts b/packages/opencode/test/cli/run/question.shared.test.ts index 39247e2d329..f0b1d6138ac 100644 --- a/packages/opencode/test/cli/run/question.shared.test.ts +++ b/packages/opencode/test/cli/run/question.shared.test.ts @@ -10,6 +10,7 @@ import { questionStoreCustom, questionSubmit, questionSync, + questionToggleAction, // kilocode_change } from "@/cli/cmd/run/question.shared" function req(input: Partial = {}): QuestionRequest { @@ -71,6 +72,7 @@ describe("run question shared", () => { }) }) + // kilocode_change start - space toggles, enter advances in multi-select test("toggles answers for multiple-choice questions", () => { const ask = req({ questions: [ @@ -83,13 +85,156 @@ describe("run question shared", () => { ], }) - let state = questionSelect(createQuestionBodyState("question-1"), ask).state + let state = questionToggleAction(createQuestionBodyState("question-1"), ask).state expect(state.answers).toEqual([["bug"]]) - state = questionSelect(state, ask).state + state = questionToggleAction(state, ask).state expect(state.answers).toEqual([[]]) }) + test("enter advances multi-select tab without toggling", () => { + const ask = req({ + questions: [ + { + question: "Tags?", + header: "Tags", + options: [{ label: "bug", description: "Bug fix" }], + multiple: true, + }, + ], + }) + + const state = questionSelect(createQuestionBodyState("question-1"), ask).state + expect(state.answers).toEqual([]) + expect(state.tab).toBe(1) + expect(questionConfirm(ask, state)).toBe(true) + }) + + test("enter advances multi-question single-select tab on the last question to confirm", () => { + const ask = req({ + questions: [ + { + question: "First?", + header: "First", + options: [{ label: "a", description: "" }], + multiple: false, + }, + { + question: "Second?", + header: "Second", + options: [{ label: "b", description: "" }], + multiple: false, + }, + ], + }) + + let state = createQuestionBodyState("question-1") + state = questionSelect(state, ask).state + expect(state.tab).toBe(1) + + state = questionSelect(state, ask).state + expect(questionConfirm(ask, state)).toBe(true) + }) + + test("toggle is a no-op in single-select", () => { + const ask = req({ + questions: [ + { + question: "Mode?", + header: "Mode", + options: [{ label: "chunked", description: "" }], + multiple: false, + }, + ], + }) + + const state = questionToggleAction(createQuestionBodyState("question-1"), ask).state + expect(state.answers).toEqual([]) + }) + + test("enter advances past the custom row in multi-select", () => { + const ask = req({ + questions: [ + { + question: "Tags?", + header: "Tags", + options: [{ label: "bug", description: "Bug fix" }], + multiple: true, + custom: true, + }, + ], + }) + + let state = questionSetSelected(createQuestionBodyState("question-1"), 1) + state = questionSelect(state, ask).state + expect(state.editing).toBe(false) + expect(state.tab).toBe(1) + }) + + test("enter on custom row in single-select opens the editor", () => { + const ask = req({ + questions: [ + { + question: "Mode?", + header: "Mode", + options: [{ label: "chunked", description: "" }], + multiple: false, + custom: true, + }, + ], + }) + + const state = questionSetSelected(createQuestionBodyState("question-1"), 1) + const next = questionSelect(state, ask).state + expect(next.editing).toBe(true) + }) + + test("space on custom row with typed value toggles it", () => { + const ask = req({ + questions: [ + { + question: "Tags?", + header: "Tags", + options: [{ label: "bug", description: "Bug fix" }], + multiple: true, + custom: true, + }, + ], + }) + + let state = createQuestionBodyState("question-1") + state = questionStoreCustom(state, 0, "custom-tag") + state = questionSave(state, ask).state + expect(state.answers[0]).toContain("custom-tag") + + state = questionSetSelected(state, 1) + state = questionToggleAction(state, ask).state + expect(state.answers[0]).not.toContain("custom-tag") + + // Toggle it back on + state = questionToggleAction(state, ask).state + expect(state.answers[0]).toContain("custom-tag") + }) + + test("space on custom row without typed value opens the editor", () => { + const ask = req({ + questions: [ + { + question: "Tags?", + header: "Tags", + options: [{ label: "bug", description: "Bug fix" }], + multiple: true, + custom: true, + }, + ], + }) + + const state = questionSetSelected(createQuestionBodyState("question-1"), 1) + const next = questionToggleAction(state, ask).state + expect(next.editing).toBe(true) + }) + // kilocode_change end + test("stores and submits custom answers", () => { let state = questionSetSelected(createQuestionBodyState("question-1"), 1) let next = questionSelect(state, req())