@@ -42,17 +42,6 @@
"@earendil-works/pi-coding-agent": ">=0.74.0",
},
},
- "packages/questionnaire": {
- "name": "@amolith/pi-questionnaire",
- "version": "0.1.0",
- "dependencies": {
- "typebox": "^1.1.38",
- },
- "peerDependencies": {
- "@earendil-works/pi-coding-agent": ">=0.74.0",
- "@earendil-works/pi-tui": ">=0.74.0",
- },
- },
},
"packages": {
"@amolith/pi-answer": ["@amolith/pi-answer@workspace:packages/answer"],
@@ -61,8 +50,6 @@
"@amolith/pi-personas": ["@amolith/pi-personas@workspace:packages/personas"],
- "@amolith/pi-questionnaire": ["@amolith/pi-questionnaire@workspace:packages/questionnaire"],
-
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
@@ -1,552 +0,0 @@
-// 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 "@earendil-works/pi-coding-agent";
-import {
- Editor,
- type EditorTheme,
- Key,
- matchesKey,
- Text,
- truncateToWidth,
- wrapTextWithAnsi,
-} from "@earendil-works/pi-tui";
-import { Type } from "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 and deduplicate IDs
- const seenIds = new Set<string>();
- const questions: Question[] = params.questions.map((q, i) => {
- let id = q.id;
- if (seenIds.has(id)) {
- let suffix = 2;
- while (seenIds.has(`${id}_${suffix}`)) suffix++;
- id = `${id}_${suffix}`;
- }
- seenIds.add(id);
- return {
- ...q,
- id,
- 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) return; // no options available
- 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);
- },
- });
-}