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

32 KiB
Raw Blame History

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:

import test from "node:test";
import assert from "node:assert/strict";
import {
  SOMETHING_ELSE_LABEL,
  SOMETHING_ELSE_VALUE,
  createCustomAnswer,
  createPredefinedAnswer,
  normalizeQuestions,
  summarizeAnswers,
} from "./question-core.mjs";

test("normalizeQuestions adds default labels and appends the Something else option", () => {
  const [question] = normalizeQuestions([
    {
      id: "scope",
      prompt: "Which scope fits best?",
      options: [{ value: "small", label: "Small change" }],
    },
  ]);

  assert.equal(question.label, "Q1");
  assert.deepEqual(question.options[0], { value: "small", label: "Small change" });
  assert.deepEqual(question.options.at(-1), {
    value: SOMETHING_ELSE_VALUE,
    label: SOMETHING_ELSE_LABEL,
  });
});

test("normalizeQuestions keeps provided labels and descriptions intact before the synthetic option", () => {
  const [question] = normalizeQuestions([
    {
      id: "priority",
      label: "Priority",
      prompt: "Which priority?",
      options: [
        { value: "p0", label: "P0", description: "Need this now" },
        { value: "p1", label: "P1" },
      ],
    },
  ]);

  assert.equal(question.label, "Priority");
  assert.deepEqual(question.options.slice(0, 2), [
    { value: "p0", label: "P0", description: "Need this now" },
    { value: "p1", label: "P1" },
  ]);
  assert.equal(question.options[2].label, SOMETHING_ELSE_LABEL);
});

test("answer helpers preserve machine values and summary lines distinguish predefined vs custom answers", () => {
  const questions = normalizeQuestions([
    {
      id: "scope",
      label: "Scope",
      prompt: "Which scope fits best?",
      options: [{ value: "small", label: "Small change" }],
    },
    {
      id: "notes",
      label: "Notes",
      prompt: "Anything else?",
      options: [{ value: "none", label: "No extra notes" }],
    },
  ]);

  const predefined = createPredefinedAnswer("scope", questions[0].options[0], 1);
  const custom = createCustomAnswer("notes", "Needs to work with tmux");

  assert.deepEqual(predefined, {
    id: "scope",
    value: "small",
    label: "Small change",
    wasCustom: false,
    index: 1,
  });
  assert.deepEqual(custom, {
    id: "notes",
    value: "Needs to work with tmux",
    label: "Needs to work with tmux",
    wasCustom: true,
  });
  assert.deepEqual(summarizeAnswers(questions, [predefined, custom]), [
    "Scope: user selected: 1. Small change",
    "Notes: user wrote: Needs to work with tmux",
  ]);
});
  • Step 2: Run the test to verify it fails

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: FAIL with an ERR_MODULE_NOT_FOUND error for ./question-core.mjs.

  • Step 3: Write the minimal helper implementation

Create /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs with this content:

export const SOMETHING_ELSE_VALUE = "__something_else__";
export const SOMETHING_ELSE_LABEL = "Something else…";

export function normalizeQuestions(inputQuestions) {
  return inputQuestions.map((question, index) => ({
    ...question,
    label: question.label?.trim() ? question.label : `Q${index + 1}`,
    options: [
      ...question.options,
      {
        value: SOMETHING_ELSE_VALUE,
        label: SOMETHING_ELSE_LABEL,
      },
    ],
  }));
}

export function isSomethingElseOption(option) {
  return option?.value === SOMETHING_ELSE_VALUE;
}

export function createPredefinedAnswer(questionId, option, index) {
  return {
    id: questionId,
    value: option.value,
    label: option.label,
    wasCustom: false,
    index,
  };
}

export function createCustomAnswer(questionId, text) {
  return {
    id: questionId,
    value: text,
    label: text,
    wasCustom: true,
  };
}

export function summarizeAnswers(questions, answers) {
  const answerById = new Map(answers.map((answer) => [answer.id, answer]));
  return questions.flatMap((question) => {
    const answer = answerById.get(question.id);
    if (!answer) return [];
    if (answer.wasCustom) {
      return [`${question.label}: user wrote: ${answer.label}`];
    }
    return [`${question.label}: user selected: ${answer.index}. ${answer.label}`];
  });
}
  • Step 4: Run the test to verify it passes

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: PASS for all three tests.

  • Step 5: Commit the helper module

Run:

