@dreki-gg/pi-questionnaire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @dreki-gg/pi-questionnaire
2
+
3
+ Tool-first questionnaire extension for pi.
4
+
5
+ It adds:
6
+ - a `questionnaire` tool for collecting **1-5 structured answers** in one interaction
7
+ - a `/questionnaire` demo command powered by the same shared tabbed TUI flow
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@dreki-gg/pi-questionnaire
13
+ ```
14
+
15
+ ## What it provides
16
+
17
+ | Feature | Name | Notes |
18
+ |---|---|---|
19
+ | Tool | `questionnaire` | Collects structured user answers with single/multi select + optional Other text |
20
+ | Command | `/questionnaire` | Demo flow using `Scope`, `Priority`, `Approach` |
21
+
22
+ ## Input schema (summary)
23
+
24
+ ```ts
25
+ {
26
+ questions: Array<{
27
+ id: string;
28
+ label?: string;
29
+ prompt: string;
30
+ selectionMode?: 'single' | 'multiple';
31
+ options: Array<{
32
+ value: string;
33
+ label: string;
34
+ description?: string;
35
+ }>;
36
+ allowOther?: boolean; // defaults to true at runtime
37
+ }> // min 1, max 5
38
+ }
39
+ ```
40
+
41
+ Validation includes:
42
+ - 1-5 question limit
43
+ - non-empty option lists
44
+ - duplicate question id rejection
45
+ - duplicate option value rejection per question
46
+
47
+ ## Normalized output schema (summary)
48
+
49
+ ```ts
50
+ {
51
+ questions: NormalizedQuestion[];
52
+ answers: Array<{
53
+ questionId: string;
54
+ questionLabel: string;
55
+ selectedOptions: Array<{ value: string; label: string }>;
56
+ otherText: string | null;
57
+ wasOtherSelected: boolean;
58
+ }>;
59
+ cancelled: boolean;
60
+ }
61
+ ```
62
+
63
+ `Other` answers are rendered explicitly as:
64
+
65
+ ```text
66
+ Other: "GraphQL"
67
+ ```
68
+
69
+ ## Keyboard model
70
+
71
+ - `← / →`: switch tabs (question tabs + Review tab)
72
+ - `↑ / ↓`: move option cursor (question tab) or row cursor (Review tab)
73
+ - `Space`:
74
+ - question tab: select/toggle option or enter Other input
75
+ - review tab: jump to selected question for editing
76
+ - `r`: jump to Review tab
77
+ - `Esc` hierarchy:
78
+ 1. exit Other input mode
79
+ 2. from Review, go back to prior question tab
80
+ 3. from question tab outermost, cancel questionnaire
81
+ - `Enter`:
82
+ - in Other editor: submit typed text
83
+ - in Review: submit only when all required answers are valid
84
+
85
+ ## Example tool call
86
+
87
+ ```json
88
+ {
89
+ "name": "questionnaire",
90
+ "arguments": {
91
+ "questions": [
92
+ {
93
+ "id": "scope",
94
+ "label": "Scope",
95
+ "prompt": "What is the project scope?",
96
+ "selectionMode": "single",
97
+ "options": [
98
+ { "value": "low", "label": "Low" },
99
+ { "value": "high", "label": "High" }
100
+ ]
101
+ },
102
+ {
103
+ "id": "priority",
104
+ "label": "Priority",
105
+ "prompt": "What priority should we assign?",
106
+ "selectionMode": "single",
107
+ "options": [
108
+ { "value": "p0", "label": "P0" },
109
+ { "value": "p1", "label": "P1" }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Example result summaries
118
+
119
+ Completed (collapsed):
120
+
121
+ ```text
122
+ ✓ Scope: High • Priority: P1 • Approach: Other: "GraphQL"
123
+ ```
124
+
125
+ Cancelled:
126
+
127
+ ```text
128
+ ⚠ Cancelled
129
+ ```
130
+
131
+ ## Cancellation and non-interactive behavior
132
+
133
+ - User cancellation is **not treated as an error** (`cancelled: true` in details).
134
+ - Non-interactive mode (`!ctx.hasUI`) returns an immediate error result (`isError: true`).
@@ -0,0 +1,189 @@
1
+ import type {
2
+ NormalizedAnswer,
3
+ NormalizedQuestion,
4
+ QuestionInput,
5
+ QuestionSelectionState,
6
+ QuestionnaireResult,
7
+ SelectedOption,
8
+ } from './types.js';
9
+
10
+ export function validateQuestions(
11
+ questions: QuestionInput[],
12
+ ): { valid: true } | { valid: false; error: string } {
13
+ if (questions.length === 0) {
14
+ return { valid: false, error: 'Questionnaire must include at least 1 question.' };
15
+ }
16
+
17
+ if (questions.length > 5) {
18
+ return { valid: false, error: 'Questionnaire supports at most 5 questions.' };
19
+ }
20
+
21
+ const idSet = new Set<string>();
22
+
23
+ for (const [index, question] of questions.entries()) {
24
+ const questionNumber = index + 1;
25
+ const questionId = question.id.trim();
26
+
27
+ if (!questionId) {
28
+ return { valid: false, error: `Question ${questionNumber} has an empty id.` };
29
+ }
30
+
31
+ if (idSet.has(questionId)) {
32
+ return { valid: false, error: `Duplicate question id: "${questionId}".` };
33
+ }
34
+ idSet.add(questionId);
35
+
36
+ if (!question.options.length) {
37
+ return {
38
+ valid: false,
39
+ error: `Question "${questionId}" must include at least one listed option.`,
40
+ };
41
+ }
42
+
43
+ const valueSet = new Set<string>();
44
+ for (const option of question.options) {
45
+ const optionValue = option.value.trim();
46
+ if (!optionValue) {
47
+ return {
48
+ valid: false,
49
+ error: `Question "${questionId}" has an option with an empty value.`,
50
+ };
51
+ }
52
+ if (valueSet.has(optionValue)) {
53
+ return {
54
+ valid: false,
55
+ error: `Question "${questionId}" has duplicate option value "${optionValue}".`,
56
+ };
57
+ }
58
+ valueSet.add(optionValue);
59
+ }
60
+ }
61
+
62
+ return { valid: true };
63
+ }
64
+
65
+ export function normalizeQuestions(questions: QuestionInput[]): NormalizedQuestion[] {
66
+ return questions.map((question, index) => ({
67
+ id: question.id.trim(),
68
+ label: question.label?.trim() || question.id.trim() || `Q${index + 1}`,
69
+ prompt: question.prompt.trim(),
70
+ selectionMode: question.selectionMode ?? 'single',
71
+ options: question.options.map((option) => ({
72
+ value: option.value.trim(),
73
+ label: option.label,
74
+ description: option.description,
75
+ })),
76
+ allowOther: question.allowOther !== false,
77
+ }));
78
+ }
79
+
80
+ export function createInitialQuestionStateById(
81
+ questions: NormalizedQuestion[],
82
+ ): Record<string, QuestionSelectionState> {
83
+ return Object.fromEntries(
84
+ questions.map((question) => [
85
+ question.id,
86
+ {
87
+ listedSelectedValues: [],
88
+ otherText: '',
89
+ wasOtherSelected: false,
90
+ } satisfies QuestionSelectionState,
91
+ ]),
92
+ );
93
+ }
94
+
95
+ function hasValidOtherText(state: QuestionSelectionState): boolean {
96
+ return state.wasOtherSelected && state.otherText.trim().length > 0;
97
+ }
98
+
99
+ export function isAnswerValid(
100
+ question: NormalizedQuestion,
101
+ state: QuestionSelectionState,
102
+ ): boolean {
103
+ const selectedListedCount = state.listedSelectedValues.length;
104
+ const hasOtherText = hasValidOtherText(state);
105
+
106
+ if (question.selectionMode === 'single') {
107
+ const hasSingleListed = selectedListedCount === 1 && !state.wasOtherSelected;
108
+ const hasSingleOther = selectedListedCount === 0 && hasOtherText;
109
+ return hasSingleListed || hasSingleOther;
110
+ }
111
+
112
+ return selectedListedCount > 0 || hasOtherText;
113
+ }
114
+
115
+ export function areAllAnswersValid(
116
+ questions: NormalizedQuestion[],
117
+ stateById: Record<string, QuestionSelectionState>,
118
+ ): boolean {
119
+ return questions.every((question) => {
120
+ const state = stateById[question.id];
121
+ if (!state) return false;
122
+ return isAnswerValid(question, state);
123
+ });
124
+ }
125
+
126
+ export function normalizeAnswer(
127
+ question: NormalizedQuestion,
128
+ state: QuestionSelectionState,
129
+ ): NormalizedAnswer {
130
+ const selectedSet = new Set(state.listedSelectedValues);
131
+ const selectedOptions: SelectedOption[] = question.options
132
+ .filter((option) => selectedSet.has(option.value))
133
+ .map((option) => ({ value: option.value, label: option.label }));
134
+
135
+ const trimmedOther = state.otherText.trim();
136
+
137
+ return {
138
+ questionId: question.id,
139
+ questionLabel: question.label,
140
+ selectedOptions,
141
+ otherText: state.wasOtherSelected && trimmedOther ? trimmedOther : null,
142
+ wasOtherSelected: state.wasOtherSelected,
143
+ };
144
+ }
145
+
146
+ export function normalizeAnswers(
147
+ questions: NormalizedQuestion[],
148
+ stateById: Record<string, QuestionSelectionState>,
149
+ ): NormalizedAnswer[] {
150
+ return questions.map((question) =>
151
+ normalizeAnswer(question, stateById[question.id] ?? createEmptyQuestionState()),
152
+ );
153
+ }
154
+
155
+ function createEmptyQuestionState(): QuestionSelectionState {
156
+ return { listedSelectedValues: [], otherText: '', wasOtherSelected: false };
157
+ }
158
+
159
+ export function formatAnswerValue(answer: NormalizedAnswer): string {
160
+ const listed = answer.selectedOptions.map((option) => option.label).join(', ');
161
+ const other = answer.otherText ? `Other: "${answer.otherText}"` : '';
162
+
163
+ if (listed && other) return `${listed} + ${other}`;
164
+ if (listed) return listed;
165
+ if (other) return other;
166
+ return '—';
167
+ }
168
+
169
+ export function formatCompletedSummary(result: QuestionnaireResult): string {
170
+ const parts = result.answers.map(
171
+ (answer) => `${answer.questionLabel}: ${formatAnswerValue(answer)}`,
172
+ );
173
+ return `✓ ${parts.join(' • ')}`;
174
+ }
175
+
176
+ export function formatCancelledSummary(): string {
177
+ return '⚠ Cancelled';
178
+ }
179
+
180
+ export function formatExpandedAnswerLines(result: QuestionnaireResult): string[] {
181
+ return result.answers.map((answer) => `${answer.questionLabel}: ${formatAnswerValue(answer)}`);
182
+ }
183
+
184
+ export function formatToolContentSummary(result: QuestionnaireResult): string {
185
+ if (result.cancelled) {
186
+ return formatCancelledSummary();
187
+ }
188
+ return formatExpandedAnswerLines(result).join('\n');
189
+ }
@@ -0,0 +1,205 @@
1
+ import type { ExtensionAPI, Theme } from '@mariozechner/pi-coding-agent';
2
+ import { Container, Spacer, Text } from '@mariozechner/pi-tui';
3
+ import {
4
+ formatCancelledSummary,
5
+ formatCompletedSummary,
6
+ formatExpandedAnswerLines,
7
+ formatToolContentSummary,
8
+ normalizeQuestions,
9
+ validateQuestions,
10
+ } from './format.js';
11
+ import { QuestionnaireParamsSchema } from './schema.js';
12
+ import type { QuestionInput, QuestionnaireResult, SelectionMode, SelectedOption } from './types.js';
13
+ import { runQuestionnaireUI } from './ui.js';
14
+
15
+ function emptyResult(error?: string): QuestionnaireResult {
16
+ return {
17
+ questions: [],
18
+ answers: [],
19
+ cancelled: true,
20
+ ...(error ? { error } : {}),
21
+ };
22
+ }
23
+
24
+ function buildErrorResult(error: string, details: QuestionnaireResult = emptyResult(error)) {
25
+ return {
26
+ content: [{ type: 'text' as const, text: `Error: ${error}` }],
27
+ details,
28
+ isError: true,
29
+ };
30
+ }
31
+
32
+ function getCallLabels(questions: QuestionInput[]): string[] {
33
+ return questions.map((question, index) => {
34
+ const trimmed = question.label?.trim();
35
+ if (trimmed) return trimmed;
36
+ return question.id.trim() || `Q${index + 1}`;
37
+ });
38
+ }
39
+
40
+ function renderCollapsedCall(questions: QuestionInput[], theme: Theme): string {
41
+ const labels = getCallLabels(questions);
42
+ const count = questions.length;
43
+ const suffix = count === 1 ? 'question' : 'questions';
44
+ const labelsText = labels.length ? ` (${labels.join(', ')})` : '';
45
+
46
+ return (
47
+ theme.fg('toolTitle', theme.bold('questionnaire ')) +
48
+ theme.fg('muted', `${count} ${suffix}`) +
49
+ theme.fg('dim', labelsText)
50
+ );
51
+ }
52
+
53
+ function selectedOption(value: string, label: string): SelectedOption {
54
+ return { value, label };
55
+ }
56
+
57
+ function demoQuestions(): QuestionInput[] {
58
+ return [
59
+ {
60
+ id: 'scope',
61
+ label: 'Scope',
62
+ prompt: 'What is the project scope?',
63
+ selectionMode: 'single' satisfies SelectionMode,
64
+ options: [
65
+ selectedOption('small', 'Small'),
66
+ selectedOption('medium', 'Medium'),
67
+ selectedOption('high', 'High'),
68
+ ],
69
+ allowOther: true,
70
+ },
71
+ {
72
+ id: 'priority',
73
+ label: 'Priority',
74
+ prompt: 'What priority should we assign?',
75
+ selectionMode: 'single' satisfies SelectionMode,
76
+ options: [selectedOption('p0', 'P0'), selectedOption('p1', 'P1'), selectedOption('p2', 'P2')],
77
+ allowOther: true,
78
+ },
79
+ {
80
+ id: 'approach',
81
+ label: 'Approach',
82
+ prompt: 'Which implementation approach do you want?',
83
+ selectionMode: 'single' satisfies SelectionMode,
84
+ options: [
85
+ selectedOption('rest', 'REST API'),
86
+ selectedOption('event-driven', 'Event-driven'),
87
+ selectedOption('serverless', 'Serverless'),
88
+ ],
89
+ allowOther: true,
90
+ },
91
+ ];
92
+ }
93
+
94
+ export default function questionnaireExtension(pi: ExtensionAPI) {
95
+ pi.registerTool({
96
+ name: 'questionnaire',
97
+ label: 'Questionnaire',
98
+ description:
99
+ 'Collect 1-5 structured user answers in a single interactive questionnaire flow. Supports single and multi-select prompts with optional Other text.',
100
+ promptSnippet:
101
+ 'Gather explicit user choices with a structured questionnaire before proceeding with implementation or planning.',
102
+ promptGuidelines: [
103
+ 'Use this tool when you need explicit user choices before continuing.',
104
+ 'Batch related clarification questions into one questionnaire call instead of many single prompts.',
105
+ 'Only call this tool in interactive sessions with UI available.',
106
+ ],
107
+ parameters: QuestionnaireParamsSchema,
108
+
109
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
110
+ const validation = validateQuestions(params.questions);
111
+ if (!validation.valid) {
112
+ return buildErrorResult(validation.error);
113
+ }
114
+
115
+ const normalizedQuestions = normalizeQuestions(params.questions);
116
+
117
+ if (!ctx.hasUI) {
118
+ return buildErrorResult('UI not available (running in non-interactive mode).', {
119
+ questions: normalizedQuestions,
120
+ answers: [],
121
+ cancelled: true,
122
+ error: 'UI not available (running in non-interactive mode).',
123
+ });
124
+ }
125
+
126
+ const uiResult = await runQuestionnaireUI(ctx, normalizedQuestions);
127
+
128
+ if (uiResult.cancelled) {
129
+ return {
130
+ content: [{ type: 'text' as const, text: formatCancelledSummary() }],
131
+ details: uiResult,
132
+ isError: false,
133
+ cancelled: true,
134
+ };
135
+ }
136
+
137
+ return {
138
+ content: [{ type: 'text' as const, text: formatToolContentSummary(uiResult) }],
139
+ details: uiResult,
140
+ };
141
+ },
142
+
143
+ renderCall(args, theme, _context) {
144
+ return new Text(renderCollapsedCall(args.questions, theme), 0, 0);
145
+ },
146
+
147
+ renderResult(result, { expanded }, theme, _context) {
148
+ const details = result.details as QuestionnaireResult | undefined;
149
+ if (!details) {
150
+ const text = result.content[0];
151
+ return new Text(text?.type === 'text' ? text.text : '', 0, 0);
152
+ }
153
+
154
+ if (details.error) {
155
+ return new Text(theme.fg('error', `Error: ${details.error}`), 0, 0);
156
+ }
157
+
158
+ if (details.cancelled) {
159
+ if (!expanded) {
160
+ return new Text(theme.fg('warning', formatCancelledSummary()), 0, 0);
161
+ }
162
+
163
+ const container = new Container();
164
+ container.addChild(new Text(theme.fg('warning', formatCancelledSummary()), 0, 0));
165
+ container.addChild(new Spacer(1));
166
+ container.addChild(new Text(theme.fg('muted', 'No submission occurred.'), 0, 0));
167
+ return container;
168
+ }
169
+
170
+ if (!expanded) {
171
+ return new Text(theme.fg('success', formatCompletedSummary(details)), 0, 0);
172
+ }
173
+
174
+ const container = new Container();
175
+ container.addChild(new Text(theme.fg('success', formatCompletedSummary(details)), 0, 0));
176
+ container.addChild(new Spacer(1));
177
+
178
+ for (const line of formatExpandedAnswerLines(details)) {
179
+ container.addChild(new Text(theme.fg('text', line), 0, 0));
180
+ }
181
+
182
+ return container;
183
+ },
184
+ });
185
+
186
+ pi.registerCommand('questionnaire', {
187
+ description: 'Run a demo questionnaire flow.',
188
+ handler: async (_args, ctx) => {
189
+ const questions = normalizeQuestions(demoQuestions());
190
+
191
+ if (!ctx.hasUI) {
192
+ ctx.ui.notify('/questionnaire requires interactive mode.', 'error');
193
+ return;
194
+ }
195
+
196
+ const result = await runQuestionnaireUI(ctx, questions);
197
+ if (result.cancelled) {
198
+ ctx.ui.notify(formatCancelledSummary(), 'warning');
199
+ return;
200
+ }
201
+
202
+ ctx.ui.notify(formatCompletedSummary(result), 'info');
203
+ },
204
+ });
205
+ }
@@ -0,0 +1,46 @@
1
+ import { StringEnum } from '@mariozechner/pi-ai';
2
+ import { Type, type Static } from '@sinclair/typebox';
3
+
4
+ export const SelectionModeSchema = StringEnum(['single', 'multiple'] as const, {
5
+ description: 'Selection mode: single choice or multiple choices.',
6
+ default: 'single',
7
+ });
8
+
9
+ export const QuestionOptionSchema = Type.Object({
10
+ value: Type.String({ description: 'Stable value returned when this option is selected.' }),
11
+ label: Type.String({ description: 'User-facing label for this option.' }),
12
+ description: Type.Optional(
13
+ Type.String({ description: 'Optional helper text displayed under the label.' }),
14
+ ),
15
+ });
16
+
17
+ export const QuestionSchema = Type.Object({
18
+ id: Type.String({ description: 'Unique question identifier.' }),
19
+ label: Type.Optional(
20
+ Type.String({ description: 'Short label shown in tabs and summaries (defaults to id).' }),
21
+ ),
22
+ prompt: Type.String({ description: 'Full prompt shown for this question.' }),
23
+ selectionMode: Type.Optional(SelectionModeSchema),
24
+ options: Type.Array(QuestionOptionSchema, {
25
+ minItems: 1,
26
+ description: 'Options available to the user.',
27
+ }),
28
+ allowOther: Type.Optional(
29
+ Type.Boolean({
30
+ description: 'Whether the question includes an Other free-text option (default true).',
31
+ }),
32
+ ),
33
+ });
34
+
35
+ export const QuestionnaireParamsSchema = Type.Object({
36
+ questions: Type.Array(QuestionSchema, {
37
+ minItems: 1,
38
+ maxItems: 5,
39
+ description: 'Question list. Must contain between 1 and 5 questions.',
40
+ }),
41
+ });
42
+
43
+ export type SelectionMode = Static<typeof SelectionModeSchema>;
44
+ export type QuestionOption = Static<typeof QuestionOptionSchema>;
45
+ export type QuestionInput = Static<typeof QuestionSchema>;
46
+ export type QuestionnaireParams = Static<typeof QuestionnaireParamsSchema>;
@@ -0,0 +1,67 @@
1
+ import { StringEnum } from '@mariozechner/pi-ai';
2
+ import { Type, type Static } from '@sinclair/typebox';
3
+ import {
4
+ type QuestionInput as QuestionInputSchema,
5
+ type QuestionOption as QuestionOptionSchema,
6
+ type SelectionMode as SelectionModeSchema,
7
+ } from './schema.js';
8
+
9
+ export type QuestionOption = QuestionOptionSchema;
10
+ export type QuestionInput = QuestionInputSchema;
11
+ export type SelectionMode = SelectionModeSchema;
12
+
13
+ export interface NormalizedQuestion {
14
+ id: string;
15
+ label: string;
16
+ prompt: string;
17
+ selectionMode: SelectionMode;
18
+ options: QuestionOption[];
19
+ allowOther: boolean;
20
+ }
21
+
22
+ export const SelectedOptionSchema = Type.Object({
23
+ value: Type.String(),
24
+ label: Type.String(),
25
+ });
26
+
27
+ export type SelectedOption = Static<typeof SelectedOptionSchema>;
28
+
29
+ export interface NormalizedAnswer {
30
+ questionId: string;
31
+ questionLabel: string;
32
+ selectedOptions: SelectedOption[];
33
+ otherText: string | null;
34
+ wasOtherSelected: boolean;
35
+ }
36
+
37
+ export interface QuestionnaireResult {
38
+ questions: NormalizedQuestion[];
39
+ answers: NormalizedAnswer[];
40
+ cancelled: boolean;
41
+ error?: string;
42
+ }
43
+
44
+ export interface QuestionSelectionState {
45
+ listedSelectedValues: string[];
46
+ otherText: string;
47
+ wasOtherSelected: boolean;
48
+ }
49
+
50
+ export const QuestionnaireInputModeSchema = StringEnum(['navigate', 'otherInput'] as const, {
51
+ description: 'Current input mode for questionnaire UI state.',
52
+ default: 'navigate',
53
+ });
54
+
55
+ export type QuestionnaireInputMode = Static<typeof QuestionnaireInputModeSchema>;
56
+
57
+ export interface QuestionnaireUIState {
58
+ activeTabIndex: number;
59
+ lastQuestionTabIndex: number;
60
+ questionOptionCursorById: Record<string, number>;
61
+ reviewCursor: number;
62
+ inputMode: QuestionnaireInputMode;
63
+ editingQuestionId?: string;
64
+ returnToReview: boolean;
65
+ returnReviewCursor: number;
66
+ questionStateById: Record<string, QuestionSelectionState>;
67
+ }
@@ -0,0 +1,487 @@
1
+ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
2
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
3
+ import {
4
+ areAllAnswersValid,
5
+ createInitialQuestionStateById,
6
+ formatAnswerValue,
7
+ isAnswerValid,
8
+ normalizeAnswers,
9
+ } from './format.js';
10
+ import type {
11
+ NormalizedQuestion,
12
+ QuestionSelectionState,
13
+ QuestionnaireResult,
14
+ QuestionnaireUIState,
15
+ } from './types.js';
16
+
17
+ interface RenderOption {
18
+ kind: 'listed' | 'other';
19
+ value: string;
20
+ label: string;
21
+ description?: string;
22
+ }
23
+
24
+ const REVIEW_LABEL_READY = ' ✓ Review ';
25
+ const REVIEW_LABEL_PENDING = ' □ Review ';
26
+
27
+ function clamp(value: number, min: number, max: number): number {
28
+ return Math.max(min, Math.min(max, value));
29
+ }
30
+
31
+ function isSpaceKey(data: string): boolean {
32
+ return data === ' ' || matchesKey(data, Key.space);
33
+ }
34
+
35
+ function isRKey(data: string): boolean {
36
+ return data === 'r' || data === 'R' || matchesKey(data, 'r');
37
+ }
38
+
39
+ function getRenderOptions(question: NormalizedQuestion): RenderOption[] {
40
+ const listed: RenderOption[] = question.options.map((option) => ({
41
+ kind: 'listed',
42
+ value: option.value,
43
+ label: option.label,
44
+ description: option.description,
45
+ }));
46
+
47
+ if (!question.allowOther) return listed;
48
+
49
+ listed.push({ kind: 'other', value: '__other__', label: 'Other' });
50
+ return listed;
51
+ }
52
+
53
+ function ensureQuestionState(
54
+ stateById: Record<string, QuestionSelectionState>,
55
+ questionId: string,
56
+ ): QuestionSelectionState {
57
+ stateById[questionId] ??= { listedSelectedValues: [], otherText: '', wasOtherSelected: false };
58
+ return stateById[questionId];
59
+ }
60
+
61
+ export async function runQuestionnaireUI(
62
+ ctx: Pick<ExtensionContext, 'ui'>,
63
+ questions: NormalizedQuestion[],
64
+ ): Promise<QuestionnaireResult> {
65
+ const reviewTabIndex = questions.length;
66
+
67
+ return ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
68
+ const uiState: QuestionnaireUIState = {
69
+ activeTabIndex: 0,
70
+ lastQuestionTabIndex: 0,
71
+ questionOptionCursorById: Object.fromEntries(questions.map((question) => [question.id, 0])),
72
+ reviewCursor: 0,
73
+ inputMode: 'navigate',
74
+ editingQuestionId: undefined,
75
+ returnToReview: false,
76
+ returnReviewCursor: 0,
77
+ questionStateById: createInitialQuestionStateById(questions),
78
+ };
79
+
80
+ const editorTheme: EditorTheme = {
81
+ borderColor: (text) => theme.fg('accent', text),
82
+ selectList: {
83
+ selectedPrefix: (text) => theme.fg('accent', text),
84
+ selectedText: (text) => theme.fg('accent', text),
85
+ description: (text) => theme.fg('muted', text),
86
+ scrollInfo: (text) => theme.fg('dim', text),
87
+ noMatch: (text) => theme.fg('warning', text),
88
+ },
89
+ };
90
+
91
+ const editor = new Editor(tui, editorTheme);
92
+
93
+ let cachedWidth: number | undefined;
94
+ let cachedLines: string[] | undefined;
95
+
96
+ editor.onSubmit = (value) => {
97
+ if (!uiState.editingQuestionId) return;
98
+ const selection = ensureQuestionState(uiState.questionStateById, uiState.editingQuestionId);
99
+ selection.wasOtherSelected = true;
100
+ selection.otherText = value.trim();
101
+ uiState.inputMode = 'navigate';
102
+ uiState.editingQuestionId = undefined;
103
+ invalidate();
104
+ };
105
+
106
+ function invalidate() {
107
+ cachedWidth = undefined;
108
+ cachedLines = undefined;
109
+ tui.requestRender();
110
+ }
111
+
112
+ function finalize(cancelled: boolean) {
113
+ done({
114
+ questions,
115
+ answers: normalizeAnswers(questions, uiState.questionStateById),
116
+ cancelled,
117
+ });
118
+ }
119
+
120
+ function clearInputMode() {
121
+ uiState.inputMode = 'navigate';
122
+ uiState.editingQuestionId = undefined;
123
+ }
124
+
125
+ function getActiveQuestion(): NormalizedQuestion | undefined {
126
+ if (uiState.activeTabIndex >= questions.length) return undefined;
127
+ return questions[uiState.activeTabIndex];
128
+ }
129
+
130
+ function switchTabs(delta: number) {
131
+ if (questions.length === 0) return;
132
+ const totalTabs = questions.length + 1;
133
+ const nextTab = (uiState.activeTabIndex + delta + totalTabs) % totalTabs;
134
+
135
+ if (uiState.activeTabIndex < questions.length) {
136
+ uiState.lastQuestionTabIndex = uiState.activeTabIndex;
137
+ }
138
+
139
+ clearInputMode();
140
+
141
+ if (nextTab < questions.length) {
142
+ uiState.lastQuestionTabIndex = nextTab;
143
+ }
144
+
145
+ uiState.activeTabIndex = nextTab;
146
+ invalidate();
147
+ }
148
+
149
+ function jumpToReview() {
150
+ if (uiState.activeTabIndex < questions.length) {
151
+ uiState.lastQuestionTabIndex = uiState.activeTabIndex;
152
+ }
153
+
154
+ if (uiState.returnToReview) {
155
+ uiState.reviewCursor = clamp(
156
+ uiState.returnReviewCursor,
157
+ 0,
158
+ Math.max(0, questions.length - 1),
159
+ );
160
+ }
161
+
162
+ uiState.returnToReview = false;
163
+ uiState.activeTabIndex = reviewTabIndex;
164
+ clearInputMode();
165
+ invalidate();
166
+ }
167
+
168
+ function handleQuestionSelection(question: NormalizedQuestion, option: RenderOption) {
169
+ const selection = ensureQuestionState(uiState.questionStateById, question.id);
170
+
171
+ if (option.kind === 'other') {
172
+ if (question.selectionMode === 'single') {
173
+ selection.listedSelectedValues = [];
174
+ }
175
+ selection.wasOtherSelected = true;
176
+ uiState.inputMode = 'otherInput';
177
+ uiState.editingQuestionId = question.id;
178
+ editor.setText(selection.otherText);
179
+ invalidate();
180
+ return;
181
+ }
182
+
183
+ if (question.selectionMode === 'single') {
184
+ selection.listedSelectedValues = [option.value];
185
+ selection.wasOtherSelected = false;
186
+ selection.otherText = '';
187
+ } else {
188
+ const existing = new Set(selection.listedSelectedValues);
189
+ if (existing.has(option.value)) existing.delete(option.value);
190
+ else existing.add(option.value);
191
+ selection.listedSelectedValues = question.options
192
+ .map((item) => item.value)
193
+ .filter((value) => existing.has(value));
194
+ }
195
+
196
+ invalidate();
197
+ }
198
+
199
+ function jumpFromReviewToQuestion() {
200
+ const targetIndex = clamp(uiState.reviewCursor, 0, Math.max(0, questions.length - 1));
201
+ uiState.returnReviewCursor = targetIndex;
202
+ uiState.returnToReview = true;
203
+ uiState.activeTabIndex = targetIndex;
204
+ uiState.lastQuestionTabIndex = targetIndex;
205
+ clearInputMode();
206
+ invalidate();
207
+ }
208
+
209
+ function moveQuestionCursor(delta: number) {
210
+ const question = getActiveQuestion();
211
+ if (!question) return;
212
+ const options = getRenderOptions(question);
213
+ if (options.length === 0) return;
214
+ const currentCursor = uiState.questionOptionCursorById[question.id] ?? 0;
215
+ uiState.questionOptionCursorById[question.id] = clamp(
216
+ currentCursor + delta,
217
+ 0,
218
+ options.length - 1,
219
+ );
220
+ invalidate();
221
+ }
222
+
223
+ function handleInput(data: string) {
224
+ if (uiState.inputMode === 'otherInput' && matchesKey(data, Key.escape)) {
225
+ clearInputMode();
226
+ invalidate();
227
+ return;
228
+ }
229
+
230
+ if (matchesKey(data, Key.left)) {
231
+ switchTabs(-1);
232
+ return;
233
+ }
234
+
235
+ if (matchesKey(data, Key.right)) {
236
+ switchTabs(1);
237
+ return;
238
+ }
239
+
240
+ if (isRKey(data)) {
241
+ jumpToReview();
242
+ return;
243
+ }
244
+
245
+ if (uiState.inputMode === 'otherInput') {
246
+ editor.handleInput(data);
247
+ invalidate();
248
+ return;
249
+ }
250
+
251
+ if (uiState.activeTabIndex === reviewTabIndex) {
252
+ if (matchesKey(data, Key.up)) {
253
+ uiState.reviewCursor = clamp(
254
+ uiState.reviewCursor - 1,
255
+ 0,
256
+ Math.max(0, questions.length - 1),
257
+ );
258
+ invalidate();
259
+ return;
260
+ }
261
+
262
+ if (matchesKey(data, Key.down)) {
263
+ uiState.reviewCursor = clamp(
264
+ uiState.reviewCursor + 1,
265
+ 0,
266
+ Math.max(0, questions.length - 1),
267
+ );
268
+ invalidate();
269
+ return;
270
+ }
271
+
272
+ if (isSpaceKey(data)) {
273
+ jumpFromReviewToQuestion();
274
+ return;
275
+ }
276
+
277
+ if (matchesKey(data, Key.enter)) {
278
+ if (areAllAnswersValid(questions, uiState.questionStateById)) {
279
+ finalize(false);
280
+ }
281
+ return;
282
+ }
283
+
284
+ if (matchesKey(data, Key.escape)) {
285
+ uiState.activeTabIndex = clamp(
286
+ uiState.lastQuestionTabIndex,
287
+ 0,
288
+ Math.max(0, questions.length - 1),
289
+ );
290
+ uiState.returnToReview = false;
291
+ invalidate();
292
+ }
293
+
294
+ return;
295
+ }
296
+
297
+ const question = getActiveQuestion();
298
+ if (!question) return;
299
+
300
+ if (matchesKey(data, Key.up)) {
301
+ moveQuestionCursor(-1);
302
+ return;
303
+ }
304
+
305
+ if (matchesKey(data, Key.down)) {
306
+ moveQuestionCursor(1);
307
+ return;
308
+ }
309
+
310
+ if (isSpaceKey(data)) {
311
+ const options = getRenderOptions(question);
312
+ const cursor = clamp(
313
+ uiState.questionOptionCursorById[question.id] ?? 0,
314
+ 0,
315
+ options.length - 1,
316
+ );
317
+ const option = options[cursor];
318
+ if (!option) return;
319
+ handleQuestionSelection(question, option);
320
+ return;
321
+ }
322
+
323
+ if (matchesKey(data, Key.escape)) {
324
+ finalize(true);
325
+ }
326
+ }
327
+
328
+ function renderTabs(width: number, lines: string[]) {
329
+ const allValid = areAllAnswersValid(questions, uiState.questionStateById);
330
+ const renderedTabs = questions.map((question, index) => {
331
+ const state = ensureQuestionState(uiState.questionStateById, question.id);
332
+ const answered = isAnswerValid(question, state);
333
+ const marker = answered ? '■' : '□';
334
+ const text = ` ${marker} ${question.label} `;
335
+ if (uiState.activeTabIndex === index) {
336
+ return theme.bg('selectedBg', theme.fg('text', text));
337
+ }
338
+ return theme.fg(answered ? 'success' : 'muted', text);
339
+ });
340
+
341
+ const reviewText = allValid ? REVIEW_LABEL_READY : REVIEW_LABEL_PENDING;
342
+ const reviewStyled =
343
+ uiState.activeTabIndex === reviewTabIndex
344
+ ? theme.bg('selectedBg', theme.fg('text', reviewText))
345
+ : theme.fg(allValid ? 'success' : 'muted', reviewText);
346
+
347
+ const tabLine = renderedTabs.concat(reviewStyled).join(' ');
348
+ lines.push(truncateToWidth(tabLine, width));
349
+ lines.push('');
350
+ }
351
+
352
+ function renderQuestionBody(width: number, lines: string[], question: NormalizedQuestion) {
353
+ const selection = ensureQuestionState(uiState.questionStateById, question.id);
354
+ const options = getRenderOptions(question);
355
+ const cursor = clamp(
356
+ uiState.questionOptionCursorById[question.id] ?? 0,
357
+ 0,
358
+ options.length - 1,
359
+ );
360
+
361
+ lines.push(truncateToWidth(theme.fg('text', question.prompt), width));
362
+ lines.push('');
363
+
364
+ for (const [index, option] of options.entries()) {
365
+ const isCursor = cursor === index;
366
+ const prefix = isCursor ? theme.fg('accent', '> ') : ' ';
367
+
368
+ if (option.kind === 'other') {
369
+ const activeOtherText = selection.otherText.trim();
370
+ const label = activeOtherText
371
+ ? `Other: "${activeOtherText}"`
372
+ : selection.wasOtherSelected
373
+ ? 'Other (empty)'
374
+ : 'Other';
375
+ const marker = selection.wasOtherSelected ? '[x]' : '[ ]';
376
+ const color = selection.wasOtherSelected ? 'accent' : 'text';
377
+ lines.push(truncateToWidth(`${prefix}${theme.fg(color, `${marker} ${label}`)}`, width));
378
+ continue;
379
+ }
380
+
381
+ const selected = selection.listedSelectedValues.includes(option.value);
382
+ const marker = selected ? '[x]' : '[ ]';
383
+ const color = selected ? 'accent' : 'text';
384
+ lines.push(
385
+ truncateToWidth(`${prefix}${theme.fg(color, `${marker} ${option.label}`)}`, width),
386
+ );
387
+
388
+ if (option.description) {
389
+ lines.push(truncateToWidth(` ${theme.fg('muted', option.description)}`, width));
390
+ }
391
+ }
392
+
393
+ if (uiState.inputMode === 'otherInput' && uiState.editingQuestionId === question.id) {
394
+ lines.push('');
395
+ lines.push(truncateToWidth(theme.fg('muted', 'Other input:'), width));
396
+ for (const line of editor.render(Math.max(10, width - 2))) {
397
+ lines.push(truncateToWidth(` ${line}`, width));
398
+ }
399
+ }
400
+ }
401
+
402
+ function renderReviewBody(width: number, lines: string[]) {
403
+ const ready = areAllAnswersValid(questions, uiState.questionStateById);
404
+ const normalizedAnswers = normalizeAnswers(questions, uiState.questionStateById);
405
+
406
+ lines.push(truncateToWidth(theme.fg('accent', theme.bold('Review answers')), width));
407
+ lines.push('');
408
+
409
+ for (const [index, question] of questions.entries()) {
410
+ const answer = normalizedAnswers[index];
411
+ const valid = isAnswerValid(
412
+ question,
413
+ ensureQuestionState(uiState.questionStateById, question.id),
414
+ );
415
+ const cursor = uiState.reviewCursor === index ? theme.fg('accent', '> ') : ' ';
416
+ const marker = valid ? theme.fg('success', '■') : theme.fg('warning', '□');
417
+ const value = formatAnswerValue(answer);
418
+ const color = valid ? 'text' : 'muted';
419
+ lines.push(
420
+ truncateToWidth(
421
+ `${cursor}${marker} ${theme.fg('accent', `${question.label}:`)} ${theme.fg(color, value)}`,
422
+ width,
423
+ ),
424
+ );
425
+ }
426
+
427
+ lines.push('');
428
+ lines.push(
429
+ truncateToWidth(
430
+ ready
431
+ ? theme.fg('success', 'Press Enter to submit')
432
+ : theme.fg('warning', 'Complete all questions before submitting.'),
433
+ width,
434
+ ),
435
+ );
436
+ }
437
+
438
+ function renderHint(width: number, lines: string[]) {
439
+ let hint = '';
440
+
441
+ if (uiState.inputMode === 'otherInput') {
442
+ hint = 'Enter submits Other text • Esc exits input mode';
443
+ } else if (uiState.activeTabIndex === reviewTabIndex) {
444
+ hint = '←→ tabs • ↑↓ review row • Space edit • Enter submit • Esc back';
445
+ } else if (uiState.returnToReview) {
446
+ hint = 'Editing from Review • press r to return';
447
+ } else {
448
+ hint = '←→ tabs • ↑↓ options • Space select/edit • r review • Esc cancel';
449
+ }
450
+
451
+ lines.push('');
452
+ lines.push(truncateToWidth(theme.fg('dim', hint), width));
453
+ }
454
+
455
+ function render(width: number): string[] {
456
+ if (cachedLines && cachedWidth === width) {
457
+ return cachedLines;
458
+ }
459
+
460
+ const lines: string[] = [];
461
+
462
+ renderTabs(width, lines);
463
+
464
+ const activeQuestion = getActiveQuestion();
465
+ if (activeQuestion) {
466
+ renderQuestionBody(width, lines, activeQuestion);
467
+ } else {
468
+ renderReviewBody(width, lines);
469
+ }
470
+
471
+ renderHint(width, lines);
472
+
473
+ cachedWidth = width;
474
+ cachedLines = lines;
475
+ return lines;
476
+ }
477
+
478
+ return {
479
+ render,
480
+ handleInput,
481
+ invalidate: () => {
482
+ cachedWidth = undefined;
483
+ cachedLines = undefined;
484
+ },
485
+ };
486
+ });
487
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@dreki-gg/pi-questionnaire",
3
+ "version": "0.1.0",
4
+ "description": "Tool-first questionnaire flow for pi with shared tabbed TUI and custom rendering",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "author": "Juan Albarran <jalbarrandev@gmail.com>",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/dreki-gg/pi-extensions",
13
+ "directory": "packages/questionnaire"
14
+ },
15
+ "type": "module",
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "lint": "oxlint extensions",
19
+ "format": "oxfmt --write extensions",
20
+ "format:check": "oxfmt --check extensions"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions/questionnaire"
25
+ ]
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "24",
29
+ "oxfmt": "^0.43.0",
30
+ "oxlint": "^1.58.0",
31
+ "typescript": "^6.0.0"
32
+ },
33
+ "peerDependencies": {
34
+ "@mariozechner/pi-ai": "*",
35
+ "@mariozechner/pi-coding-agent": "*",
36
+ "@mariozechner/pi-tui": "*",
37
+ "@sinclair/typebox": "*"
38
+ }
39
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "esModuleInterop": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "incremental": true,
13
+ "tsBuildInfoFile": "./node_modules/.cache/questionnaire.tsbuildinfo",
14
+ "rootDir": "."
15
+ },
16
+ "include": ["extensions/**/*.ts"]
17
+ }