@@ -0,0 +1,540 @@
+// SPDX-FileCopyrightText: Mario Zechner <https://mariozechner.at>
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * Questionnaire Tool - Unified tool for asking single or multiple questions
+ *
+ * Single question: simple options list
+ * Multiple questions: tab bar navigation between questions
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import {
+ Editor,
+ type EditorTheme,
+ Key,
+ matchesKey,
+ Text,
+ truncateToWidth,
+ wrapTextWithAnsi,
+} from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+
+// Types
+interface QuestionOption {
+ value: string;
+ label: string;
+ description?: string;
+}
+
+type RenderOption = QuestionOption & { isOther?: boolean };
+
+interface Question {
+ id: string;
+ label: string;
+ prompt: string;
+ options: QuestionOption[];
+ allowOther: boolean;
+}
+
+interface Answer {
+ id: string;
+ value: string;
+ label: string;
+ wasCustom: boolean;
+ index?: number;
+}
+
+interface QuestionnaireResult {
+ questions: Question[];
+ answers: Answer[];
+ cancelled: boolean;
+}
+
+// Schema
+const QuestionOptionSchema = Type.Object({
+ value: Type.String({ description: "The value returned when selected" }),
+ label: Type.String({ description: "Display label for the option" }),
+ description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
+});
+
+const QuestionSchema = Type.Object({
+ id: Type.String({ description: "Unique identifier for this question" }),
+ label: Type.Optional(
+ Type.String({
+ description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
+ }),
+ ),
+ prompt: Type.String({ description: "The full question text to display" }),
+ options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
+});
+
+const QuestionnaireParams = Type.Object({
+ questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
+});
+
+function errorResult(
+ message: string,
+ questions: Question[] = [],
+): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
+ return {
+ content: [{ type: "text", text: message }],
+ details: { questions, answers: [], cancelled: true },
+ };
+}
+
+export default function questionnaire(pi: ExtensionAPI) {
+ pi.registerTool({
+ name: "questionnaire",
+ label: "Questionnaire",
+ description:
+ "Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface. Users can always add a free-text note alongside their answers.",
+ parameters: QuestionnaireParams,
+
+ 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");
+ }
+
+ // Normalize questions with defaults
+ const questions: Question[] = params.questions.map((q, i) => ({
+ ...q,
+ label: q.label || `Q${i + 1}`,
+ allowOther: q.allowOther !== false,
+ }));
+
+ const isMulti = questions.length > 1;
+ const totalTabs = questions.length + 1; // questions + Submit
+
+ const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
+ // State
+ let currentTab = 0;
+ let optionIndex = 0;
+ let inputMode = false;
+ let inputQuestionId: string | null = null;
+ let cachedLines: string[] | undefined;
+ const answers = new Map<string, Answer>();
+
+ // Editor for "Type something" option
+ const editorTheme: EditorTheme = {
+ borderColor: (s) => theme.fg("accent", s),
+ selectList: {
+ selectedPrefix: (t) => theme.fg("accent", t),
+ selectedText: (t) => theme.fg("accent", t),
+ description: (t) => theme.fg("muted", t),
+ scrollInfo: (t) => theme.fg("dim", t),
+ noMatch: (t) => theme.fg("warning", t),
+ },
+ };
+ const editor = new Editor(tui, editorTheme);
+
+ // Helpers
+ function refresh() {
+ cachedLines = undefined;
+ tui.requestRender();
+ }
+
+ function submit(cancelled: boolean) {
+ const allAnswers = Array.from(answers.values());
+ if (!cancelled && notesText) {
+ allAnswers.push({ id: "_note", value: notesText, label: notesText, wasCustom: true });
+ }
+ done({ questions, answers: allAnswers, cancelled });
+ }
+
+ function currentQuestion(): Question | undefined {
+ return questions[currentTab];
+ }
+
+ function currentOptions(): RenderOption[] {
+ const q = currentQuestion();
+ if (!q) return [];
+ const opts: RenderOption[] = [...q.options];
+ if (q.allowOther) {
+ opts.push({ value: "__other__", label: "Type something.", isOther: true });
+ }
+ return opts;
+ }
+
+ function allAnswered(): boolean {
+ return questions.every((q) => answers.has(q.id));
+ }
+
+ function advanceAfterAnswer() {
+ if (!isMulti) {
+ submit(false);
+ return;
+ }
+ if (currentTab < questions.length - 1) {
+ currentTab++;
+ } else {
+ currentTab = questions.length; // Submit tab
+ }
+ optionIndex = 0;
+ refresh();
+ }
+
+ function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
+ answers.set(questionId, { id: questionId, value, label, wasCustom, index });
+ }
+
+ // Editor submit callback
+ editor.onSubmit = (value) => {
+ if (!inputQuestionId) return;
+ const trimmed = value.trim() || "(no response)";
+ saveAnswer(inputQuestionId, trimmed, trimmed, true);
+ inputMode = false;
+ inputQuestionId = null;
+ editor.setText("");
+ advanceAfterAnswer();
+ };
+
+ // Free-text notes (always available via 'n' key)
+ let notesMode = false;
+ let notesText = "";
+ const notesEditor = new Editor(tui, editorTheme);
+ notesEditor.onSubmit = (value) => {
+ notesText = value.trim();
+ notesMode = false;
+ refresh();
+ };
+
+ function handleInput(data: string) {
+ // Input mode: route to editor
+ if (inputMode) {
+ if (matchesKey(data, Key.escape)) {
+ inputMode = false;
+ inputQuestionId = null;
+ editor.setText("");
+ refresh();
+ return;
+ }
+ editor.handleInput(data);
+ refresh();
+ return;
+ }
+
+ // Notes mode: route to notes editor
+ if (notesMode) {
+ if (matchesKey(data, Key.escape)) {
+ notesMode = false;
+ notesEditor.setText(notesText);
+ refresh();
+ return;
+ }
+ notesEditor.handleInput(data);
+ refresh();
+ return;
+ }
+
+ // Activate notes editor (available on any tab)
+ if (data === "n" || data === "N") {
+ notesMode = true;
+ notesEditor.setText(notesText);
+ refresh();
+ return;
+ }
+
+ const q = currentQuestion();
+ const opts = currentOptions();
+
+ // Tab navigation (multi-question only)
+ if (isMulti) {
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
+ currentTab = (currentTab + 1) % totalTabs;
+ optionIndex = 0;
+ refresh();
+ return;
+ }
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
+ currentTab = (currentTab - 1 + totalTabs) % totalTabs;
+ optionIndex = 0;
+ refresh();
+ return;
+ }
+ }
+
+ // Submit tab
+ if (currentTab === questions.length) {
+ if (matchesKey(data, Key.enter) && allAnswered()) {
+ submit(false);
+ } else if (matchesKey(data, Key.escape)) {
+ submit(true);
+ }
+ return;
+ }
+
+ // Option navigation
+ if (matchesKey(data, Key.up)) {
+ optionIndex = Math.max(0, optionIndex - 1);
+ refresh();
+ return;
+ }
+ if (matchesKey(data, Key.down)) {
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
+ refresh();
+ return;
+ }
+
+ // Select option
+ if (matchesKey(data, Key.enter) && q) {
+ const opt = opts[optionIndex];
+ if (opt.isOther) {
+ inputMode = true;
+ inputQuestionId = q.id;
+ editor.setText("");
+ refresh();
+ return;
+ }
+ saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
+ advanceAfterAnswer();
+ return;
+ }
+
+ // Cancel
+ if (matchesKey(data, Key.escape)) {
+ submit(true);
+ }
+ }
+
+ function render(width: number): string[] {
+ if (cachedLines) return cachedLines;
+
+ const lines: string[] = [];
+ const q = currentQuestion();
+ const opts = currentOptions();
+
+ // Helper to add truncated line
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
+
+ add(theme.fg("accent", "─".repeat(width)));
+
+ // Tab bar (multi-question only)
+ if (isMulti) {
+ const tabs: string[] = ["← "];
+ for (let i = 0; i < questions.length; i++) {
+ const isActive = i === currentTab;
+ const isAnswered = answers.has(questions[i].id);
+ const lbl = questions[i].label;
+ const box = isAnswered ? "■" : "□";
+ const color = isAnswered ? "success" : "muted";
+ const text = ` ${box} ${lbl} `;
+ const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
+ tabs.push(`${styled} `);
+ }
+ const canSubmit = allAnswered();
+ const isSubmitTab = currentTab === questions.length;
+ const submitText = " ✓ Submit ";
+ const submitStyled = isSubmitTab
+ ? theme.bg("selectedBg", theme.fg("text", submitText))
+ : theme.fg(canSubmit ? "success" : "dim", submitText);
+ tabs.push(`${submitStyled} →`);
+ add(` ${tabs.join("")}`);
+ lines.push("");
+ }
+
+ // Helper to render options list
+ function renderOptions() {
+ for (let i = 0; i < opts.length; i++) {
+ const opt = opts[i];
+ const selected = i === optionIndex;
+ const isOther = opt.isOther === true;
+ const prefix = selected ? theme.fg("accent", "> ") : " ";
+ const color = selected ? "accent" : "text";
+ // Mark "Type something" differently when in input mode
+ if (isOther && inputMode) {
+ add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
+ } else {
+ add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
+ }
+ if (opt.description) {
+ add(` ${theme.fg("muted", opt.description)}`);
+ }
+ }
+ }
+
+ // Helper to add word-wrapped lines (for long prompts)
+ const addWrapped = (s: string) => {
+ for (const line of wrapTextWithAnsi(s, width)) {
+ lines.push(line);
+ }
+ };
+
+ // Content
+ if (inputMode && q) {
+ addWrapped(theme.fg("text", ` ${q.prompt}`));
+ lines.push("");
+ // Show options for reference
+ renderOptions();
+ lines.push("");
+ add(theme.fg("muted", " Your answer:"));
+ for (const line of editor.render(width - 2)) {
+ add(` ${line}`);
+ }
+ lines.push("");
+ add(theme.fg("dim", " Enter to submit • Esc to cancel"));
+ } else if (currentTab === questions.length) {
+ add(theme.fg("accent", theme.bold(" Ready to submit")));
+ lines.push("");
+ for (const question of questions) {
+ const answer = answers.get(question.id);
+ if (answer) {
+ const prefix = answer.wasCustom ? "(wrote) " : "";
+ add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
+ }
+ }
+ lines.push("");
+ if (allAnswered()) {
+ add(theme.fg("success", " Press Enter to submit"));
+ } else {
+ const missing = questions
+ .filter((q) => !answers.has(q.id))
+ .map((q) => q.label)
+ .join(", ");
+ add(theme.fg("warning", ` Unanswered: ${missing}`));
+ }
+ } else if (q) {
+ addWrapped(theme.fg("text", ` ${q.prompt}`));
+ lines.push("");
+ renderOptions();
+ }
+
+ // Notes section (visible unless typing an answer)
+ if (!inputMode) {
+ if (notesMode) {
+ lines.push("");
+ add(theme.fg("accent", " ✎ Note:"));
+ for (const line of notesEditor.render(width - 2)) {
+ add(` ${line}`);
+ }
+ lines.push("");
+ add(theme.fg("dim", " Enter to save • Esc to discard"));
+ } else if (notesText) {
+ lines.push("");
+ add(theme.fg("muted", " ✎ Note: ") + theme.fg("text", notesText));
+ add(theme.fg("dim", " Press 'n' to edit"));
+ } else {
+ lines.push("");
+ add(theme.fg("dim", " Press 'n' to add a note"));
+ }
+ }
+
+ lines.push("");
+ if (!inputMode && !notesMode) {
+ const help = isMulti
+ ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
+ : " ↑↓ navigate • Enter select • Esc cancel";
+ add(theme.fg("dim", help));
+ }
+ add(theme.fg("accent", "─".repeat(width)));
+
+ cachedLines = lines;
+ return lines;
+ }
+
+ return {
+ render,
+ invalidate: () => {
+ cachedLines = undefined;
+ },
+ handleInput,
+ };
+ });
+
+ if (result.cancelled) {
+ return {
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
+ details: result,
+ };
+ }
+
+ // Check if any answers were custom (user typed instead of selecting)
+ const customAnswers = result.answers.filter((a) => a.wasCustom);
+ const selectedAnswers = result.answers.filter((a) => !a.wasCustom);
+
+ // If there are custom answers, send them as a user message
+ if (customAnswers.length > 0) {
+ // Build user message with identifiers for each custom response
+ const messageLines = customAnswers.map((a) => `[${a.id}] ${a.label}`);
+ const userMessage = messageLines.join("\n\n");
+ pi.sendUserMessage(userMessage, { deliverAs: "steer" });
+
+ // Build tool result showing what happened
+ const toolResultLines: string[] = [];
+
+ // First show any selected answers normally
+ for (const a of selectedAnswers) {
+ const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
+ toolResultLines.push(`${qLabel}: user selected: ${a.index}. ${a.label}`);
+ }
+
+ // Then indicate which questions got user message responses
+ for (const a of customAnswers) {
+ if (a.id === "_note") {
+ toolResultLines.push(`Note: (see [_note] in following message)`);
+ } else {
+ const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
+ toolResultLines.push(`${qLabel}: (see [${a.id}] in following message)`);
+ }
+ }
+
+ return {
+ content: [{ type: "text", text: toolResultLines.join("\n") }],
+ details: result,
+ };
+ }
+
+ const answerLines = result.answers.map((a) => {
+ const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
+ if (a.wasCustom) {
+ return `${qLabel}: user wrote: ${a.label}`;
+ }
+ return `${qLabel}: user selected: ${a.index}. ${a.label}`;
+ });
+
+ return {
+ content: [{ type: "text", text: answerLines.join("\n") }],
+ details: result,
+ };
+ },
+
+ renderCall(args, theme) {
+ const qs = (args.questions as Question[]) || [];
+ const count = qs.length;
+ const labels = qs.map((q) => q.label || q.id).join(", ");
+ let text = theme.fg("toolTitle", theme.bold("questionnaire "));
+ text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
+ if (labels) {
+ text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
+ }
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, _options, theme) {
+ const details = result.details as QuestionnaireResult | undefined;
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
+ }
+ if (details.cancelled) {
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
+ }
+ const lines = details.answers.map((a) => {
+ if (a.id === "_note") {
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", "Note")}: ${theme.fg("muted", "(via message)")}`;
+ }
+ if (a.wasCustom) {
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(via message) ")}[${a.id}]`;
+ }
+ const display = a.index ? `${a.index}. ${a.label}` : a.label;
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
+ });
+ return new Text(lines.join("\n"), 0, 0);
+ },
+ });
+}