git -C /home/alex/dotfiles add \
  .pi/agent/extensions/question-core.mjs \
  .pi/agent/extensions/question-core.test.mjs \
  docs/superpowers/plans/2026-04-09-question-tool.md

git -C /home/alex/dotfiles commit -m "test: add question helper module"

Task 2: Add the single-question extension shell and structured results

Files:

  • Modify: /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs

  • Modify: /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs

  • Create: /home/alex/dotfiles/.pi/agent/extensions/question.ts

  • Test: /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs

  • Step 1: Add failing tests for cancelled and sorted answered results

Replace the import block in /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs with this block, then append the new tests below it:

import test from "node:test";
import assert from "node:assert/strict";
import {
  SOMETHING_ELSE_LABEL,
  SOMETHING_ELSE_VALUE,
  createAnsweredResult,
  createCancelledResult,
  createCustomAnswer,
  createPredefinedAnswer,
  normalizeQuestions,
  summarizeAnswers,
} from "./question-core.mjs";

Append these tests:

test("createCancelledResult returns a structured cancelled payload", () => {
  const questions = normalizeQuestions([
    {
      id: "scope",
      prompt: "Which scope fits best?",
      options: [{ value: "small", label: "Small change" }],
    },
  ]);

  assert.deepEqual(createCancelledResult(questions), {
    questions,
    answers: [],
    cancelled: true,
  });
});

test("createAnsweredResult keeps answers in question order", () => {
  const questions = normalizeQuestions([
    {
      id: "scope",
      label: "Scope",
      prompt: "Which scope fits best?",
      options: [{ value: "small", label: "Small change" }],
    },
    {
      id: "notes",
      label: "Notes",
      prompt: "Anything else?",
      options: [{ value: "none", label: "No extra notes" }],
    },
  ]);

  const second = createCustomAnswer("notes", "Custom note");
  const first = createPredefinedAnswer("scope", questions[0].options[0], 1);
  const result = createAnsweredResult(questions, [second, first]);

  assert.equal(result.cancelled, false);
  assert.deepEqual(result.answers.map((answer) => answer.id), ["scope", "notes"]);
});
  • Step 2: Run the tests to verify they fail

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: FAIL because createAnsweredResult and createCancelledResult are not exported yet.

  • Step 3: Add the minimal result helpers

Append these functions to /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs:

export function createCancelledResult(questions = []) {
  return {
    questions,
    answers: [],
    cancelled: true,
  };
}

export function createAnsweredResult(questions, answers) {
  const order = new Map(questions.map((question, index) => [question.id, index]));
  return {
    questions,
    answers: [...answers].sort(
      (left, right) => (order.get(left.id) ?? Number.POSITIVE_INFINITY) - (order.get(right.id) ?? Number.POSITIVE_INFINITY),
    ),
    cancelled: false,
  };
}
  • Step 4: Run the tests to verify they pass

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: PASS for all five tests.

  • Step 5: Create the first live extension version with single-question support

Create /home/alex/dotfiles/.pi/agent/extensions/question.ts with this content:

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import {
  createAnsweredResult,
  createCancelledResult,
  createCustomAnswer,
  createPredefinedAnswer,
  isSomethingElseOption,
  normalizeQuestions,
  summarizeAnswers,
} from "./question-core.mjs";

interface QuestionOption {
  value: string;
  label: string;
  description?: string;
}

interface Question {
  id: string;
  label: string;
  prompt: string;
  options: QuestionOption[];
}

interface Answer {
  id: string;
  value: string;
  label: string;
  wasCustom: boolean;
  index?: number;
}

interface QuestionResult {
  questions: Question[];
  answers: Answer[];
  cancelled: boolean;
}

const OptionSchema = Type.Object({
  value: Type.String({ description: "Machine-friendly value returned to the model" }),
  label: Type.String({ description: "Human-friendly label shown in the UI" }),
  description: Type.Optional(Type.String({ description: "Optional help text shown under the label" })),
});

const QuestionSchema = Type.Object({
  id: Type.String({ description: "Stable identifier for the answer" }),
  label: Type.Optional(Type.String({ description: "Short label for summaries and tabs" })),
  prompt: Type.String({ description: "Full question text shown to the user" }),
  options: Type.Array(OptionSchema, { description: "Predefined options for the user to choose from" }),
});

const QuestionParams = Type.Object({
  questions: Type.Array(QuestionSchema, { description: "One or more questions to ask the user" }),
});

function errorResult(message: string, questions: Question[] = []) {
  return {
    content: [{ type: "text" as const, text: message }],
    details: createCancelledResult(questions) as QuestionResult,
  };
}

