Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clever-questions-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@haphazarddev/pi-ask-user-question": minor
---

Add multi-select option prompts for choosing multiple predefined answers.
1 change: 1 addition & 0 deletions extensions/pi-ask-user-question/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 142 additions & 2 deletions extensions/pi-ask-user-question/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand All @@ -25,6 +25,12 @@ const AskUserQuestionParams = Type.Object({
description: "Optional list of choices for the user",
}),
),
multiSelect: Type.Optional(
Comment thread
corwinm marked this conversation as resolved.
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.",
Expand Down Expand Up @@ -121,6 +127,111 @@ async function selectOptionWithShortcuts(
});
}

async function selectMultipleOptionsWithShortcuts(
ctx: {
ui: {
custom: <T>(
component: (tui: any, theme: any, keybindings: any, done: (value: T) => void) => any,
) => Promise<T>;
};
},
question: string,
options: string[],
): Promise<string[] | null> {
return ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
let optionIndex = 0;
const selectedIndexes = new Set<number>();
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",
Expand Down Expand Up @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions extensions/pi-ask-user-question/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
Loading