diff --git a/.changeset/clever-questions-select.md b/.changeset/clever-questions-select.md new file mode 100644 index 0000000..a70daa8 --- /dev/null +++ b/.changeset/clever-questions-select.md @@ -0,0 +1,5 @@ +--- +"@haphazarddev/pi-ask-user-question": minor +--- + +Add multi-select option prompts for choosing multiple predefined answers. diff --git a/extensions/pi-ask-user-question/README.md b/extensions/pi-ask-user-question/README.md index 1fb7020..5e8fb92 100644 --- a/extensions/pi-ask-user-question/README.md +++ b/extensions/pi-ask-user-question/README.md @@ -26,6 +26,7 @@ This package registers: - single-line input - multi-line editor input - option selection with keyboard shortcuts +- multi-select option selection for choosing more than one predefined answer (returns `answer` as a string array) - optional custom answers alongside predefined choices ## Typical use cases diff --git a/extensions/pi-ask-user-question/src/index.ts b/extensions/pi-ask-user-question/src/index.ts index bff6e99..7fcdee0 100644 --- a/extensions/pi-ask-user-question/src/index.ts +++ b/extensions/pi-ask-user-question/src/index.ts @@ -4,9 +4,9 @@ import { Type } from "@sinclair/typebox"; interface AskUserQuestionDetails { question: string; - answer: string | null; + answer: string | string[] | null; cancelled: boolean; - mode: "confirm" | "select" | "input" | "editor"; + mode: "confirm" | "select" | "multi-select" | "input" | "editor"; options?: string[]; } @@ -25,6 +25,12 @@ const AskUserQuestionParams = Type.Object({ description: "Optional list of choices for the user", }), ), + multiSelect: Type.Optional( + Type.Boolean({ + description: + "Switch option prompts from single-select to multi-select. When true, the returned answer is a string array. Defaults to false.", + }), + ), allowCustomAnswer: Type.Optional( Type.Boolean({ description: "When options are provided, allow the user to type a custom answer. Defaults to true.", @@ -121,6 +127,111 @@ async function selectOptionWithShortcuts( }); } +async function selectMultipleOptionsWithShortcuts( + ctx: { + ui: { + custom: ( + component: (tui: any, theme: any, keybindings: any, done: (value: T) => void) => any, + ) => Promise; + }; + }, + question: string, + options: string[], +): Promise { + return ctx.ui.custom((tui, theme, _kb, done) => { + let optionIndex = 0; + const selectedIndexes = new Set(); + let cachedLines: string[] | undefined; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function toggle(index: number) { + if (index < 0 || index >= options.length) return; + + if (selectedIndexes.has(index)) { + selectedIndexes.delete(index); + } else { + selectedIndexes.add(index); + } + refresh(); + } + + function submit() { + done([...selectedIndexes].sort((a, b) => a - b).map((index) => options[index])); + } + + function handleInput(data: string) { + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(options.length - 1, optionIndex + 1); + refresh(); + return; + } + + if (matchesKey(data, Key.space)) { + toggle(optionIndex); + return; + } + + if (matchesKey(data, Key.enter)) { + submit(); + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + + if (/^[1-9]$/.test(data)) { + toggle(Number(data) - 1); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const add = (text: string) => lines.push(truncateToWidth(text, width)); + + add(theme.fg("accent", "─".repeat(width))); + add(theme.fg("text", ` ${question}`)); + lines.push(""); + + for (let i = 0; i < options.length; i++) { + const highlighted = i === optionIndex; + const checked = selectedIndexes.has(i) ? "x" : " "; + const prefix = highlighted ? theme.fg("accent", "> ") : " "; + const text = `${i + 1}. [${checked}] ${options[i]}`; + add(highlighted ? prefix + theme.fg("accent", text) : ` ${theme.fg("text", text)}`); + } + + lines.push(""); + add(theme.fg("dim", " 1-9 toggle • Space toggle • ↑↓ navigate • Enter submit • Esc cancel")); + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }); +} + export default function askUserQuestion(pi: ExtensionAPI) { pi.registerTool({ name: "ask_user_question", @@ -163,6 +274,35 @@ export default function askUserQuestion(pi: ExtensionAPI) { const options = (params.options ?? []).filter((option) => option.trim().length > 0); if (options.length > 0) { + if (params.multiSelect) { + const choices = await selectMultipleOptionsWithShortcuts(ctx, params.question, options); + + if (!choices) { + return { + content: [{ type: "text", text: "User cancelled the question." }], + details: { + question: params.question, + answer: null, + cancelled: true, + mode: "multi-select", + options, + } satisfies AskUserQuestionDetails, + }; + } + + const answerText = choices.join(", "); + return { + content: [{ type: "text", text: answerText ? `User selected: ${answerText}` : "User selected no options." }], + details: { + question: params.question, + answer: choices, + cancelled: false, + mode: "multi-select", + options, + } satisfies AskUserQuestionDetails, + }; + } + const allowCustomAnswer = params.allowCustomAnswer !== false; const customLabel = "Type your own answer…"; const displayedOptions = allowCustomAnswer ? [...options, customLabel] : options; diff --git a/extensions/pi-ask-user-question/test/index.test.ts b/extensions/pi-ask-user-question/test/index.test.ts index 137a37a..b18b46f 100644 --- a/extensions/pi-ask-user-question/test/index.test.ts +++ b/extensions/pi-ask-user-question/test/index.test.ts @@ -78,6 +78,89 @@ describe("pi-ask-user-question", () => { expect(result.details).toMatchObject({ answer: "B", cancelled: false, mode: "select", options: ["A", "B"] }); }); + it("keeps single-choice option selection distinct from multi-select", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue("A") }); + + const result = await tool.execute("id", { question: "Pick one", options: ["A", "B"] }, undefined, undefined, createMockContext({ ui })); + + expect(result.content[0].text).toBe("User selected: A"); + expect(result.details).toMatchObject({ answer: "A", cancelled: false, mode: "select" }); + expect(result.details.answers).toBeUndefined(); + }); + + it("handles multi-select option answers", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue(["A", "C"]) }); + + const result = await tool.execute( + "id", + { question: "Select all valid answers", options: ["A", "B", "C"], multiSelect: true }, + undefined, + undefined, + createMockContext({ ui }), + ); + + expect(result.content[0].text).toBe("User selected: A, C"); + expect(result.details).toMatchObject({ + answer: ["A", "C"], + cancelled: false, + mode: "multi-select", + options: ["A", "B", "C"], + }); + expect(result.details.answers).toBeUndefined(); + }); + + it("handles cancelled multi-select option answers", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue(null) }); + + const result = await tool.execute( + "id", + { question: "Select all valid answers", options: ["A", "B"], multiSelect: true }, + undefined, + undefined, + createMockContext({ ui }), + ); + + expect(result.content[0].text).toBe("User cancelled the question."); + expect(result.details).toMatchObject({ answer: null, cancelled: true, mode: "multi-select" }); + expect(result.details.answers).toBeUndefined(); + }); + + it("toggles multiple options in the multi-select UI before submitting", async () => { + const tool = registerTool(); + const ui = createMockUi({ + custom: vi.fn().mockImplementation((component) => + new Promise((resolve) => { + const widget = component( + { requestRender: vi.fn() }, + { fg: (_color: string, text: string) => text }, + {}, + resolve, + ); + + widget.handleInput(" "); + widget.handleInput("\x1b[B"); + widget.handleInput("\x1b[B"); + widget.handleInput(" "); + widget.handleInput("\r"); + }), + ), + }); + + const result = await tool.execute( + "id", + { question: "Select all valid answers", options: ["A", "B", "C"], multiSelect: true }, + undefined, + undefined, + createMockContext({ ui }), + ); + + expect(result.details).toMatchObject({ answer: ["A", "C"], mode: "multi-select" }); + expect(result.details.answers).toBeUndefined(); + }); + it("handles cancelled option selection", async () => { const tool = registerTool(); const ui = createMockUi({ custom: vi.fn().mockResolvedValue(null) });