async function runSingleQuestion(ctx: any, question: Question): Promise<QuestionResult> {
  const result = await ctx.ui.custom<Answer | null>((tui, theme, _kb, done) => {
    let optionIndex = 0;
    let inputMode = false;
    let cachedLines: string[] | undefined;

    const editorTheme: EditorTheme = {
      borderColor: (text) => theme.fg("accent", text),
      selectList: {
        selectedPrefix: (text) => theme.fg("accent", text),
        selectedText: (text) => theme.fg("accent", text),
        description: (text) => theme.fg("muted", text),
        scrollInfo: (text) => theme.fg("dim", text),
        noMatch: (text) => theme.fg("warning", text),
      },
    };

    const editor = new Editor(tui, editorTheme);
    editor.onSubmit = (value) => {
      const trimmed = value.trim();
      if (trimmed.length === 0) {
        cachedLines = undefined;
        tui.requestRender();
        return;
      }
      done(createCustomAnswer(question.id, trimmed) as Answer);
    };

    function refresh() {
      cachedLines = undefined;
      tui.requestRender();
    }

    function handleInput(data: string) {
      if (inputMode) {
        if (matchesKey(data, Key.escape)) {
          inputMode = false;
          editor.setText("");
          refresh();
          return;
        }
        editor.handleInput(data);
        refresh();
        return;
      }

      if (matchesKey(data, Key.up)) {
        optionIndex = Math.max(0, optionIndex - 1);
        refresh();
        return;
      }

      if (matchesKey(data, Key.down)) {
        optionIndex = Math.min(question.options.length - 1, optionIndex + 1);
        refresh();
        return;
      }

      if (matchesKey(data, Key.enter)) {
        const selected = question.options[optionIndex]!;
        if (isSomethingElseOption(selected)) {
          inputMode = true;
          editor.setText("");
          refresh();
          return;
        }
        done(createPredefinedAnswer(question.id, selected, optionIndex + 1) as Answer);
        return;
      }

      if (matchesKey(data, Key.escape)) {
        done(null);
      }
    }

    function render(width: number): string[] {
      if (cachedLines) return cachedLines;

      const lines: string[] = [];
      const add = (line: string) => lines.push(truncateToWidth(line, width));

      add(theme.fg("accent", "─".repeat(width)));
      add(theme.fg("text", ` ${question.prompt}`));
      lines.push("");

      for (let index = 0; index < question.options.length; index += 1) {
        const option = question.options[index]!;
        const isSelected = index === optionIndex;
        const prefix = isSelected ? theme.fg("accent", "> ") : "  ";
        const label = `${index + 1}. ${option.label}`;
        add(prefix + (isSelected ? theme.fg("accent", label) : theme.fg("text", label)));
        if (option.description) {
          add(`     ${theme.fg("muted", option.description)}`);
        }
      }

      if (inputMode) {
        lines.push("");
        add(theme.fg("muted", " Your answer:"));
        for (const line of editor.render(width - 2)) {
          add(` ${line}`);
        }
      }

      lines.push("");
      add(theme.fg("dim", inputMode ? " Enter to submit • Esc to go back" : " ↑↓ navigate • Enter select • Esc cancel"));
      add(theme.fg("accent", "─".repeat(width)));

      cachedLines = lines;
      return lines;
    }

    return {
      render,
      invalidate: () => {
        cachedLines = undefined;
      },
      handleInput,
    };
  });

  if (!result) {
    return createCancelledResult([question]) as QuestionResult;
  }

  return createAnsweredResult([question], [result]) as QuestionResult;
}

