diff --git a/packages/questionnaire/package.json b/packages/questionnaire/package.json new file mode 100644 index 0000000000000000000000000000000000000000..68d8f4ac9bd8e0e62afb6e3abc2c026f8b719ec0 --- /dev/null +++ b/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" +} diff --git a/packages/questionnaire/package.json.license b/packages/questionnaire/package.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/questionnaire/package.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0 diff --git a/packages/questionnaire/src/index.ts b/packages/questionnaire/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cd309ca6002bc9f0b12f2bd875c33475dc2bc5b --- /dev/null +++ b/packages/questionnaire/src/index.ts @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: Mario Zechner +// +// 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((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(); + + // 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); + }, + }); +} diff --git a/packages/questionnaire/tsconfig.json b/packages/questionnaire/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0c91d6284869132f30fd7f951e89ce4b9c7d9d54 --- /dev/null +++ b/packages/questionnaire/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/questionnaire/tsconfig.json.license b/packages/questionnaire/tsconfig.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/questionnaire/tsconfig.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0