1115 lines
32 KiB
Markdown
1115 lines
32 KiB
Markdown
# 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<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.
|