export default function question(pi: ExtensionAPI) {
  pi.registerTool({
    name: "question",
    label: "Question",
    description:
      "Ask the user one or more multiple-choice questions. Every question automatically gets a final Something else… option for free-text answers.",
    parameters: QuestionParams,

    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
      if (!ctx.hasUI) {
        return errorResult("Error: UI not available (running in non-interactive mode)");
      }

      if (params.questions.length === 0) {
        return errorResult("Error: No questions provided");
      }

      const questions = normalizeQuestions(params.questions) as Question[];
      if (questions.length !== 1) {
        return errorResult("Error: Multi-question flow is not implemented yet", questions);
      }

      const result = await runSingleQuestion(ctx, questions[0]!);
      if (result.cancelled) {
        return {
          content: [{ type: "text", text: "User cancelled the question flow" }],
          details: result,
        };
      }

      return {
        content: [{ type: "text", text: summarizeAnswers(result.questions, result.answers).join("\n") }],
        details: result,
      };
    },

    renderCall(args, theme) {
      const questions = Array.isArray(args.questions) ? args.questions : [];
      const labels = questions
        .map((question: { label?: string; id?: string }) => question.label || question.id)
        .filter(Boolean)
        .join(", ");

      let text = theme.fg("toolTitle", theme.bold("question "));
      text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
      if (labels) {
        text += theme.fg("dim", ` (${labels})`);
      }
      return new Text(text, 0, 0);
    },

    renderResult(result, _options, theme) {
      const details = result.details as QuestionResult | undefined;
      if (!details) {
        const first = result.content[0];
        return new Text(first?.type === "text" ? first.text : "", 0, 0);
      }

      if (details.cancelled) {
        return new Text(theme.fg("warning", "Cancelled"), 0, 0);
      }

      const lines = summarizeAnswers(details.questions, details.answers).map(
        (line) => `${theme.fg("success", "✓ ")}${line}`,
      );
      return new Text(lines.join("\n"), 0, 0);
    },
  });
}
  • Step 6: Reload pi and manually verify the single-question path

Run:

cd /home/alex/dotfiles
pi

Inside pi, run this sequence:

/reload
Use the question tool to ask me one question with these options:
- stable: Stable release
- nightly: Nightly release

Expected manual checks:

  • the tool row appears as question 1 question

  • the UI shows three options, with Something else… appended last

  • selecting Something else… opens inline text entry

  • pressing 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:

git -C /home/alex/dotfiles add \
  .pi/agent/extensions/question-core.mjs \
  .pi/agent/extensions/question-core.test.mjs \
  .pi/agent/extensions/question.ts \
  docs/superpowers/plans/2026-04-09-question-tool.md

git -C /home/alex/dotfiles commit -m "feat: add single-question pi question tool"

Task 3: Extend the tool to multi-question review and submit flow

Files:

  • Modify: /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs

  • Modify: /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs

  • Modify: /home/alex/dotfiles/.pi/agent/extensions/question.ts

  • Test: /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs

  • Step 1: Add failing tests for multi-question flow helpers

Replace the import block in /home/alex/dotfiles/.pi/agent/extensions/question-core.test.mjs with this block, then append the new tests below it:

import test from "node:test";
import assert from "node:assert/strict";
import {
  SOMETHING_ELSE_LABEL,
  SOMETHING_ELSE_VALUE,
  allQuestionsAnswered,
  createAnsweredResult,
  createCancelledResult,
  createCustomAnswer,
  createPredefinedAnswer,
  nextTabAfterAnswer,
  normalizeQuestions,
  summarizeAnswers,
} from "./question-core.mjs";

Append these tests:

test("allQuestionsAnswered only returns true when every question has an answer", () => {
  const questions = normalizeQuestions([
    {
      id: "scope",
      prompt: "Scope?",
      options: [{ value: "small", label: "Small" }],
    },
    {
      id: "priority",
      prompt: "Priority?",
      options: [{ value: "p1", label: "P1" }],
    },
  ]);

  const answers = new Map([
    ["scope", createPredefinedAnswer("scope", questions[0].options[0], 1)],
  ]);

  assert.equal(allQuestionsAnswered(questions, answers), false);
  answers.set("priority", createCustomAnswer("priority", "Ship this week"));
  assert.equal(allQuestionsAnswered(questions, answers), true);
});

test("nextTabAfterAnswer advances through questions and then to the submit tab", () => {
  assert.equal(nextTabAfterAnswer(0, 3), 1);
  assert.equal(nextTabAfterAnswer(1, 3), 2);
  assert.equal(nextTabAfterAnswer(2, 3), 3);
});
  • Step 2: Run the tests to verify they fail

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: FAIL because allQuestionsAnswered and nextTabAfterAnswer are not exported yet.

  • Step 3: Add the multi-question helper functions

Append these functions to /home/alex/dotfiles/.pi/agent/extensions/question-core.mjs:

export function allQuestionsAnswered(questions, answers) {
  return questions.every((question) => answers.has(question.id));
}

export function nextTabAfterAnswer(currentTab, questionCount) {
  return currentTab < questionCount - 1 ? currentTab + 1 : questionCount;
}
  • Step 4: Run the tests to verify they pass

Run:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: PASS for all seven tests.

  • Step 5: Replace the single-question-only execution path with a unified question flow

