Add questionnaire extension

Amolith created

Multi-question interactive prompts with tab-based navigation, from Mario
Zechner's pi-mono examples. Changes from upstream:

- Added a free-text notes editor (press 'n') available on any tab
- Custom answers and notes are sent as a steering user message so
the model sees them as user content, not just tool results
- Added word wrapping for long question prompts via wrapTextWithAnsi
- Updated tool description to mention the notes feature

Licensed MIT per the upstream.

Change summary

packages/questionnaire/package.json          |  22 
packages/questionnaire/package.json.license  |   3 
packages/questionnaire/src/index.ts          | 540 ++++++++++++++++++++++
packages/questionnaire/tsconfig.json         |   4 
packages/questionnaire/tsconfig.json.license |   3 
5 files changed, 572 insertions(+)

Detailed changes

packages/questionnaire/package.json 🔗

@@ -0,0 +1,22 @@
+{
+	"name": "@amolith/pi-questionnaire",
+	"version": "0.1.0",
+	"description": "Multi-question interactive prompts for Pi",
+	"keywords": [
+		"pi-package"
+	],
+	"pi": {
+		"extensions": [
+			"./src/index.ts"
+		]
+	},
+	"scripts": {
+		"typecheck": "tsc --noEmit"
+	},
+	"peerDependencies": {
+		"@mariozechner/pi-coding-agent": "*",
+		"@mariozechner/pi-tui": "*",
+		"@sinclair/typebox": "*"
+	},
+	"type": "module"
+}

packages/questionnaire/src/index.ts 🔗

@@ -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);
+		},
+	});
+}