index.ts

  1// SPDX-FileCopyrightText: Mario Zechner <https://mariozechner.at>
  2//
  3// SPDX-License-Identifier: MIT
  4
  5/**
  6 * Questionnaire Tool - Unified tool for asking single or multiple questions
  7 *
  8 * Single question: simple options list
  9 * Multiple questions: tab bar navigation between questions
 10 */
 11
 12import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 13import {
 14	Editor,
 15	type EditorTheme,
 16	Key,
 17	matchesKey,
 18	Text,
 19	truncateToWidth,
 20	wrapTextWithAnsi,
 21} from "@mariozechner/pi-tui";
 22import { Type } from "@sinclair/typebox";
 23
 24// Types
 25interface QuestionOption {
 26	value: string;
 27	label: string;
 28	description?: string;
 29}
 30
 31type RenderOption = QuestionOption & { isOther?: boolean };
 32
 33interface Question {
 34	id: string;
 35	label: string;
 36	prompt: string;
 37	options: QuestionOption[];
 38	allowOther: boolean;
 39}
 40
 41interface Answer {
 42	id: string;
 43	value: string;
 44	label: string;
 45	wasCustom: boolean;
 46	index?: number;
 47}
 48
 49interface QuestionnaireResult {
 50	questions: Question[];
 51	answers: Answer[];
 52	cancelled: boolean;
 53}
 54
 55// Schema
 56const QuestionOptionSchema = Type.Object({
 57	value: Type.String({ description: "The value returned when selected" }),
 58	label: Type.String({ description: "Display label for the option" }),
 59	description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
 60});
 61
 62const QuestionSchema = Type.Object({
 63	id: Type.String({ description: "Unique identifier for this question" }),
 64	label: Type.Optional(
 65		Type.String({
 66			description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
 67		}),
 68	),
 69	prompt: Type.String({ description: "The full question text to display" }),
 70	options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
 71	allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
 72});
 73
 74const QuestionnaireParams = Type.Object({
 75	questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
 76});
 77
 78function errorResult(
 79	message: string,
 80	questions: Question[] = [],
 81): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
 82	return {
 83		content: [{ type: "text", text: message }],
 84		details: { questions, answers: [], cancelled: true },
 85	};
 86}
 87
 88export default function questionnaire(pi: ExtensionAPI) {
 89	pi.registerTool({
 90		name: "questionnaire",
 91		label: "Questionnaire",
 92		description:
 93			"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.",
 94		parameters: QuestionnaireParams,
 95
 96		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
 97			if (!ctx.hasUI) {
 98				return errorResult("Error: UI not available (running in non-interactive mode)");
 99			}
100			if (params.questions.length === 0) {
101				return errorResult("Error: No questions provided");
102			}
103
104			// Normalize questions with defaults
105			const questions: Question[] = params.questions.map((q, i) => ({
106				...q,
107				label: q.label || `Q${i + 1}`,
108				allowOther: q.allowOther !== false,
109			}));
110
111			const isMulti = questions.length > 1;
112			const totalTabs = questions.length + 1; // questions + Submit
113
114			const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
115				// State
116				let currentTab = 0;
117				let optionIndex = 0;
118				let inputMode = false;
119				let inputQuestionId: string | null = null;
120				let cachedLines: string[] | undefined;
121				const answers = new Map<string, Answer>();
122
123				// Editor for "Type something" option
124				const editorTheme: EditorTheme = {
125					borderColor: (s) => theme.fg("accent", s),
126					selectList: {
127						selectedPrefix: (t) => theme.fg("accent", t),
128						selectedText: (t) => theme.fg("accent", t),
129						description: (t) => theme.fg("muted", t),
130						scrollInfo: (t) => theme.fg("dim", t),
131						noMatch: (t) => theme.fg("warning", t),
132					},
133				};
134				const editor = new Editor(tui, editorTheme);
135
136				// Helpers
137				function refresh() {
138					cachedLines = undefined;
139					tui.requestRender();
140				}
141
142				function submit(cancelled: boolean) {
143					const allAnswers = Array.from(answers.values());
144					if (!cancelled && notesText) {
145						allAnswers.push({ id: "_note", value: notesText, label: notesText, wasCustom: true });
146					}
147					done({ questions, answers: allAnswers, cancelled });
148				}
149
150				function currentQuestion(): Question | undefined {
151					return questions[currentTab];
152				}
153
154				function currentOptions(): RenderOption[] {
155					const q = currentQuestion();
156					if (!q) return [];
157					const opts: RenderOption[] = [...q.options];
158					if (q.allowOther) {
159						opts.push({ value: "__other__", label: "Type something.", isOther: true });
160					}
161					return opts;
162				}
163
164				function allAnswered(): boolean {
165					return questions.every((q) => answers.has(q.id));
166				}
167
168				function advanceAfterAnswer() {
169					if (!isMulti) {
170						submit(false);
171						return;
172					}
173					if (currentTab < questions.length - 1) {
174						currentTab++;
175					} else {
176						currentTab = questions.length; // Submit tab
177					}
178					optionIndex = 0;
179					refresh();
180				}
181
182				function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
183					answers.set(questionId, { id: questionId, value, label, wasCustom, index });
184				}
185
186				// Editor submit callback
187				editor.onSubmit = (value) => {
188					if (!inputQuestionId) return;
189					const trimmed = value.trim() || "(no response)";
190					saveAnswer(inputQuestionId, trimmed, trimmed, true);
191					inputMode = false;
192					inputQuestionId = null;
193					editor.setText("");
194					advanceAfterAnswer();
195				};
196
197				// Free-text notes (always available via 'n' key)
198				let notesMode = false;
199				let notesText = "";
200				const notesEditor = new Editor(tui, editorTheme);
201				notesEditor.onSubmit = (value) => {
202					notesText = value.trim();
203					notesMode = false;
204					refresh();
205				};
206
207				function handleInput(data: string) {
208					// Input mode: route to editor
209					if (inputMode) {
210						if (matchesKey(data, Key.escape)) {
211							inputMode = false;
212							inputQuestionId = null;
213							editor.setText("");
214							refresh();
215							return;
216						}
217						editor.handleInput(data);
218						refresh();
219						return;
220					}
221
222					// Notes mode: route to notes editor
223					if (notesMode) {
224						if (matchesKey(data, Key.escape)) {
225							notesMode = false;
226							notesEditor.setText(notesText);
227							refresh();
228							return;
229						}
230						notesEditor.handleInput(data);
231						refresh();
232						return;
233					}
234
235					// Activate notes editor (available on any tab)
236					if (data === "n" || data === "N") {
237						notesMode = true;
238						notesEditor.setText(notesText);
239						refresh();
240						return;
241					}
242
243					const q = currentQuestion();
244					const opts = currentOptions();
245
246					// Tab navigation (multi-question only)
247					if (isMulti) {
248						if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
249							currentTab = (currentTab + 1) % totalTabs;
250							optionIndex = 0;
251							refresh();
252							return;
253						}
254						if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
255							currentTab = (currentTab - 1 + totalTabs) % totalTabs;
256							optionIndex = 0;
257							refresh();
258							return;
259						}
260					}
261
262					// Submit tab
263					if (currentTab === questions.length) {
264						if (matchesKey(data, Key.enter) && allAnswered()) {
265							submit(false);
266						} else if (matchesKey(data, Key.escape)) {
267							submit(true);
268						}
269						return;
270					}
271
272					// Option navigation
273					if (matchesKey(data, Key.up)) {
274						optionIndex = Math.max(0, optionIndex - 1);
275						refresh();
276						return;
277					}
278					if (matchesKey(data, Key.down)) {
279						optionIndex = Math.min(opts.length - 1, optionIndex + 1);
280						refresh();
281						return;
282					}
283
284					// Select option
285					if (matchesKey(data, Key.enter) && q) {
286						const opt = opts[optionIndex];
287						if (opt.isOther) {
288							inputMode = true;
289							inputQuestionId = q.id;
290							editor.setText("");
291							refresh();
292							return;
293						}
294						saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
295						advanceAfterAnswer();
296						return;
297					}
298
299					// Cancel
300					if (matchesKey(data, Key.escape)) {
301						submit(true);
302					}
303				}
304
305				function render(width: number): string[] {
306					if (cachedLines) return cachedLines;
307
308					const lines: string[] = [];
309					const q = currentQuestion();
310					const opts = currentOptions();
311
312					// Helper to add truncated line
313					const add = (s: string) => lines.push(truncateToWidth(s, width));
314
315					add(theme.fg("accent", "─".repeat(width)));
316
317					// Tab bar (multi-question only)
318					if (isMulti) {
319						const tabs: string[] = ["← "];
320						for (let i = 0; i < questions.length; i++) {
321							const isActive = i === currentTab;
322							const isAnswered = answers.has(questions[i].id);
323							const lbl = questions[i].label;
324							const box = isAnswered ? "■" : "□";
325							const color = isAnswered ? "success" : "muted";
326							const text = ` ${box} ${lbl} `;
327							const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
328							tabs.push(`${styled} `);
329						}
330						const canSubmit = allAnswered();
331						const isSubmitTab = currentTab === questions.length;
332						const submitText = " ✓ Submit ";
333						const submitStyled = isSubmitTab
334							? theme.bg("selectedBg", theme.fg("text", submitText))
335							: theme.fg(canSubmit ? "success" : "dim", submitText);
336						tabs.push(`${submitStyled}`);
337						add(` ${tabs.join("")}`);
338						lines.push("");
339					}
340
341					// Helper to render options list
342					function renderOptions() {
343						for (let i = 0; i < opts.length; i++) {
344							const opt = opts[i];
345							const selected = i === optionIndex;
346							const isOther = opt.isOther === true;
347							const prefix = selected ? theme.fg("accent", "> ") : "  ";
348							const color = selected ? "accent" : "text";
349							// Mark "Type something" differently when in input mode
350							if (isOther && inputMode) {
351								add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
352							} else {
353								add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
354							}
355							if (opt.description) {
356								add(`     ${theme.fg("muted", opt.description)}`);
357							}
358						}
359					}
360
361					// Helper to add word-wrapped lines (for long prompts)
362					const addWrapped = (s: string) => {
363						for (const line of wrapTextWithAnsi(s, width)) {
364							lines.push(line);
365						}
366					};
367
368					// Content
369					if (inputMode && q) {
370						addWrapped(theme.fg("text", ` ${q.prompt}`));
371						lines.push("");
372						// Show options for reference
373						renderOptions();
374						lines.push("");
375						add(theme.fg("muted", " Your answer:"));
376						for (const line of editor.render(width - 2)) {
377							add(` ${line}`);
378						}
379						lines.push("");
380						add(theme.fg("dim", " Enter to submit • Esc to cancel"));
381					} else if (currentTab === questions.length) {
382						add(theme.fg("accent", theme.bold(" Ready to submit")));
383						lines.push("");
384						for (const question of questions) {
385							const answer = answers.get(question.id);
386							if (answer) {
387								const prefix = answer.wasCustom ? "(wrote) " : "";
388								add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
389							}
390						}
391						lines.push("");
392						if (allAnswered()) {
393							add(theme.fg("success", " Press Enter to submit"));
394						} else {
395							const missing = questions
396								.filter((q) => !answers.has(q.id))
397								.map((q) => q.label)
398								.join(", ");
399							add(theme.fg("warning", ` Unanswered: ${missing}`));
400						}
401					} else if (q) {
402						addWrapped(theme.fg("text", ` ${q.prompt}`));
403						lines.push("");
404						renderOptions();
405					}
406
407					// Notes section (visible unless typing an answer)
408					if (!inputMode) {
409						if (notesMode) {
410							lines.push("");
411							add(theme.fg("accent", " ✎ Note:"));
412							for (const line of notesEditor.render(width - 2)) {
413								add(` ${line}`);
414							}
415							lines.push("");
416							add(theme.fg("dim", " Enter to save • Esc to discard"));
417						} else if (notesText) {
418							lines.push("");
419							add(theme.fg("muted", " ✎ Note: ") + theme.fg("text", notesText));
420							add(theme.fg("dim", " Press 'n' to edit"));
421						} else {
422							lines.push("");
423							add(theme.fg("dim", " Press 'n' to add a note"));
424						}
425					}
426
427					lines.push("");
428					if (!inputMode && !notesMode) {
429						const help = isMulti
430							? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
431							: " ↑↓ navigate • Enter select • Esc cancel";
432						add(theme.fg("dim", help));
433					}
434					add(theme.fg("accent", "─".repeat(width)));
435
436					cachedLines = lines;
437					return lines;
438				}
439
440				return {
441					render,
442					invalidate: () => {
443						cachedLines = undefined;
444					},
445					handleInput,
446				};
447			});
448
449			if (result.cancelled) {
450				return {
451					content: [{ type: "text", text: "User cancelled the questionnaire" }],
452					details: result,
453				};
454			}
455
456			// Check if any answers were custom (user typed instead of selecting)
457			const customAnswers = result.answers.filter((a) => a.wasCustom);
458			const selectedAnswers = result.answers.filter((a) => !a.wasCustom);
459
460			// If there are custom answers, send them as a user message
461			if (customAnswers.length > 0) {
462				// Build user message with identifiers for each custom response
463				const messageLines = customAnswers.map((a) => `[${a.id}] ${a.label}`);
464				const userMessage = messageLines.join("\n\n");
465				pi.sendUserMessage(userMessage, { deliverAs: "steer" });
466
467				// Build tool result showing what happened
468				const toolResultLines: string[] = [];
469
470				// First show any selected answers normally
471				for (const a of selectedAnswers) {
472					const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
473					toolResultLines.push(`${qLabel}: user selected: ${a.index}. ${a.label}`);
474				}
475
476				// Then indicate which questions got user message responses
477				for (const a of customAnswers) {
478					if (a.id === "_note") {
479						toolResultLines.push(`Note: (see [_note] in following message)`);
480					} else {
481						const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
482						toolResultLines.push(`${qLabel}: (see [${a.id}] in following message)`);
483					}
484				}
485
486				return {
487					content: [{ type: "text", text: toolResultLines.join("\n") }],
488					details: result,
489				};
490			}
491
492			const answerLines = result.answers.map((a) => {
493				const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
494				if (a.wasCustom) {
495					return `${qLabel}: user wrote: ${a.label}`;
496				}
497				return `${qLabel}: user selected: ${a.index}. ${a.label}`;
498			});
499
500			return {
501				content: [{ type: "text", text: answerLines.join("\n") }],
502				details: result,
503			};
504		},
505
506		renderCall(args, theme) {
507			const qs = (args.questions as Question[]) || [];
508			const count = qs.length;
509			const labels = qs.map((q) => q.label || q.id).join(", ");
510			let text = theme.fg("toolTitle", theme.bold("questionnaire "));
511			text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
512			if (labels) {
513				text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
514			}
515			return new Text(text, 0, 0);
516		},
517
518		renderResult(result, _options, theme) {
519			const details = result.details as QuestionnaireResult | undefined;
520			if (!details) {
521				const text = result.content[0];
522				return new Text(text?.type === "text" ? text.text : "", 0, 0);
523			}
524			if (details.cancelled) {
525				return new Text(theme.fg("warning", "Cancelled"), 0, 0);
526			}
527			const lines = details.answers.map((a) => {
528				if (a.id === "_note") {
529					return `${theme.fg("success", "✓ ")}${theme.fg("accent", "Note")}: ${theme.fg("muted", "(via message)")}`;
530				}
531				if (a.wasCustom) {
532					return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(via message) ")}[${a.id}]`;
533				}
534				const display = a.index ? `${a.index}. ${a.label}` : a.label;
535				return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
536			});
537			return new Text(lines.join("\n"), 0, 0);
538		},
539	});
540}