32 KiB
Question Tool Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a global pi question extension that supports one or many multiple-choice questions and always appends a final “Something else…” free-text option.
Architecture: Keep pi-specific UI/runtime code in .pi/agent/extensions/question.ts, and extract a tiny pure helper module beside it for normalization, answer/result construction, and multi-question flow decisions. Cover the helper module with Node’s built-in test runner first, then wire the custom UI and verify the live extension through pi with manual smoke checks.
Tech Stack: Pi extensions API, @mariozechner/pi-coding-agent, @mariozechner/pi-tui, @sinclair/typebox, Node node:test
File Structure
- Create:
/home/alex/dotfiles/.pi/agent/extensions/question-core.mjs- Pure helpers for normalizing question input, appending the synthetic
Something else…option, creating structured answers, summarizing results, and multi-question flow decisions.
- Pure helpers for normalizing question input, appending the synthetic
- Create:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs- Node
node:testcoverage for the pure helper module.
- Node
- Create:
/home/alex/dotfiles/.pi/agent/extensions/question.ts- The live pi extension that registers the
questiontool and renders the single-question / multi-question custom UI.
- The live pi extension that registers the
- Modify:
/home/alex/dotfiles/docs/superpowers/plans/2026-04-09-question-tool.md- Check boxes as work progresses.
Environment Notes
/home/alex/.pi/agent/extensionsalready resolves to/home/alex/dotfiles/.pi/agent/extensions, so editing files in the repo updates the live global pi extension path.- No copy/symlink step is needed during implementation.
- Use
/reloadinside pi after file edits to reload the extension.
Task 1: Build the pure helper module with tests first
Files:
-
Create:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Create:
/home/alex/dotfiles/.pi/agent/extensions/question-core.mjs -
Test:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Step 1: Write the failing helper tests
Create /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs with this content:
import test from "node:test";
import assert from "node:assert/strict";
import {
SOMETHING_ELSE_LABEL,
SOMETHING_ELSE_VALUE,
createCustomAnswer,
createPredefinedAnswer,
normalizeQuestions,
summarizeAnswers,
} from "./question-core.mjs";
test("normalizeQuestions adds default labels and appends the Something else option", () => {
const [question] = normalizeQuestions([
{
id: "scope",
prompt: "Which scope fits best?",
options: [{ value: "small", label: "Small change" }],
},
]);
assert.equal(question.label, "Q1");
assert.deepEqual(question.options[0], { value: "small", label: "Small change" });
assert.deepEqual(question.options.at(-1), {
value: SOMETHING_ELSE_VALUE,
label: SOMETHING_ELSE_LABEL,
});
});
test("normalizeQuestions keeps provided labels and descriptions intact before the synthetic option", () => {
const [question] = normalizeQuestions([
{
id: "priority",
label: "Priority",
prompt: "Which priority?",
options: [
{ value: "p0", label: "P0", description: "Need this now" },
{ value: "p1", label: "P1" },
],
},
]);
assert.equal(question.label, "Priority");
assert.deepEqual(question.options.slice(0, 2), [
{ value: "p0", label: "P0", description: "Need this now" },
{ value: "p1", label: "P1" },
]);
assert.equal(question.options[2].label, SOMETHING_ELSE_LABEL);
});
test("answer helpers preserve machine values and summary lines distinguish predefined vs custom answers", () => {
const questions = normalizeQuestions([
{
id: "scope",
label: "Scope",
prompt: "Which scope fits best?",
options: [{ value: "small", label: "Small change" }],
},
{
id: "notes",
label: "Notes",
prompt: "Anything else?",
options: [{ value: "none", label: "No extra notes" }],
},
]);
const predefined = createPredefinedAnswer("scope", questions[0].options[0], 1);
const custom = createCustomAnswer("notes", "Needs to work with tmux");
assert.deepEqual(predefined, {
id: "scope",
value: "small",
label: "Small change",
wasCustom: false,
index: 1,
});
assert.deepEqual(custom, {
id: "notes",
value: "Needs to work with tmux",
label: "Needs to work with tmux",
wasCustom: true,
});
assert.deepEqual(summarizeAnswers(questions, [predefined, custom]), [
"Scope: user selected: 1. Small change",
"Notes: user wrote: Needs to work with tmux",
]);
});
- Step 2: Run the test to verify it fails
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: FAIL with an ERR_MODULE_NOT_FOUND error for ./question-core.mjs.
- Step 3: Write the minimal helper implementation
Create /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs with this content:
export const SOMETHING_ELSE_VALUE = "__something_else__";
export const SOMETHING_ELSE_LABEL = "Something else…";
export function normalizeQuestions(inputQuestions) {
return inputQuestions.map((question, index) => ({
...question,
label: question.label?.trim() ? question.label : `Q${index + 1}`,
options: [
...question.options,
{
value: SOMETHING_ELSE_VALUE,
label: SOMETHING_ELSE_LABEL,
},
],
}));
}
export function isSomethingElseOption(option) {
return option?.value === SOMETHING_ELSE_VALUE;
}
export function createPredefinedAnswer(questionId, option, index) {
return {
id: questionId,
value: option.value,
label: option.label,
wasCustom: false,
index,
};
}
export function createCustomAnswer(questionId, text) {
return {
id: questionId,
value: text,
label: text,
wasCustom: true,
};
}
export function summarizeAnswers(questions, answers) {
const answerById = new Map(answers.map((answer) => [answer.id, answer]));
return questions.flatMap((question) => {
const answer = answerById.get(question.id);
if (!answer) return [];
if (answer.wasCustom) {
return [`${question.label}: user wrote: ${answer.label}`];
}
return [`${question.label}: user selected: ${answer.index}. ${answer.label}`];
});
}
- Step 4: Run the test to verify it passes
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: PASS for all three tests.
- Step 5: Commit the helper module
Run:
git -C /home/alex/dotfiles add \
.pi/agent/extensions/question-core.mjs \
.pi/agent/extensions/question-core.test.mjs \
docs/superpowers/plans/2026-04-09-question-tool.md
git -C /home/alex/dotfiles commit -m "test: add question helper module"
Task 2: Add the single-question extension shell and structured results
Files:
-
Modify:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Modify:
/home/alex/dotfiles/.pi/agent/extensions/question-core.mjs -
Create:
/home/alex/dotfiles/.pi/agent/extensions/question.ts -
Test:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Step 1: Add failing tests for cancelled and sorted answered results
Replace the import block in /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs with this block, then append the new tests below it:
import test from "node:test";
import assert from "node:assert/strict";
import {
SOMETHING_ELSE_LABEL,
SOMETHING_ELSE_VALUE,
createAnsweredResult,
createCancelledResult,
createCustomAnswer,
createPredefinedAnswer,
normalizeQuestions,
summarizeAnswers,
} from "./question-core.mjs";
Append these tests:
test("createCancelledResult returns a structured cancelled payload", () => {
const questions = normalizeQuestions([
{
id: "scope",
prompt: "Which scope fits best?",
options: [{ value: "small", label: "Small change" }],
},
]);
assert.deepEqual(createCancelledResult(questions), {
questions,
answers: [],
cancelled: true,
});
});
test("createAnsweredResult keeps answers in question order", () => {
const questions = normalizeQuestions([
{
id: "scope",
label: "Scope",
prompt: "Which scope fits best?",
options: [{ value: "small", label: "Small change" }],
},
{
id: "notes",
label: "Notes",
prompt: "Anything else?",
options: [{ value: "none", label: "No extra notes" }],
},
]);
const second = createCustomAnswer("notes", "Custom note");
const first = createPredefinedAnswer("scope", questions[0].options[0], 1);
const result = createAnsweredResult(questions, [second, first]);
assert.equal(result.cancelled, false);
assert.deepEqual(result.answers.map((answer) => answer.id), ["scope", "notes"]);
});
- Step 2: Run the tests to verify they fail
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: FAIL because createAnsweredResult and createCancelledResult are not exported yet.
- Step 3: Add the minimal result helpers
Append these functions to /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs:
export function createCancelledResult(questions = []) {
return {
questions,
answers: [],
cancelled: true,
};
}
export function createAnsweredResult(questions, answers) {
const order = new Map(questions.map((question, index) => [question.id, index]));
return {
questions,
answers: [...answers].sort(
(left, right) => (order.get(left.id) ?? Number.POSITIVE_INFINITY) - (order.get(right.id) ?? Number.POSITIVE_INFINITY),
),
cancelled: false,
};
}
- Step 4: Run the tests to verify they pass
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: PASS for all five tests.
- Step 5: Create the first live extension version with single-question support
Create /home/alex/dotfiles/.pi/agent/extensions/question.ts with this content:
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import {
createAnsweredResult,
createCancelledResult,
createCustomAnswer,
createPredefinedAnswer,
isSomethingElseOption,
normalizeQuestions,
summarizeAnswers,
} from "./question-core.mjs";
interface QuestionOption {
value: string;
label: string;
description?: string;
}
interface Question {
id: string;
label: string;
prompt: string;
options: QuestionOption[];
}
interface Answer {
id: string;
value: string;
label: string;
wasCustom: boolean;
index?: number;
}
interface QuestionResult {
questions: Question[];
answers: Answer[];
cancelled: boolean;
}
const OptionSchema = Type.Object({
value: Type.String({ description: "Machine-friendly value returned to the model" }),
label: Type.String({ description: "Human-friendly label shown in the UI" }),
description: Type.Optional(Type.String({ description: "Optional help text shown under the label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({ description: "Stable identifier for the answer" }),
label: Type.Optional(Type.String({ description: "Short label for summaries and tabs" })),
prompt: Type.String({ description: "Full question text shown to the user" }),
options: Type.Array(OptionSchema, { description: "Predefined options for the user to choose from" }),
});
const QuestionParams = Type.Object({
questions: Type.Array(QuestionSchema, { description: "One or more questions to ask the user" }),
});
function errorResult(message: string, questions: Question[] = []) {
return {
content: [{ type: "text" as const, text: message }],
details: createCancelledResult(questions) as QuestionResult,
};
}
async function runSingleQuestion(ctx: any, question: Question): Promise<QuestionResult> {
const result = await ctx.ui.custom<Answer | null>((tui, theme, _kb, done) => {
let optionIndex = 0;
let inputMode = false;
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (value) => {
const trimmed = value.trim();
if (trimmed.length === 0) {
cachedLines = undefined;
tui.requestRender();
return;
}
done(createCustomAnswer(question.id, trimmed) as Answer);
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function handleInput(data: string) {
if (inputMode) {
if (matchesKey(data, Key.escape)) {
inputMode = false;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(question.options.length - 1, optionIndex + 1);
refresh();
return;
}
if (matchesKey(data, Key.enter)) {
const selected = question.options[optionIndex]!;
if (isSomethingElseOption(selected)) {
inputMode = true;
editor.setText("");
refresh();
return;
}
done(createPredefinedAnswer(question.id, selected, optionIndex + 1) as Answer);
return;
}
if (matchesKey(data, Key.escape)) {
done(null);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (line: string) => lines.push(truncateToWidth(line, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", ` ${question.prompt}`));
lines.push("");
for (let index = 0; index < question.options.length; index += 1) {
const option = question.options[index]!;
const isSelected = index === optionIndex;
const prefix = isSelected ? theme.fg("accent", "> ") : " ";
const label = `${index + 1}. ${option.label}`;
add(prefix + (isSelected ? theme.fg("accent", label) : theme.fg("text", label)));
if (option.description) {
add(` ${theme.fg("muted", option.description)}`);
}
}
if (inputMode) {
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
}
lines.push("");
add(theme.fg("dim", inputMode ? " Enter to submit • Esc to go back" : " ↑↓ navigate • Enter select • Esc cancel"));
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
});
if (!result) {
return createCancelledResult([question]) as QuestionResult;
}
return createAnsweredResult([question], [result]) as QuestionResult;
}
export default function question(pi: ExtensionAPI) {
pi.registerTool({
name: "question",
label: "Question",
description:
"Ask the user one or more multiple-choice questions. Every question automatically gets a final Something else… option for free-text answers.",
parameters: QuestionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return errorResult("Error: UI not available (running in non-interactive mode)");
}
if (params.questions.length === 0) {
return errorResult("Error: No questions provided");
}
const questions = normalizeQuestions(params.questions) as Question[];
if (questions.length !== 1) {
return errorResult("Error: Multi-question flow is not implemented yet", questions);
}
const result = await runSingleQuestion(ctx, questions[0]!);
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the question flow" }],
details: result,
};
}
return {
content: [{ type: "text", text: summarizeAnswers(result.questions, result.answers).join("\n") }],
details: result,
};
},
renderCall(args, theme) {
const questions = Array.isArray(args.questions) ? args.questions : [];
const labels = questions
.map((question: { label?: string; id?: string }) => question.label || question.id)
.filter(Boolean)
.join(", ");
let text = theme.fg("toolTitle", theme.bold("question "));
text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
if (labels) {
text += theme.fg("dim", ` (${labels})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as QuestionResult | undefined;
if (!details) {
const first = result.content[0];
return new Text(first?.type === "text" ? first.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
const lines = summarizeAnswers(details.questions, details.answers).map(
(line) => `${theme.fg("success", "✓ ")}${line}`,
);
return new Text(lines.join("\n"), 0, 0);
},
});
}
- Step 6: Reload pi and manually verify the single-question path
Run:
cd /home/alex/dotfiles
pi
Inside pi, run this sequence:
/reload
Use the question tool to ask me one question with these options:
- stable: Stable release
- nightly: Nightly release
Expected manual checks:
-
the tool row appears as
question 1 question -
the UI shows three options, with
Something else…appended last -
selecting
Something else…opens inline text entry -
pressing
Escin text entry returns to the option list -
selecting
stablereturns a successful tool result with a single summary line for the chosen answer -
Step 7: Commit the single-question extension
Run:
git -C /home/alex/dotfiles add \
.pi/agent/extensions/question-core.mjs \
.pi/agent/extensions/question-core.test.mjs \
.pi/agent/extensions/question.ts \
docs/superpowers/plans/2026-04-09-question-tool.md
git -C /home/alex/dotfiles commit -m "feat: add single-question pi question tool"
Task 3: Extend the tool to multi-question review and submit flow
Files:
-
Modify:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Modify:
/home/alex/dotfiles/.pi/agent/extensions/question-core.mjs -
Modify:
/home/alex/dotfiles/.pi/agent/extensions/question.ts -
Test:
/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs -
Step 1: Add failing tests for multi-question flow helpers
Replace the import block in /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs with this block, then append the new tests below it:
import test from "node:test";
import assert from "node:assert/strict";
import {
SOMETHING_ELSE_LABEL,
SOMETHING_ELSE_VALUE,
allQuestionsAnswered,
createAnsweredResult,
createCancelledResult,
createCustomAnswer,
createPredefinedAnswer,
nextTabAfterAnswer,
normalizeQuestions,
summarizeAnswers,
} from "./question-core.mjs";
Append these tests:
test("allQuestionsAnswered only returns true when every question has an answer", () => {
const questions = normalizeQuestions([
{
id: "scope",
prompt: "Scope?",
options: [{ value: "small", label: "Small" }],
},
{
id: "priority",
prompt: "Priority?",
options: [{ value: "p1", label: "P1" }],
},
]);
const answers = new Map([
["scope", createPredefinedAnswer("scope", questions[0].options[0], 1)],
]);
assert.equal(allQuestionsAnswered(questions, answers), false);
answers.set("priority", createCustomAnswer("priority", "Ship this week"));
assert.equal(allQuestionsAnswered(questions, answers), true);
});
test("nextTabAfterAnswer advances through questions and then to the submit tab", () => {
assert.equal(nextTabAfterAnswer(0, 3), 1);
assert.equal(nextTabAfterAnswer(1, 3), 2);
assert.equal(nextTabAfterAnswer(2, 3), 3);
});
- Step 2: Run the tests to verify they fail
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: FAIL because allQuestionsAnswered and nextTabAfterAnswer are not exported yet.
- Step 3: Add the multi-question helper functions
Append these functions to /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs:
export function allQuestionsAnswered(questions, answers) {
return questions.every((question) => answers.has(question.id));
}
export function nextTabAfterAnswer(currentTab, questionCount) {
return currentTab < questionCount - 1 ? currentTab + 1 : questionCount;
}
- Step 4: Run the tests to verify they pass
Run:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: PASS for all seven tests.
- Step 5: Replace the single-question-only execution path with a unified question flow
In /home/alex/dotfiles/.pi/agent/extensions/question.ts, replace the helper import block with this block, then replace the runSingleQuestion() function with the runQuestionFlow() function below:
import {
allQuestionsAnswered,
createAnsweredResult,
createCancelledResult,
createCustomAnswer,
createPredefinedAnswer,
isSomethingElseOption,
nextTabAfterAnswer,
normalizeQuestions,
summarizeAnswers,
} from "./question-core.mjs";
Then replace runSingleQuestion() with:
async function runQuestionFlow(ctx: any, questions: Question[]): Promise<QuestionResult> {
return ctx.ui.custom<QuestionResult>((tui, theme, _kb, done) => {
const isMulti = questions.length > 1;
let currentTab = 0;
let optionIndex = 0;
let inputMode = false;
let cachedLines: string[] | undefined;
const answers = new Map<string, Answer>();
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
const editor = new Editor(tui, editorTheme);
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function currentQuestion(): Question | undefined {
return questions[currentTab];
}
function currentOptions(): QuestionOption[] {
return currentQuestion()?.options ?? [];
}
function finish(cancelled: boolean) {
if (cancelled) {
done(createCancelledResult(questions) as QuestionResult);
return;
}
done(createAnsweredResult(questions, Array.from(answers.values())) as QuestionResult);
}
editor.onSubmit = (value) => {
const question = currentQuestion();
const trimmed = value.trim();
if (!question || trimmed.length === 0) {
refresh();
return;
}
answers.set(question.id, createCustomAnswer(question.id, trimmed) as Answer);
inputMode = false;
editor.setText("");
if (!isMulti) {
finish(false);
return;
}
currentTab = nextTabAfterAnswer(currentTab, questions.length);
optionIndex = 0;
refresh();
};
function handleInput(data: string) {
if (inputMode) {
if (matchesKey(data, Key.escape)) {
inputMode = false;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (isMulti) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
currentTab = (currentTab + 1) % (questions.length + 1);
optionIndex = 0;
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
currentTab = (currentTab - 1 + questions.length + 1) % (questions.length + 1);
optionIndex = 0;
refresh();
return;
}
if (currentTab === questions.length) {
if (matchesKey(data, Key.enter) && allQuestionsAnswered(questions, answers)) {
finish(false);
return;
}
if (matchesKey(data, Key.escape)) {
finish(true);
return;
}
return;
}
}
const question = currentQuestion();
const options = currentOptions();
if (!question || options.length === 0) {
return;
}
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.enter)) {
const selected = options[optionIndex]!;
if (isSomethingElseOption(selected)) {
inputMode = true;
editor.setText("");
refresh();
return;
}
answers.set(question.id, createPredefinedAnswer(question.id, selected, optionIndex + 1) as Answer);
if (!isMulti) {
finish(false);
return;
}
currentTab = nextTabAfterAnswer(currentTab, questions.length);
optionIndex = 0;
refresh();
return;
}
if (matchesKey(data, Key.escape)) {
finish(true);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (line: string) => lines.push(truncateToWidth(line, width));
const question = currentQuestion();
const options = currentOptions();
add(theme.fg("accent", "─".repeat(width)));
if (isMulti) {
const tabs: string[] = [];
for (let index = 0; index < questions.length; index += 1) {
const tabQuestion = questions[index]!;
const active = index === currentTab;
const answered = answers.has(tabQuestion.id);
const box = answered ? "■" : "□";
const text = ` ${box} ${tabQuestion.label} `;
tabs.push(active ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(answered ? "success" : "muted", text));
}
const submitText = " ✓ Submit ";
const submitActive = currentTab === questions.length;
const submitReady = allQuestionsAnswered(questions, answers);
tabs.push(
submitActive
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(submitReady ? "success" : "dim", submitText),
);
add(` ${tabs.join(" ")}`);
lines.push("");
}
if (inputMode && question) {
add(theme.fg("text", ` ${question.prompt}`));
lines.push("");
for (let index = 0; index < options.length; index += 1) {
const option = options[index]!;
const prefix = index === optionIndex ? theme.fg("accent", "> ") : " ";
add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`));
if (option.description) {
add(` ${theme.fg("muted", option.description)}`);
}
}
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
} else if (isMulti && currentTab === questions.length) {
add(theme.fg("accent", theme.bold(" Ready to submit")));
lines.push("");
for (const reviewQuestion of questions) {
const answer = answers.get(reviewQuestion.id);
if (!answer) continue;
const label = answer.wasCustom ? `(wrote) ${answer.label}` : `${answer.index}. ${answer.label}`;
add(`${theme.fg("muted", ` ${reviewQuestion.label}: `)}${theme.fg("text", label)}`);
}
lines.push("");
if (allQuestionsAnswered(questions, answers)) {
add(theme.fg("success", " Press Enter to submit"));
} else {
add(theme.fg("warning", " All questions must be answered before submit"));
}
} else if (question) {
add(theme.fg("text", ` ${question.prompt}`));
lines.push("");
for (let index = 0; index < options.length; index += 1) {
const option = options[index]!;
const prefix = index === optionIndex ? theme.fg("accent", "> ") : " ";
add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`));
if (option.description) {
add(` ${theme.fg("muted", option.description)}`);
}
}
}
lines.push("");
if (inputMode) {
add(theme.fg("dim", " Enter to submit • Esc to go back"));
} else if (isMulti) {
add(theme.fg("dim", " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"));
} else {
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
}
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
});
}
Then, in the execute() method, replace this block:
const questions = normalizeQuestions(params.questions) as Question[];
if (questions.length !== 1) {
return errorResult("Error: Multi-question flow is not implemented yet", questions);
}
const result = await runSingleQuestion(ctx, questions[0]!);
with this block:
const questions = normalizeQuestions(params.questions) as Question[];
const result = await runQuestionFlow(ctx, questions);
- Step 6: Reload pi and manually verify the multi-question flow
Run:
cd /home/alex/dotfiles
pi
Inside pi, run this sequence:
/reload
Use the question tool to ask me two questions.
Question 1: Scope
- small: Small change
- medium: Medium change
Question 2: Priority
- p1: P1
- p2: P2
Expected manual checks:
- the tool UI shows tabs for both questions plus a final
Submittab - each question appends
Something else…as the last option - tab / left-right navigation moves between questions and the submit tab
- pressing
Enteron the submit tab only works after both questions have answers - choosing
Something else…for either question opens inline text entry and returns to the multi-question flow after submission - the final result renders one success line per answer in question order
Run one more manual cancel check in the same session:
Use the question tool to ask me one question with two choices.
Expected manual checks:
-
pressing
Escfrom the picker cancels the tool -
the rendered result shows
Cancelled -
Step 7: Commit the finished multi-question tool
Run:
git -C /home/alex/dotfiles add \
.pi/agent/extensions/question-core.mjs \
.pi/agent/extensions/question-core.test.mjs \
.pi/agent/extensions/question.ts \
docs/superpowers/plans/2026-04-09-question-tool.md
git -C /home/alex/dotfiles commit -m "feat: add multi-question pi question tool"
Final Verification Checklist
Run these before claiming the work is complete:
cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs
Expected: PASS for all tests.
Then run pi from /home/alex/dotfiles, use /reload, and manually verify:
- one predefined answer
- one custom
Something else…answer - multi-question predefined flow
- multi-question mixed predefined/custom flow
- submit-tab blocking until all answers exist
- cancel from picker
Escfrom text entry returns to options
If any of those checks fail, do not mark the work complete.