Files
dotfiles/docs/superpowers/plans/2026-04-09-question-tool.md
alex wiesner 18245c778e changes
2026-04-09 11:31:06 +01:00

1115 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 Nodes 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<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:
```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<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:
```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.