@geoqiao/pi-ask 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,326 @@
1
+ import type {
2
+ AskOption,
3
+ AskParams,
4
+ AskQuestion,
5
+ AskQuestionInput,
6
+ AskValidationIssue,
7
+ } from "../types.ts";
8
+
9
+ interface IssueCollector {
10
+ add: (path: string, message: string) => void;
11
+ issues: AskValidationIssue[];
12
+ }
13
+
14
+ interface ValidationOptions {
15
+ allowFreeform?: boolean;
16
+ presentSingleAsMulti?: boolean;
17
+ }
18
+
19
+ export function normalizeQuestions(
20
+ params: AskParams,
21
+ options: ValidationOptions = {}
22
+ ): AskQuestion[] {
23
+ const issues = collectValidationIssues(params, options);
24
+ if (issues.length > 0) {
25
+ throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
26
+ }
27
+ return params.questions.map((question, index) =>
28
+ normalizeQuestion(question, index, options)
29
+ );
30
+ }
31
+
32
+ export function collectValidationIssues(
33
+ params: AskParams,
34
+ options: ValidationOptions = {}
35
+ ): AskValidationIssue[] {
36
+ const collector = createIssueCollector();
37
+ validateQuestions(params.questions, collector, options);
38
+ return collector.issues;
39
+ }
40
+
41
+ function normalizeQuestion(
42
+ question: AskQuestionInput,
43
+ index: number,
44
+ options: ValidationOptions = {}
45
+ ): AskQuestion {
46
+ const requestedType = normalizeQuestionType(question.type);
47
+ const presentedType =
48
+ options.presentSingleAsMulti && requestedType === "single"
49
+ ? "multi"
50
+ : requestedType;
51
+ return {
52
+ id: question.id.trim(),
53
+ label: question.label?.trim() || `Q${index + 1}`,
54
+ prompt: question.prompt.trim(),
55
+ type: presentedType,
56
+ ...(presentedType === requestedType
57
+ ? {}
58
+ : { requestedType, presentedType }),
59
+ required: question.required ?? false,
60
+ options: question.options.map(normalizeOption),
61
+ };
62
+ }
63
+
64
+ function normalizeOption(option: AskOption): AskOption {
65
+ return {
66
+ value: option.value.trim(),
67
+ label: option.label.trim(),
68
+ description: option.description?.trim(),
69
+ preview: option.preview?.trim(),
70
+ ...(option.freeform ? { freeform: true } : {}),
71
+ };
72
+ }
73
+
74
+ function validateQuestions(
75
+ questions: AskParams["questions"],
76
+ collector: IssueCollector,
77
+ options: ValidationOptions
78
+ ) {
79
+ if (questions.length === 0) {
80
+ collector.add("questions", "At least one question is required");
81
+ return;
82
+ }
83
+
84
+ const questionIds = new Set<string>();
85
+ for (const [questionIndex, question] of questions.entries()) {
86
+ validateQuestion(question, questionIndex, questionIds, collector, options);
87
+ }
88
+ }
89
+
90
+ function validateQuestion(
91
+ question: AskQuestionInput,
92
+ questionIndex: number,
93
+ questionIds: Set<string>,
94
+ collector: IssueCollector,
95
+ options: ValidationOptions
96
+ ) {
97
+ const questionNumber = questionIndex + 1;
98
+ const questionPath = `questions[${questionIndex}]`;
99
+ const questionId = question.id?.trim();
100
+ const questionType = normalizeQuestionType(question.type);
101
+
102
+ validateQuestionType(
103
+ question.type,
104
+ questionNumber,
105
+ collector,
106
+ `${questionPath}.type`
107
+ );
108
+ assertRequired(
109
+ questionId,
110
+ collector,
111
+ `${questionPath}.id`,
112
+ `Question ${questionNumber}: id is required`
113
+ );
114
+ assertUnique(
115
+ questionIds,
116
+ questionId,
117
+ collector,
118
+ `${questionPath}.id`,
119
+ `Question ${questionNumber}: duplicate question id "${questionId}"`
120
+ );
121
+ assertOptionalText(
122
+ question.label,
123
+ collector,
124
+ `${questionPath}.label`,
125
+ `Question ${questionNumber}: label must not be empty`
126
+ );
127
+ assertRequired(
128
+ question.prompt?.trim(),
129
+ collector,
130
+ `${questionPath}.prompt`,
131
+ `Question ${questionNumber}: prompt is required`
132
+ );
133
+ assertHasItems(
134
+ question.options,
135
+ collector,
136
+ `${questionPath}.options`,
137
+ `Question ${questionNumber}: at least one option is required`
138
+ );
139
+
140
+ validateFreeformOptions(
141
+ question.options,
142
+ questionNumber,
143
+ collector,
144
+ `${questionPath}.options`,
145
+ options
146
+ );
147
+
148
+ const optionValues = new Set<string>();
149
+ for (const [optionIndex, option] of question.options.entries()) {
150
+ validateOption(
151
+ option,
152
+ optionIndex,
153
+ optionValues,
154
+ questionNumber,
155
+ questionType,
156
+ collector,
157
+ `${questionPath}.options[${optionIndex}]`
158
+ );
159
+ }
160
+ }
161
+
162
+ function validateFreeformOptions(
163
+ options: AskOption[],
164
+ questionNumber: number,
165
+ collector: IssueCollector,
166
+ path: string,
167
+ validationOptions: ValidationOptions
168
+ ) {
169
+ const freeformCount = options.filter((option) => option.freeform).length;
170
+ if (freeformCount === 0) {
171
+ return;
172
+ }
173
+ if (!validationOptions.allowFreeform) {
174
+ collector.add(
175
+ path,
176
+ `Question ${questionNumber}: freeform options are only supported for /answer forms`
177
+ );
178
+ return;
179
+ }
180
+ if (freeformCount > 1 || options.length > 1) {
181
+ collector.add(
182
+ path,
183
+ `Question ${questionNumber}: freeform options must be the only option`
184
+ );
185
+ }
186
+ }
187
+
188
+ function validateOption(
189
+ option: AskOption,
190
+ optionIndex: number,
191
+ optionValues: Set<string>,
192
+ questionNumber: number,
193
+ questionType: AskQuestion["type"],
194
+ collector: IssueCollector,
195
+ optionPath: string
196
+ ) {
197
+ const optionNumber = optionIndex + 1;
198
+ const prefix = `Question ${questionNumber}, option ${optionNumber}`;
199
+ const optionValue = option.value?.trim();
200
+ const optionPreview = option.preview?.trim();
201
+
202
+ assertRequired(
203
+ optionValue,
204
+ collector,
205
+ `${optionPath}.value`,
206
+ `${prefix}: value is required`
207
+ );
208
+ assertUnique(
209
+ optionValues,
210
+ optionValue,
211
+ collector,
212
+ `${optionPath}.value`,
213
+ `${prefix}: duplicate option value "${optionValue}"`
214
+ );
215
+ assertRequired(
216
+ option.label?.trim(),
217
+ collector,
218
+ `${optionPath}.label`,
219
+ `${prefix}: label is required`
220
+ );
221
+ assertOptionalText(
222
+ option.description,
223
+ collector,
224
+ `${optionPath}.description`,
225
+ `${prefix}: description must not be empty`
226
+ );
227
+ assertOptionalText(
228
+ option.preview,
229
+ collector,
230
+ `${optionPath}.preview`,
231
+ `${prefix}: preview must not be empty`
232
+ );
233
+ if (questionType === "preview") {
234
+ assertRequired(
235
+ optionPreview,
236
+ collector,
237
+ `${optionPath}.preview`,
238
+ `${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`
239
+ );
240
+ }
241
+ }
242
+
243
+ function normalizeQuestionType(
244
+ value: AskQuestionInput["type"]
245
+ ): AskQuestion["type"] {
246
+ return value === "multi" || value === "preview" ? value : "single";
247
+ }
248
+
249
+ function validateQuestionType(
250
+ value: AskQuestionInput["type"],
251
+ questionNumber: number,
252
+ collector: IssueCollector,
253
+ path: string
254
+ ) {
255
+ if (
256
+ value !== undefined &&
257
+ value !== "single" &&
258
+ value !== "multi" &&
259
+ value !== "preview"
260
+ ) {
261
+ collector.add(
262
+ path,
263
+ `Question ${questionNumber}: invalid type ${JSON.stringify(value)}; expected "single", "multi", or "preview"`
264
+ );
265
+ }
266
+ }
267
+
268
+ function assertHasItems(
269
+ items: unknown[],
270
+ collector: IssueCollector,
271
+ path: string,
272
+ message: string
273
+ ) {
274
+ if (items.length === 0) {
275
+ collector.add(path, message);
276
+ }
277
+ }
278
+
279
+ function assertRequired(
280
+ value: string | undefined,
281
+ collector: IssueCollector,
282
+ path: string,
283
+ message: string
284
+ ) {
285
+ if (!value) {
286
+ collector.add(path, message);
287
+ }
288
+ }
289
+
290
+ function assertOptionalText(
291
+ value: string | undefined,
292
+ collector: IssueCollector,
293
+ path: string,
294
+ message: string
295
+ ) {
296
+ if (value !== undefined && !value.trim()) {
297
+ collector.add(path, message);
298
+ }
299
+ }
300
+
301
+ function assertUnique(
302
+ seen: Set<string>,
303
+ value: string | undefined,
304
+ collector: IssueCollector,
305
+ path: string,
306
+ message: string
307
+ ) {
308
+ if (!value) {
309
+ return;
310
+ }
311
+ if (seen.has(value)) {
312
+ collector.add(path, message);
313
+ return;
314
+ }
315
+ seen.add(value);
316
+ }
317
+
318
+ function createIssueCollector(): IssueCollector {
319
+ const issues: AskValidationIssue[] = [];
320
+ return {
321
+ issues,
322
+ add(path, message) {
323
+ issues.push({ path, message });
324
+ },
325
+ };
326
+ }
@@ -0,0 +1,128 @@
1
+ import type { AskQuestionType, AskState, AskStateAnswer } from "../types.ts";
2
+ import { isAnswerEmpty } from "./answers.ts";
3
+ import { getAnswer, getCurrentQuestion, isSubmitTab } from "./selectors.ts";
4
+
5
+ export interface QuestionTypeChangeResult {
6
+ needsConfirmation: boolean;
7
+ notice?: string;
8
+ state: AskState;
9
+ }
10
+
11
+ export function cycleCurrentQuestionType(
12
+ state: AskState,
13
+ options: { confirmed?: boolean } = {}
14
+ ): QuestionTypeChangeResult {
15
+ if (isSubmitTab(state)) {
16
+ return { needsConfirmation: false, state };
17
+ }
18
+
19
+ const question = getCurrentQuestion(state);
20
+ if (!question) {
21
+ return { needsConfirmation: false, state };
22
+ }
23
+
24
+ const nextType = getNextQuestionType(question);
25
+ if (nextType === question.type) {
26
+ return { needsConfirmation: false, state };
27
+ }
28
+
29
+ const answer = getAnswer(state, question.id);
30
+ const requiresConfirmation =
31
+ nextType === "single" && countCommittedAnswers(answer) > 1;
32
+ if (requiresConfirmation && !options.confirmed) {
33
+ return {
34
+ needsConfirmation: true,
35
+ notice:
36
+ "Switching to single-select will clear selected options for this question. Press the type key again to confirm.",
37
+ state,
38
+ };
39
+ }
40
+
41
+ return {
42
+ needsConfirmation: false,
43
+ notice: getQuestionTypeChangeNotice(question.type, nextType),
44
+ state: setQuestionType(state, question.id, nextType, {
45
+ clearSelectedOptions: requiresConfirmation,
46
+ }),
47
+ };
48
+ }
49
+
50
+ function getNextQuestionType(question: {
51
+ requestedType?: AskQuestionType;
52
+ type: AskQuestionType;
53
+ }): AskQuestionType {
54
+ const requestedType = question.requestedType ?? question.type;
55
+ if (requestedType === "preview") {
56
+ return question.type === "preview" ? "multi" : "preview";
57
+ }
58
+ switch (question.type) {
59
+ case "single":
60
+ return "multi";
61
+ case "multi":
62
+ return "single";
63
+ case "preview":
64
+ return "multi";
65
+ default:
66
+ return "multi";
67
+ }
68
+ }
69
+
70
+ function countCommittedAnswers(answer: AskStateAnswer | undefined): number {
71
+ if (!answer) {
72
+ return 0;
73
+ }
74
+ return (
75
+ answer.selected.length +
76
+ (answer.customSelected && answer.customText?.trim() ? 1 : 0)
77
+ );
78
+ }
79
+
80
+ function setQuestionType(
81
+ state: AskState,
82
+ questionId: string,
83
+ type: AskQuestionType,
84
+ options: { clearSelectedOptions: boolean }
85
+ ): AskState {
86
+ const questions = state.questions.map((question) => {
87
+ if (question.id !== questionId) {
88
+ return question;
89
+ }
90
+ const requestedType = question.requestedType ?? question.type;
91
+ return {
92
+ ...question,
93
+ type,
94
+ requestedType,
95
+ presentedType: type === requestedType ? undefined : type,
96
+ };
97
+ });
98
+
99
+ const currentAnswer = state.answers[questionId];
100
+ if (!(options.clearSelectedOptions && currentAnswer)) {
101
+ return { ...state, questions };
102
+ }
103
+
104
+ const nextAnswer = {
105
+ ...currentAnswer,
106
+ customSelected: currentAnswer.customText?.trim() ? true : undefined,
107
+ selected: [],
108
+ };
109
+ const answers = { ...state.answers };
110
+ if (isAnswerEmpty(nextAnswer)) {
111
+ delete answers[questionId];
112
+ } else {
113
+ answers[questionId] = nextAnswer;
114
+ }
115
+
116
+ return {
117
+ ...state,
118
+ questions,
119
+ answers,
120
+ };
121
+ }
122
+
123
+ function getQuestionTypeChangeNotice(
124
+ from: AskQuestionType,
125
+ to: AskQuestionType
126
+ ): string {
127
+ return `Question type changed from ${from} to ${to}.`;
128
+ }
@@ -0,0 +1,263 @@
1
+ import {
2
+ CANCELLED_SUMMARY,
3
+ ELABORATED_SUMMARY,
4
+ ELABORATION_INSTRUCTION,
5
+ SUBMITTED_SUMMARY,
6
+ } from "../constants/text.ts";
7
+ import { formatElaborationLines, formatResultLines } from "../result-format.ts";
8
+ import type {
9
+ AskContinuationPayload,
10
+ AskElaborationPayload,
11
+ AskResult,
12
+ AskResultAnswer,
13
+ AskState,
14
+ AskStateAnswer,
15
+ } from "../types.ts";
16
+ import {
17
+ cloneResultAnswer,
18
+ getExtraOptionNotes,
19
+ hasAnswerNotes,
20
+ isAnswerAnswered,
21
+ isAnswerEmpty,
22
+ isOptionSelected,
23
+ isResultAnswerCommitted,
24
+ isResultAnswerEmpty,
25
+ serializeAnswer,
26
+ } from "./answers.ts";
27
+ import { getQuestionOptionByValue } from "./selectors.ts";
28
+
29
+ export type ReviewAnswer = AskResult["answers"][string] & {
30
+ extraOptionNotes?: Array<{
31
+ label: string;
32
+ note: string;
33
+ }>;
34
+ };
35
+
36
+ export function toAskResult(state: AskState): AskResult {
37
+ const answers = Object.fromEntries(
38
+ Object.entries(state.answers)
39
+ .map(
40
+ ([questionId, answer]) => [questionId, serializeAnswer(answer)] as const
41
+ )
42
+ .filter(([, answer]) =>
43
+ state.mode === "elaborate"
44
+ ? isResultAnswerCommitted(answer)
45
+ : !isResultAnswerEmpty(answer)
46
+ )
47
+ );
48
+
49
+ return {
50
+ title: state.title,
51
+ cancelled: state.cancelled,
52
+ mode: state.mode,
53
+ questions: state.questions.map((question) => ({
54
+ id: question.id,
55
+ label: question.label,
56
+ prompt: question.prompt,
57
+ type: question.requestedType ?? question.type,
58
+ ...(question.presentedType &&
59
+ question.presentedType !== question.requestedType
60
+ ? { presentedType: question.presentedType }
61
+ : {}),
62
+ })),
63
+ answers,
64
+ continuation:
65
+ state.mode === "elaborate"
66
+ ? serializeContinuation(state, answers)
67
+ : undefined,
68
+ elaboration:
69
+ state.mode === "elaborate" ? serializeElaboration(state) : undefined,
70
+ };
71
+ }
72
+
73
+ export function summarizeResult(result: AskResult): string {
74
+ if (result.cancelled) {
75
+ return CANCELLED_SUMMARY;
76
+ }
77
+ if (result.mode === "elaborate") {
78
+ const lines = formatElaborationLines(result, { mode: "summary" });
79
+ return lines.join("\n") || ELABORATED_SUMMARY;
80
+ }
81
+
82
+ const lines = formatResultLines(result, { mode: "summary" });
83
+ return lines.join("\n") || SUBMITTED_SUMMARY;
84
+ }
85
+
86
+ export function hasAnswerContent(state: AskState, questionId: string): boolean {
87
+ const answer = state.answers[questionId];
88
+ return !!answer && !isAnswerEmpty(answer);
89
+ }
90
+
91
+ function serializeContinuation(
92
+ state: AskState,
93
+ answers: AskResult["answers"]
94
+ ): AskContinuationPayload {
95
+ const affectedQuestionIds: string[] = [];
96
+ const preservedAnswers: AskContinuationPayload["preservedAnswers"] = {};
97
+ const questionStates: AskContinuationPayload["questionStates"] = {};
98
+
99
+ for (const question of state.questions) {
100
+ const answer = state.answers[question.id];
101
+ const hasClarificationNeed = hasAnswerNotes(answer);
102
+ const committedAnswer = answers[question.id];
103
+ const answered = !!committedAnswer;
104
+
105
+ if (hasClarificationNeed) {
106
+ affectedQuestionIds.push(question.id);
107
+ questionStates[question.id] = { status: "needs_clarification" };
108
+ continue;
109
+ }
110
+
111
+ if (answered) {
112
+ preservedAnswers[question.id] = cloneResultAnswer(committedAnswer);
113
+ questionStates[question.id] = { status: "answered" };
114
+ continue;
115
+ }
116
+
117
+ questionStates[question.id] = { status: "unanswered" };
118
+ }
119
+
120
+ return {
121
+ affectedQuestionIds,
122
+ preservedAnswers,
123
+ questionStates,
124
+ strategy: "refine_only",
125
+ };
126
+ }
127
+
128
+ function serializeElaboration(state: AskState): AskElaborationPayload {
129
+ const items = state.questions.flatMap((question) =>
130
+ serializeElaborationItemsForQuestion(question, state.answers[question.id])
131
+ );
132
+
133
+ return {
134
+ instruction: ELABORATION_INSTRUCTION,
135
+ nextAction: "clarify_then_reask",
136
+ items,
137
+ };
138
+ }
139
+
140
+ function serializeElaborationItemsForQuestion(
141
+ question: AskState["questions"][number],
142
+ answer: AskStateAnswer | undefined
143
+ ): AskElaborationPayload["items"] {
144
+ if (!(answer && hasAnswerNotes(answer))) {
145
+ return [];
146
+ }
147
+
148
+ const questionContext = createElaborationQuestionContext(question);
149
+ const serializedAnswer = toCommittedResultAnswer(answer);
150
+ const answered = isAnswerAnswered(answer);
151
+ const items: AskElaborationPayload["items"] = [];
152
+
153
+ if (answer.note) {
154
+ items.push({
155
+ target: { kind: "question" },
156
+ question: questionContext,
157
+ answered,
158
+ answer: serializedAnswer,
159
+ note: answer.note,
160
+ });
161
+ }
162
+
163
+ for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
164
+ const option = getQuestionOptionByValue(question, value);
165
+ if (!(option && note)) {
166
+ continue;
167
+ }
168
+
169
+ items.push({
170
+ target: {
171
+ kind: "option",
172
+ optionValue: value,
173
+ },
174
+ question: questionContext,
175
+ option: cloneOption(option),
176
+ selected: isOptionSelected(answer, value),
177
+ answered,
178
+ answer: serializedAnswer,
179
+ note,
180
+ });
181
+ }
182
+
183
+ return items;
184
+ }
185
+
186
+ function createElaborationQuestionContext(
187
+ question: AskState["questions"][number]
188
+ ) {
189
+ return {
190
+ id: question.id,
191
+ label: question.label,
192
+ prompt: question.prompt,
193
+ type: question.requestedType ?? question.type,
194
+ ...(question.presentedType &&
195
+ question.presentedType !== question.requestedType
196
+ ? { presentedType: question.presentedType }
197
+ : {}),
198
+ options: question.options.map(cloneOption),
199
+ };
200
+ }
201
+
202
+ function cloneOption(option: AskState["questions"][number]["options"][number]) {
203
+ return {
204
+ value: option.value,
205
+ label: option.label,
206
+ ...(option.description ? { description: option.description } : {}),
207
+ ...(option.preview ? { preview: option.preview } : {}),
208
+ };
209
+ }
210
+
211
+ function toCommittedResultAnswer(
212
+ answer: AskStateAnswer | undefined
213
+ ): AskResultAnswer | undefined {
214
+ if (!(answer && isAnswerAnswered(answer))) {
215
+ return;
216
+ }
217
+ return cloneResultAnswer(serializeAnswer(answer));
218
+ }
219
+
220
+ export function toReviewAnswer(
221
+ question: AskState["questions"][number],
222
+ answer: AskStateAnswer | undefined,
223
+ showAllNotes: boolean
224
+ ): ReviewAnswer | undefined {
225
+ if (!answer) {
226
+ return;
227
+ }
228
+
229
+ const serialized = serializeAnswer(answer);
230
+ const hasCommittedAnswer = isResultAnswerCommitted(serialized);
231
+ if (!showAllNotes) {
232
+ return hasCommittedAnswer ? serialized : undefined;
233
+ }
234
+
235
+ const extraOptionNotes = getExtraOptionNotes({
236
+ answer,
237
+ questionOptions: question.options,
238
+ selectedValues: serialized.values,
239
+ });
240
+ if (
241
+ !(hasCommittedAnswer || serialized.note) &&
242
+ extraOptionNotes.length === 0
243
+ ) {
244
+ return;
245
+ }
246
+
247
+ return {
248
+ ...serialized,
249
+ extraOptionNotes:
250
+ extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
251
+ };
252
+ }
253
+
254
+ export function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
255
+ if (!answer.labels.length) {
256
+ return false;
257
+ }
258
+
259
+ return (
260
+ answer.labels.length > 1 ||
261
+ Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
262
+ );
263
+ }