diff --git a/README.md b/README.md index 7fb7e895873f1d02ab1afea3b2eca3edc1304b29..5b93db2b8d69655c0848a276def58c021086affe 100644 --- a/README.md +++ b/README.md @@ -52,22 +52,6 @@ pi install npm:@amolith/pi-answer > fallback extraction with the current model when the preferred model is > unavailable. -### `@amolith/pi-questionnaire` - -[![npm](https://img.shields.io/npm/v/@amolith/pi-questionnaire)](https://www.npmjs.com/package/@amolith/pi-questionnaire) - -Have Pi construct on-the-fly questionnaires to ask you stuff before it goes off -and does the wrong thing. - -```sh -pi install npm:@amolith/pi-questionnaire -``` - -> Based on [questionnaire.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/questionnaire.ts) -> by Mario Zechner, licensed MIT accordingly. Added a free-text notes editor, -> word wrapping for long prompts, and custom answers are sent as a steering -> user message so the user can go back and edit them later. - ### `@amolith/pi-personas` [![npm](https://img.shields.io/npm/v/@amolith/pi-personas)](https://www.npmjs.com/package/@amolith/pi-personas) diff --git a/bun.lock b/bun.lock index efd058812466f57ff6fb66b09877e3bd529a311c..a7fee9eb4f24f9b3bce83c5319649836fd6b5dc5 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/questionnaire/package.json b/packages/questionnaire/package.json deleted file mode 100644 index 9de7f2fa7c7abfdf849b04d127fb2d863806cd0e..0000000000000000000000000000000000000000 --- a/packages/questionnaire/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@amolith/pi-questionnaire", - "version": "0.1.0", - "peerDependencies": { - "@earendil-works/pi-coding-agent": ">=0.74.0", - "@earendil-works/pi-tui": ">=0.74.0" - }, - "description": "Multi-question interactive prompts for Pi", - "keywords": [ - "pi-package" - ], - "pi": { - "extensions": [ - "./src/index.ts" - ] - }, - "scripts": { - "typecheck": "tsc --noEmit" - }, - "type": "module", - "dependencies": { - "typebox": "^1.1.38" - } -} diff --git a/packages/questionnaire/package.json.license b/packages/questionnaire/package.json.license deleted file mode 100644 index 3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e..0000000000000000000000000000000000000000 --- a/packages/questionnaire/package.json.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Amolith - -SPDX-License-Identifier: CC0-1.0 diff --git a/packages/questionnaire/src/index.ts b/packages/questionnaire/src/index.ts deleted file mode 100644 index da48f5a613c9f43746eb282641ee4f602b75e551..0000000000000000000000000000000000000000 --- a/packages/questionnaire/src/index.ts +++ /dev/null @@ -1,552 +0,0 @@ -// 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 "@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(); - 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((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) 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); - }, - }); -} diff --git a/packages/questionnaire/tsconfig.json b/packages/questionnaire/tsconfig.json deleted file mode 100644 index 0c91d6284869132f30fd7f951e89ce4b9c7d9d54..0000000000000000000000000000000000000000 --- a/packages/questionnaire/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src"] -} diff --git a/packages/questionnaire/tsconfig.json.license b/packages/questionnaire/tsconfig.json.license deleted file mode 100644 index 3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e..0000000000000000000000000000000000000000 --- a/packages/questionnaire/tsconfig.json.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Amolith - -SPDX-License-Identifier: CC0-1.0 diff --git a/scripts/resolve-release-packages.bash b/scripts/resolve-release-packages.bash index b5c9277b02c6b8569cf8cb14f76c5bfe6347a6f2..3bc92803bc076d4c960c1689010c6c9a9ab3758a 100755 --- a/scripts/resolve-release-packages.bash +++ b/scripts/resolve-release-packages.bash @@ -6,14 +6,13 @@ set -euo pipefail -valid_packages="answer, handoff, personas, questionnaire" +valid_packages="answer, handoff, personas" resolve_package_dir() { case "$1" in answer|pi-answer|@amolith/pi-answer) printf '%s\n' packages/answer ;; handoff|pi-handoff|@amolith/pi-handoff) printf '%s\n' packages/handoff ;; personas|pi-personas|@amolith/pi-personas) printf '%s\n' packages/personas ;; - questionnaire|pi-questionnaire|@amolith/pi-questionnaire) printf '%s\n' packages/questionnaire ;; *) echo "Error: unknown package '$1'" >&2 echo "Valid packages: $valid_packages" >&2