In /home/alex/dotfiles/.pi/agent/extensions/question.ts, replace the helper import block with this block, then replace the runSingleQuestion() function with the runQuestionFlow() function below:

import {
  allQuestionsAnswered,
  createAnsweredResult,
  createCancelledResult,
  createCustomAnswer,
  createPredefinedAnswer,
  isSomethingElseOption,
  nextTabAfterAnswer,
  normalizeQuestions,
  summarizeAnswers,
} from "./question-core.mjs";

Then replace runSingleQuestion() with:

async function runQuestionFlow(ctx: any, questions: Question[]): Promise<QuestionResult> {
  return ctx.ui.custom<QuestionResult>((tui, theme, _kb, done) => {
    const isMulti = questions.length > 1;
    let currentTab = 0;
    let optionIndex = 0;
    let inputMode = false;
    let cachedLines: string[] | undefined;
    const answers = new Map<string, Answer>();

    const editorTheme: EditorTheme = {
      borderColor: (text) => theme.fg("accent", text),
      selectList: {
        selectedPrefix: (text) => theme.fg("accent", text),
        selectedText: (text) => theme.fg("accent", text),
        description: (text) => theme.fg("muted", text),
        scrollInfo: (text) => theme.fg("dim", text),
        noMatch: (text) => theme.fg("warning", text),
      },
    };

    const editor = new Editor(tui, editorTheme);

    function refresh() {
      cachedLines = undefined;
      tui.requestRender();
    }

    function currentQuestion(): Question | undefined {
      return questions[currentTab];
    }

    function currentOptions(): QuestionOption[] {
      return currentQuestion()?.options ?? [];
    }

    function finish(cancelled: boolean) {
      if (cancelled) {
        done(createCancelledResult(questions) as QuestionResult);
        return;
      }
      done(createAnsweredResult(questions, Array.from(answers.values())) as QuestionResult);
    }

    editor.onSubmit = (value) => {
      const question = currentQuestion();
      const trimmed = value.trim();
      if (!question || trimmed.length === 0) {
        refresh();
        return;
      }

      answers.set(question.id, createCustomAnswer(question.id, trimmed) as Answer);
      inputMode = false;
      editor.setText("");

      if (!isMulti) {
        finish(false);
        return;
      }

      currentTab = nextTabAfterAnswer(currentTab, questions.length);
      optionIndex = 0;
      refresh();
    };

    function handleInput(data: string) {
      if (inputMode) {
        if (matchesKey(data, Key.escape)) {
          inputMode = false;
          editor.setText("");
          refresh();
          return;
        }
        editor.handleInput(data);
        refresh();
        return;
      }

      if (isMulti) {
        if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
          currentTab = (currentTab + 1) % (questions.length + 1);
          optionIndex = 0;
          refresh();
          return;
        }
        if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
          currentTab = (currentTab - 1 + questions.length + 1) % (questions.length + 1);
          optionIndex = 0;
          refresh();
          return;
        }
        if (currentTab === questions.length) {
          if (matchesKey(data, Key.enter) && allQuestionsAnswered(questions, answers)) {
            finish(false);
            return;
          }
          if (matchesKey(data, Key.escape)) {
            finish(true);
            return;
          }
          return;
        }
      }

      const question = currentQuestion();
      const options = currentOptions();
      if (!question || options.length === 0) {
        return;
      }

      if (matchesKey(data, Key.up)) {
        optionIndex = Math.max(0, optionIndex - 1);
        refresh();
        return;
      }

      if (matchesKey(data, Key.down)) {
        optionIndex = Math.min(options.length - 1, optionIndex + 1);
        refresh();
        return;
      }

      if (matchesKey(data, Key.enter)) {
        const selected = options[optionIndex]!;
        if (isSomethingElseOption(selected)) {
          inputMode = true;
          editor.setText("");
          refresh();
          return;
        }

        answers.set(question.id, createPredefinedAnswer(question.id, selected, optionIndex + 1) as Answer);
        if (!isMulti) {
          finish(false);
          return;
        }

        currentTab = nextTabAfterAnswer(currentTab, questions.length);
        optionIndex = 0;
        refresh();
        return;
      }

      if (matchesKey(data, Key.escape)) {
        finish(true);
      }
    }

    function render(width: number): string[] {
      if (cachedLines) return cachedLines;

      const lines: string[] = [];
      const add = (line: string) => lines.push(truncateToWidth(line, width));
      const question = currentQuestion();
      const options = currentOptions();

      add(theme.fg("accent", "─".repeat(width)));

      if (isMulti) {
        const tabs: string[] = [];
        for (let index = 0; index < questions.length; index += 1) {
          const tabQuestion = questions[index]!;
          const active = index === currentTab;
          const answered = answers.has(tabQuestion.id);
          const box = answered ? "■" : "□";
          const text = ` ${box} ${tabQuestion.label} `;
          tabs.push(active ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(answered ? "success" : "muted", text));
        }

        const submitText = " ✓ Submit ";
        const submitActive = currentTab === questions.length;
        const submitReady = allQuestionsAnswered(questions, answers);
        tabs.push(
          submitActive
            ? theme.bg("selectedBg", theme.fg("text", submitText))
            : theme.fg(submitReady ? "success" : "dim", submitText),
        );

        add(` ${tabs.join(" ")}`);
        lines.push("");
      }

      if (inputMode && question) {
        add(theme.fg("text", ` ${question.prompt}`));
        lines.push("");
        for (let index = 0; index < options.length; index += 1) {
          const option = options[index]!;
          const prefix = index === optionIndex ? theme.fg("accent", "> ") : "  ";
          add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`));
          if (option.description) {
            add(`     ${theme.fg("muted", option.description)}`);
          }
        }
        lines.push("");
        add(theme.fg("muted", " Your answer:"));
        for (const line of editor.render(width - 2)) {
          add(` ${line}`);
        }
      } else if (isMulti && currentTab === questions.length) {
        add(theme.fg("accent", theme.bold(" Ready to submit")));
        lines.push("");
        for (const reviewQuestion of questions) {
          const answer = answers.get(reviewQuestion.id);
          if (!answer) continue;
          const label = answer.wasCustom ? `(wrote) ${answer.label}` : `${answer.index}. ${answer.label}`;
          add(`${theme.fg("muted", ` ${reviewQuestion.label}: `)}${theme.fg("text", label)}`);
        }
        lines.push("");
        if (allQuestionsAnswered(questions, answers)) {
          add(theme.fg("success", " Press Enter to submit"));
        } else {
          add(theme.fg("warning", " All questions must be answered before submit"));
        }
      } else if (question) {
        add(theme.fg("text", ` ${question.prompt}`));
        lines.push("");
        for (let index = 0; index < options.length; index += 1) {
          const option = options[index]!;
          const prefix = index === optionIndex ? theme.fg("accent", "> ") : "  ";
          add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`));
          if (option.description) {
            add(`     ${theme.fg("muted", option.description)}`);
          }
        }
      }

      lines.push("");
      if (inputMode) {
        add(theme.fg("dim", " Enter to submit • Esc to go back"));
      } else if (isMulti) {
        add(theme.fg("dim", " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"));
      } else {
        add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
      }
      add(theme.fg("accent", "─".repeat(width)));

      cachedLines = lines;
      return lines;
    }

    return {
      render,
      invalidate: () => {
        cachedLines = undefined;
      },
      handleInput,
    };
  });
}

