@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,563 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AskQuestion,
4
+ AskResult,
5
+ AskSelectedOption,
6
+ AskState,
7
+ AskStateAnswer,
8
+ } from "./types.ts";
9
+
10
+ export const PI_ASK_STARTED_EVENT = "@eko24ive/pi-ask:started";
11
+ export const PI_ASK_COMPLETED_EVENT = "@eko24ive/pi-ask:completed";
12
+ export const PI_ASK_SUBMIT_EVENT = "@eko24ive/pi-ask:submit";
13
+ export const PI_ASK_SUBMIT_RESULT_EVENT = "@eko24ive/pi-ask:submit-result";
14
+
15
+ export type RemoteAskSource = "tool" | "answer" | "answer:again" | "ask:replay";
16
+
17
+ export interface RemoteAskAnswer {
18
+ customText?: string;
19
+ note?: string;
20
+ optionNotes?: Record<string, string>;
21
+ values?: string[];
22
+ }
23
+
24
+ export type RemoteAskResponse =
25
+ | {
26
+ answers: Record<string, RemoteAskAnswer>;
27
+ kind: "answer";
28
+ mode?: "submit" | "elaborate";
29
+ }
30
+ | { kind: "cancel" };
31
+
32
+ export interface RemoteAskStartedEvent {
33
+ createdAt: number;
34
+ flowId: string;
35
+ questions: AskQuestion[];
36
+ source: RemoteAskSource;
37
+ title?: string;
38
+ toolCallId?: string;
39
+ version: 1;
40
+ }
41
+
42
+ export interface RemoteAskCompletedEvent {
43
+ completedAt: number;
44
+ flowId: string;
45
+ result: AskResult;
46
+ source: RemoteAskSource;
47
+ toolCallId?: string;
48
+ version: 1;
49
+ }
50
+
51
+ export interface RemoteAskSubmitEvent {
52
+ flowId: string;
53
+ requestId: string;
54
+ response: RemoteAskResponse;
55
+ version: 1;
56
+ }
57
+
58
+ export type RemoteAskSubmitError =
59
+ | "flow_not_found"
60
+ | "invalid_answer"
61
+ | "invalid_request";
62
+
63
+ export type RemoteAskSubmitResultEvent = {
64
+ flowId: string;
65
+ requestId: string;
66
+ version: 1;
67
+ } & (
68
+ | { ok: true }
69
+ | { error: RemoteAskSubmitError; message: string; ok: false }
70
+ );
71
+
72
+ export interface RemoteAskFlowOptions {
73
+ source: RemoteAskSource;
74
+ toolCallId?: string;
75
+ }
76
+
77
+ export interface RemoteAskFlowInput extends RemoteAskFlowOptions {
78
+ onSubmit: (response: RemoteAskResponse) => RemoteAskSubmitResolution;
79
+ questions: AskQuestion[];
80
+ title?: string;
81
+ }
82
+
83
+ interface ActiveRemoteAskFlow extends RemoteAskFlowInput {
84
+ flowId: string;
85
+ }
86
+
87
+ export type RemoteAskSubmitResolution =
88
+ | { ok: true }
89
+ | { error: RemoteAskSubmitError; message: string; ok: false };
90
+
91
+ export interface RemoteAskFlowHandle {
92
+ complete: (result: AskResult) => void;
93
+ dispose: () => void;
94
+ flowId: string;
95
+ }
96
+
97
+ export interface RemoteAskRuntime {
98
+ disposeAll: () => void;
99
+ startFlow: (flow: RemoteAskFlowInput) => RemoteAskFlowHandle;
100
+ }
101
+
102
+ type EventBus = ExtensionAPI["events"];
103
+
104
+ export function createRemoteAskRuntime(events: EventBus): RemoteAskRuntime {
105
+ const activeFlows = new Map<string, ActiveRemoteAskFlow>();
106
+ const unsubscribeSubmit = events.on(PI_ASK_SUBMIT_EVENT, (data) => {
107
+ handleSubmitEvent(events, activeFlows, data);
108
+ });
109
+
110
+ return {
111
+ disposeAll() {
112
+ activeFlows.clear();
113
+ unsubscribeSubmit();
114
+ },
115
+ startFlow(flow) {
116
+ return startRemoteAskFlow(events, activeFlows, flow);
117
+ },
118
+ };
119
+ }
120
+
121
+ function handleSubmitEvent(
122
+ events: EventBus,
123
+ activeFlows: Map<string, ActiveRemoteAskFlow>,
124
+ data: unknown
125
+ ): void {
126
+ const request = parseSubmitEvent(data);
127
+ if (!request.ok) {
128
+ emitSubmitResult(events, {
129
+ version: 1,
130
+ requestId: request.requestId,
131
+ flowId: request.flowId,
132
+ ok: false,
133
+ error: "invalid_request",
134
+ message: request.message,
135
+ });
136
+ return;
137
+ }
138
+
139
+ const event = request.event;
140
+ const flow = activeFlows.get(event.flowId);
141
+ if (!flow) {
142
+ emitSubmitResult(events, failedSubmit(event));
143
+ return;
144
+ }
145
+
146
+ emitSubmitResult(events, {
147
+ version: 1,
148
+ requestId: event.requestId,
149
+ flowId: event.flowId,
150
+ ...flow.onSubmit(event.response),
151
+ });
152
+ }
153
+
154
+ function startRemoteAskFlow(
155
+ events: EventBus,
156
+ activeFlows: Map<string, ActiveRemoteAskFlow>,
157
+ flow: RemoteAskFlowInput
158
+ ): RemoteAskFlowHandle {
159
+ const flowId = createFlowId(flow.toolCallId);
160
+ let completed = false;
161
+ activeFlows.set(flowId, { ...flow, flowId });
162
+ queueMicrotask(() => {
163
+ if (completed || !activeFlows.has(flowId)) {
164
+ return;
165
+ }
166
+ events.emit(PI_ASK_STARTED_EVENT, {
167
+ version: 1,
168
+ flowId,
169
+ toolCallId: flow.toolCallId,
170
+ source: flow.source,
171
+ title: flow.title,
172
+ questions: cloneQuestions(flow.questions),
173
+ createdAt: Date.now(),
174
+ } satisfies RemoteAskStartedEvent);
175
+ });
176
+
177
+ return {
178
+ flowId,
179
+ complete(result) {
180
+ if (completed) {
181
+ return;
182
+ }
183
+ completed = true;
184
+ activeFlows.delete(flowId);
185
+ events.emit(PI_ASK_COMPLETED_EVENT, {
186
+ version: 1,
187
+ flowId,
188
+ toolCallId: flow.toolCallId,
189
+ source: flow.source,
190
+ result,
191
+ completedAt: Date.now(),
192
+ } satisfies RemoteAskCompletedEvent);
193
+ },
194
+ dispose() {
195
+ if (completed) {
196
+ return;
197
+ }
198
+ activeFlows.delete(flowId);
199
+ },
200
+ };
201
+ }
202
+
203
+ function cloneQuestions(questions: AskQuestion[]): AskQuestion[] {
204
+ return questions.map((question) => ({
205
+ ...question,
206
+ options: question.options.map((option) => ({ ...option })),
207
+ }));
208
+ }
209
+
210
+ function emitSubmitResult(
211
+ events: EventBus,
212
+ result: RemoteAskSubmitResultEvent
213
+ ): void {
214
+ events.emit(PI_ASK_SUBMIT_RESULT_EVENT, result);
215
+ }
216
+
217
+ function failedSubmit(event: RemoteAskSubmitEvent): RemoteAskSubmitResultEvent {
218
+ return {
219
+ version: 1,
220
+ requestId: event.requestId,
221
+ flowId: event.flowId,
222
+ ok: false,
223
+ error: "flow_not_found",
224
+ message: "Ask flow is not active.",
225
+ };
226
+ }
227
+
228
+ export function applyRemoteAskResponse(
229
+ state: AskState,
230
+ response: RemoteAskResponse
231
+ ): { state: AskState } & RemoteAskSubmitResolution {
232
+ if (response.kind === "cancel") {
233
+ return {
234
+ ok: true,
235
+ state: { ...state, cancelled: true, completed: true },
236
+ };
237
+ }
238
+
239
+ const validation = validateAnswerResponse(state, response);
240
+ if (!validation.ok) {
241
+ return { ...validation, state };
242
+ }
243
+
244
+ return {
245
+ ok: true,
246
+ state: {
247
+ ...state,
248
+ answers: validation.answers,
249
+ activeTabIndex: state.questions.length,
250
+ activeSubmitActionIndex: response.mode === "elaborate" ? 1 : 0,
251
+ completed: true,
252
+ mode: response.mode ?? "submit",
253
+ view: { kind: "submit" },
254
+ },
255
+ };
256
+ }
257
+
258
+ function validateAnswerResponse(
259
+ state: AskState,
260
+ response: Extract<RemoteAskResponse, { kind: "answer" }>
261
+ ):
262
+ | { answers: Record<string, AskStateAnswer>; ok: true }
263
+ | { error: "invalid_answer"; message: string; ok: false } {
264
+ if (!isPlainObject(response.answers)) {
265
+ return invalidAnswer("Answer response must include an answers object.");
266
+ }
267
+ if (
268
+ response.mode &&
269
+ response.mode !== "submit" &&
270
+ response.mode !== "elaborate"
271
+ ) {
272
+ return invalidAnswer('Answer mode must be "submit" or "elaborate".');
273
+ }
274
+
275
+ const answers: Record<string, AskStateAnswer> = {};
276
+ for (const [questionId, answer] of Object.entries(response.answers)) {
277
+ const question = state.questions.find(
278
+ (candidate) => candidate.id === questionId
279
+ );
280
+ if (!question) {
281
+ return invalidAnswer(`Unknown question id "${questionId}".`);
282
+ }
283
+ const normalized = normalizeRemoteAnswer(question, answer);
284
+ if (!normalized.ok) {
285
+ return normalized;
286
+ }
287
+ answers[questionId] = normalized.answer;
288
+ }
289
+
290
+ return { ok: true, answers };
291
+ }
292
+
293
+ function normalizeRemoteAnswer(
294
+ question: AskQuestion,
295
+ answer: unknown
296
+ ):
297
+ | { answer: AskStateAnswer; ok: true }
298
+ | { error: "invalid_answer"; message: string; ok: false } {
299
+ const parts = parseRemoteAnswerParts(question, answer);
300
+ if (!parts.ok) {
301
+ return parts;
302
+ }
303
+ const selected = normalizeSelectedValues(question, parts.values);
304
+ if (!selected.ok) {
305
+ return selected;
306
+ }
307
+ const optionNotes = normalizeOptionNotes(question, parts.optionNotes);
308
+ if (!optionNotes.ok) {
309
+ return optionNotes;
310
+ }
311
+
312
+ const trimmedCustomText = parts.customText?.trim();
313
+ return {
314
+ ok: true,
315
+ answer: {
316
+ selected: selected.selected,
317
+ customSelected: trimmedCustomText ? true : undefined,
318
+ customText: trimmedCustomText ? parts.customText : undefined,
319
+ note: parts.note?.trim() ? parts.note : undefined,
320
+ optionNotes: optionNotes.optionNotes,
321
+ },
322
+ };
323
+ }
324
+
325
+ function parseRemoteAnswerParts(
326
+ question: AskQuestion,
327
+ answer: unknown
328
+ ):
329
+ | {
330
+ customText?: string;
331
+ note?: string;
332
+ ok: true;
333
+ optionNotes?: Record<string, string>;
334
+ values: string[];
335
+ }
336
+ | { error: "invalid_answer"; message: string; ok: false } {
337
+ if (!isPlainObject(answer)) {
338
+ return invalidAnswer(
339
+ `Answer for question "${question.id}" must be an object.`
340
+ );
341
+ }
342
+ if (answer.values !== undefined && !isStringArray(answer.values)) {
343
+ return invalidAnswer(
344
+ `Answer values for question "${question.id}" must be strings.`
345
+ );
346
+ }
347
+ const values = answer.values ?? [];
348
+ const scalarError = validateRemoteAnswerScalars(question, answer, values);
349
+ if (scalarError) {
350
+ return scalarError;
351
+ }
352
+ return {
353
+ ok: true,
354
+ values,
355
+ customText: answer.customText as string | undefined,
356
+ note: answer.note as string | undefined,
357
+ optionNotes: answer.optionNotes as Record<string, string> | undefined,
358
+ };
359
+ }
360
+
361
+ function validateRemoteAnswerScalars(
362
+ question: AskQuestion,
363
+ answer: Record<string, unknown>,
364
+ values: string[]
365
+ ): { error: "invalid_answer"; message: string; ok: false } | undefined {
366
+ if (new Set(values).size !== values.length) {
367
+ return invalidAnswer(
368
+ `Answer values for question "${question.id}" must be unique.`
369
+ );
370
+ }
371
+ if (question.type !== "multi" && values.length > 1) {
372
+ return invalidAnswer(
373
+ `Question "${question.id}" accepts only one selected value.`
374
+ );
375
+ }
376
+ if (
377
+ answer.customText !== undefined &&
378
+ typeof answer.customText !== "string"
379
+ ) {
380
+ return invalidAnswer(
381
+ `Custom text for question "${question.id}" must be a string.`
382
+ );
383
+ }
384
+ if (
385
+ typeof answer.customText === "string" &&
386
+ answer.customText.trim() &&
387
+ question.type !== "multi" &&
388
+ values.length > 0
389
+ ) {
390
+ return invalidAnswer(
391
+ `Question "${question.id}" cannot combine a selected value and custom text.`
392
+ );
393
+ }
394
+ if (answer.note !== undefined && typeof answer.note !== "string") {
395
+ return invalidAnswer(
396
+ `Note for question "${question.id}" must be a string.`
397
+ );
398
+ }
399
+ if (answer.optionNotes !== undefined && !isStringRecord(answer.optionNotes)) {
400
+ return invalidAnswer(
401
+ `Option notes for question "${question.id}" must be string values.`
402
+ );
403
+ }
404
+ }
405
+
406
+ function normalizeSelectedValues(
407
+ question: AskQuestion,
408
+ values: string[]
409
+ ):
410
+ | { ok: true; selected: AskSelectedOption[] }
411
+ | { error: "invalid_answer"; message: string; ok: false } {
412
+ const selected: AskSelectedOption[] = [];
413
+ for (const value of values) {
414
+ const optionIndex = question.options.findIndex(
415
+ (option) => option.value === value
416
+ );
417
+ if (optionIndex < 0) {
418
+ return invalidAnswer(
419
+ `Unknown option value "${value}" for question "${question.id}".`
420
+ );
421
+ }
422
+ const option = question.options[optionIndex];
423
+ selected.push({
424
+ value: option.value,
425
+ label: option.label,
426
+ index: optionIndex + 1,
427
+ });
428
+ }
429
+ return { ok: true, selected };
430
+ }
431
+
432
+ function normalizeOptionNotes(
433
+ question: AskQuestion,
434
+ optionNotes: Record<string, string> | undefined
435
+ ):
436
+ | { ok: true; optionNotes?: Record<string, string> }
437
+ | { error: "invalid_answer"; message: string; ok: false } {
438
+ const entries = Object.entries(optionNotes ?? {}).filter(([, value]) =>
439
+ value.trim()
440
+ );
441
+ for (const [value] of entries) {
442
+ if (!question.options.some((option) => option.value === value)) {
443
+ return invalidAnswer(
444
+ `Unknown option note value "${value}" for question "${question.id}".`
445
+ );
446
+ }
447
+ }
448
+ return {
449
+ ok: true,
450
+ optionNotes: entries.length > 0 ? Object.fromEntries(entries) : undefined,
451
+ };
452
+ }
453
+
454
+ function parseSubmitEvent(
455
+ data: unknown
456
+ ):
457
+ | { event: RemoteAskSubmitEvent; ok: true }
458
+ | { flowId: string; message: string; ok: false; requestId: string } {
459
+ const fallback = { requestId: "", flowId: "" };
460
+ if (!isPlainObject(data)) {
461
+ return {
462
+ ...fallback,
463
+ ok: false,
464
+ message: "Submit event must be an object.",
465
+ };
466
+ }
467
+ const requestId = typeof data.requestId === "string" ? data.requestId : "";
468
+ const flowId = typeof data.flowId === "string" ? data.flowId : "";
469
+ if (data.version !== 1) {
470
+ return {
471
+ requestId,
472
+ flowId,
473
+ ok: false,
474
+ message: "Submit event version must be 1.",
475
+ };
476
+ }
477
+ if (!requestId) {
478
+ return {
479
+ requestId,
480
+ flowId,
481
+ ok: false,
482
+ message: "Submit event requestId is required.",
483
+ };
484
+ }
485
+ if (!flowId) {
486
+ return {
487
+ requestId,
488
+ flowId,
489
+ ok: false,
490
+ message: "Submit event flowId is required.",
491
+ };
492
+ }
493
+ const response = parseRemoteResponse(data.response);
494
+ if (!response.ok) {
495
+ return { requestId, flowId, ok: false, message: response.message };
496
+ }
497
+ return {
498
+ ok: true,
499
+ event: {
500
+ version: 1,
501
+ requestId,
502
+ flowId,
503
+ response: response.response,
504
+ },
505
+ };
506
+ }
507
+
508
+ function parseRemoteResponse(
509
+ data: unknown
510
+ ): { ok: true; response: RemoteAskResponse } | { message: string; ok: false } {
511
+ if (!isPlainObject(data)) {
512
+ return { ok: false, message: "Submit response must be an object." };
513
+ }
514
+ if (data.kind === "cancel") {
515
+ return { ok: true, response: { kind: "cancel" } };
516
+ }
517
+ if (data.kind === "answer") {
518
+ return {
519
+ ok: true,
520
+ response: {
521
+ kind: "answer",
522
+ answers: data.answers as Record<string, RemoteAskAnswer>,
523
+ mode: data.mode as "submit" | "elaborate" | undefined,
524
+ },
525
+ };
526
+ }
527
+ return {
528
+ ok: false,
529
+ message: 'Submit response kind must be "answer" or "cancel".',
530
+ };
531
+ }
532
+
533
+ function invalidAnswer(message: string): {
534
+ error: "invalid_answer";
535
+ message: string;
536
+ ok: false;
537
+ } {
538
+ return { ok: false, error: "invalid_answer", message };
539
+ }
540
+
541
+ function createFlowId(toolCallId?: string): string {
542
+ if (toolCallId) {
543
+ return `tool:${toolCallId}`;
544
+ }
545
+ return `flow:${Date.now()}:${Math.random().toString(36).slice(2)}`;
546
+ }
547
+
548
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
549
+ return !!value && typeof value === "object" && !Array.isArray(value);
550
+ }
551
+
552
+ function isStringArray(value: unknown): value is string[] {
553
+ return (
554
+ Array.isArray(value) && value.every((item) => typeof item === "string")
555
+ );
556
+ }
557
+
558
+ function isStringRecord(value: unknown): value is Record<string, string> {
559
+ return (
560
+ isPlainObject(value) &&
561
+ Object.values(value).every((item) => typeof item === "string")
562
+ );
563
+ }
@@ -0,0 +1,157 @@
1
+ import { ELABORATED_SUMMARY } from "./constants/text.ts";
2
+ import { isCustomOnlyAnswer } from "./state/answers.ts";
3
+ import type { AskResult } from "./types.ts";
4
+
5
+ export function formatResultLines(
6
+ result: AskResult,
7
+ options: { mode: "summary" | "render" }
8
+ ): string[] {
9
+ const lines: string[] = [];
10
+
11
+ let hasPresentationOverride = false;
12
+
13
+ for (const question of result.questions) {
14
+ const answer = result.answers[question.id];
15
+ if (!answer) {
16
+ continue;
17
+ }
18
+
19
+ const answerLine = formatAnswerLine(question.label, answer, options.mode);
20
+ if (answerLine) {
21
+ lines.push(answerLine);
22
+ }
23
+
24
+ if (hasPresentedTypeOverride(question.type, question.presentedType)) {
25
+ hasPresentationOverride = true;
26
+ }
27
+
28
+ const questionNoteLine = formatQuestionNoteLine(
29
+ question.label,
30
+ answer.note,
31
+ options.mode
32
+ );
33
+ if (questionNoteLine) {
34
+ lines.push(questionNoteLine);
35
+ }
36
+
37
+ lines.push(...formatOptionNoteLines(question.label, answer, options.mode));
38
+ }
39
+
40
+ if (hasPresentationOverride) {
41
+ lines.push(formatPresentationNoteLine(options.mode));
42
+ }
43
+
44
+ return lines;
45
+ }
46
+
47
+ function formatAnswerLine(
48
+ questionLabel: string,
49
+ answer: AskResult["answers"][string],
50
+ mode: "summary" | "render"
51
+ ): string | undefined {
52
+ const answerText = answer.labels.join(", ");
53
+ if (!answerText) {
54
+ return;
55
+ }
56
+ if (mode === "summary") {
57
+ return `${questionLabel}: ${answerText}`;
58
+ }
59
+ if (isCustomOnlyAnswer(answer)) {
60
+ return `✓ ${questionLabel}: (wrote) ${answerText}`;
61
+ }
62
+ return `✓ ${questionLabel}: ${answerText}`;
63
+ }
64
+
65
+ function hasPresentedTypeOverride(
66
+ type: string,
67
+ presentedType: string | undefined
68
+ ): boolean {
69
+ return !!presentedType && presentedType !== type;
70
+ }
71
+
72
+ function formatPresentationNoteLine(mode: "summary" | "render"): string {
73
+ const text =
74
+ "Note: Some questions were presented as multi-select by user preference.";
75
+ return mode === "summary" ? text : ` ${text}`;
76
+ }
77
+
78
+ function formatQuestionNoteLine(
79
+ questionLabel: string,
80
+ note: string | undefined,
81
+ mode: "summary" | "render"
82
+ ): string | undefined {
83
+ if (!note) {
84
+ return;
85
+ }
86
+ return mode === "summary"
87
+ ? `${questionLabel} note: ${note}`
88
+ : ` note: ${note}`;
89
+ }
90
+
91
+ export function formatElaborationLines(
92
+ result: AskResult,
93
+ _options: { mode: "summary" | "render" }
94
+ ): string[] {
95
+ const items = result.elaboration?.items ?? [];
96
+ const lines = items.map((item) => {
97
+ const answerContext = formatElaborationAnswerContext(item.answer);
98
+ if (item.target.kind === "question") {
99
+ return `User asked to elaborate on question ${quote(item.question.prompt)}${answerContext} with note ${quote(item.note)}`;
100
+ }
101
+ if (!("option" in item)) {
102
+ return `User asked to elaborate on question ${quote(item.question.prompt)}${answerContext} with note ${quote(item.note)}`;
103
+ }
104
+ return `User asked to elaborate on question ${quote(item.question.prompt)} option ${quote(item.option.label)}${answerContext} with note ${quote(item.note)}`;
105
+ });
106
+
107
+ if (lines.length > 0) {
108
+ return lines;
109
+ }
110
+
111
+ const answerLines = result.questions
112
+ .map((question) => {
113
+ const answer = result.answers[question.id];
114
+ return answer?.labels.length
115
+ ? `User asked to elaborate on question ${quote(question.prompt)} after current answer ${quote(answer.labels.join(", "))}`
116
+ : undefined;
117
+ })
118
+ .filter((line): line is string => Boolean(line));
119
+
120
+ return answerLines.length > 0 ? answerLines : [ELABORATED_SUMMARY];
121
+ }
122
+
123
+ function formatElaborationAnswerContext(
124
+ answer: AskResult["answers"][string] | undefined
125
+ ): string {
126
+ const labels = answer?.labels ?? [];
127
+ if (labels.length === 0) {
128
+ return "";
129
+ }
130
+ return ` after current answer ${quote(labels.join(", "))}`;
131
+ }
132
+
133
+ function quote(value: string): string {
134
+ return JSON.stringify(value);
135
+ }
136
+
137
+ function formatOptionNoteLines(
138
+ questionLabel: string,
139
+ answer: AskResult["answers"][string],
140
+ mode: "summary" | "render"
141
+ ): string[] {
142
+ const lines: string[] = [];
143
+ for (let index = 0; index < answer.values.length; index++) {
144
+ const value = answer.values[index];
145
+ const label = answer.labels[index] ?? value;
146
+ const note = answer.optionNotes?.[value];
147
+ if (!note) {
148
+ continue;
149
+ }
150
+ lines.push(
151
+ mode === "summary"
152
+ ? `${questionLabel} / ${label} note: ${note}`
153
+ : ` ${label} note: ${note}`
154
+ );
155
+ }
156
+ return lines;
157
+ }