@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 +134 -0
- package/extensions/questionnaire/format.ts +189 -0
- package/extensions/questionnaire/index.ts +205 -0
- package/extensions/questionnaire/schema.ts +46 -0
- package/extensions/questionnaire/types.ts +67 -0
- package/extensions/questionnaire/ui.ts +487 -0
- package/package.json +39 -0
- package/tsconfig.json +17 -0
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
|
+
}
|