Then, in the execute() method, replace this block:

const questions = normalizeQuestions(params.questions) as Question[];
if (questions.length !== 1) {
  return errorResult("Error: Multi-question flow is not implemented yet", questions);
}

const result = await runSingleQuestion(ctx, questions[0]!);

with this block:

const questions = normalizeQuestions(params.questions) as Question[];
const result = await runQuestionFlow(ctx, questions);
  • Step 6: Reload pi and manually verify the multi-question flow

Run:

cd /home/alex/dotfiles
pi

Inside pi, run this sequence:

/reload
Use the question tool to ask me two questions.
Question 1: Scope
- small: Small change
- medium: Medium change
Question 2: Priority
- p1: P1
- p2: P2

Expected manual checks:

  • the tool UI shows tabs for both questions plus a final 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:

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:

git -C /home/alex/dotfiles add \
  .pi/agent/extensions/question-core.mjs \
  .pi/agent/extensions/question-core.test.mjs \
  .pi/agent/extensions/question.ts \
  docs/superpowers/plans/2026-04-09-question-tool.md

git -C /home/alex/dotfiles commit -m "feat: add multi-question pi question tool"

Final Verification Checklist

Run these before claiming the work is complete:

cd /home/alex/dotfiles
node --test .pi/agent/extensions/question-core.test.mjs

Expected: PASS for all tests.

Then run pi from /home/alex/dotfiles, use /reload, and manually verify:

  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.