# 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. - Create: `/home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs` - Node `node:test` coverage for the pure helper module. - Create: `/home/alex/dotfiles/.pi/agent/extensions/question.ts` - The live pi extension that registers the `question` tool and renders the single-question / multi-question custom UI. - Modify: `/home/alex/dotfiles/docs/superpowers/plans/2026-04-09-question-tool.md` - Check boxes as work progresses. ## Environment Notes - `/home/alex/.pi/agent/extensions` already 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 `/reload` inside 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: ```js 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: ```bash 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: ```js 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: ```bash 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: ```bash 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: ```js 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: ```js 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: ```bash 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`: ```js 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: ```bash 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: ```ts 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 { const result = await ctx.ui.custom((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: ```bash cd /home/alex/dotfiles pi ``` Inside pi, run this sequence: ```text /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 `Esc` in text entry returns to the option list - selecting `stable` returns a successful tool result with a single summary line for the chosen answer - [ ] **Step 7: Commit the single-question extension** Run: ```bash 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: ```js 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: ```js 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: ```bash 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`: ```js 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: ```bash 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: ```ts import { allQuestionsAnswered, createAnsweredResult, createCancelledResult, createCustomAnswer, createPredefinedAnswer, isSomethingElseOption, nextTabAfterAnswer, normalizeQuestions, summarizeAnswers, } from "./question-core.mjs"; ``` Then replace `runSingleQuestion()` with: ```ts async function runQuestionFlow(ctx: any, questions: Question[]): Promise { return ctx.ui.custom((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(); 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: ```ts 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: ```ts 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: ```bash cd /home/alex/dotfiles pi ``` Inside pi, run this sequence: ```text /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 `Submit` tab - each question appends `Something else…` as the last option - tab / left-right navigation moves between questions and the submit tab - pressing `Enter` on 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: ```text Use the question tool to ask me one question with two choices. ``` Expected manual checks: - pressing `Esc` from the picker cancels the tool - the rendered result shows `Cancelled` - [ ] **Step 7: Commit the finished multi-question tool** Run: ```bash 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: ```bash 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: 1. one predefined answer 2. one custom `Something else…` answer 3. multi-question predefined flow 4. multi-question mixed predefined/custom flow 5. submit-tab blocking until all answers exist 6. cancel from picker 7. `Esc` from text entry returns to options If any of those checks fail, do not mark the work complete.