@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,461 @@
1
+ import {
2
+ truncateToWidth,
3
+ visibleWidth,
4
+ wrapTextWithAnsi,
5
+ } from "@earendil-works/pi-tui";
6
+ import { DEFAULT_ASK_CONFIG, normalizeAskConfig } from "../config/defaults.ts";
7
+ import type { AskConfig } from "../config/schema.ts";
8
+ import type { AskConfigNotice } from "../config/store.ts";
9
+ import {
10
+ getAskContextBindings,
11
+ matchesBinding,
12
+ renderSettingsFooterKeymaps,
13
+ } from "../constants/keymaps.ts";
14
+
15
+ interface Theme {
16
+ bg(color: string, text: string): string;
17
+ fg(color: string, text: string): string;
18
+ }
19
+
20
+ interface TuiLike {
21
+ requestRender(): void;
22
+ }
23
+
24
+ interface AskSettingsListOptions {
25
+ configPath: string;
26
+ notice?: AskConfigNotice;
27
+ onClose: () => void;
28
+ onSave: (config: AskConfig) => Promise<AskConfig>;
29
+ savedConfig: AskConfig;
30
+ tui: TuiLike;
31
+ }
32
+
33
+ const DESCRIPTION_LINE_COUNT = 3;
34
+ const COMPACT_WIDTH = 40;
35
+ const RESET_CONFIRMATION_MS = 2000;
36
+
37
+ type SettingSection = "Actions" | "Defaults for future asks" | "Live settings";
38
+ type ToggleSettingKey = keyof AskConfig["behaviour"] | "notifications.enabled";
39
+
40
+ interface BaseSetting {
41
+ description: string;
42
+ label: string;
43
+ section: SettingSection;
44
+ }
45
+
46
+ interface ActionSetting extends BaseSetting {
47
+ key: "resetConfig";
48
+ type: "action";
49
+ }
50
+
51
+ interface ToggleSetting extends BaseSetting {
52
+ key: ToggleSettingKey;
53
+ type: "toggle";
54
+ }
55
+
56
+ type Setting = ActionSetting | ToggleSetting;
57
+
58
+ const SETTINGS = [
59
+ {
60
+ description:
61
+ "Auto-submit completed ask flows when no question or option notes were added.",
62
+ key: "autoSubmitWhenAnsweredWithoutNotes",
63
+ section: "Live settings",
64
+ label: "Auto-submit when answered without notes",
65
+ type: "toggle",
66
+ },
67
+ {
68
+ description:
69
+ "Require a second cancel or dismiss action before discarding answered or drafted ask content.",
70
+ key: "confirmDismissWhenDirty",
71
+ section: "Live settings",
72
+ label: "Confirm dismiss when dirty",
73
+ type: "toggle",
74
+ },
75
+ {
76
+ description:
77
+ "Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
78
+ key: "doublePressReviewShortcuts",
79
+ section: "Live settings",
80
+ label: "Double-press review shortcuts",
81
+ type: "toggle",
82
+ },
83
+ {
84
+ description:
85
+ "Emit one external notification when the ask flow opens and waits for input.",
86
+ key: "notifications.enabled",
87
+ section: "Live settings",
88
+ label: "Notifications",
89
+ type: "toggle",
90
+ },
91
+ {
92
+ description: "Show footer keymap hints at the bottom of the ask flow.",
93
+ key: "showFooterHints",
94
+ section: "Live settings",
95
+ label: "Show footer hints",
96
+ type: "toggle",
97
+ },
98
+ {
99
+ description:
100
+ "Default new and replayed ask flows to render single-select questions as multi-select. This is not hot-applied to the current flow; use the type hotkey there.",
101
+ key: "presentSingleAsMulti",
102
+ section: "Defaults for future asks",
103
+ label: "Present single-select as multi-select",
104
+ type: "toggle",
105
+ },
106
+ {
107
+ description:
108
+ "Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
109
+ key: "resetConfig",
110
+ section: "Actions",
111
+ label: "Reset config to defaults",
112
+ type: "action",
113
+ },
114
+ ] as const satisfies readonly Setting[];
115
+
116
+ type SettingKey = ToggleSettingKey;
117
+
118
+ export class AskSettingsList {
119
+ private closed = false;
120
+ private config: AskConfig;
121
+ private error?: string;
122
+ private focusIndex = 0;
123
+ private resetConfirmUntil = 0;
124
+ private notice?: AskConfigNotice;
125
+ private readonly configPath: string;
126
+ private readonly onClose: () => void;
127
+ private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
128
+ private readonly theme: Theme;
129
+ private readonly tui: TuiLike;
130
+
131
+ constructor(theme: Theme, options: AskSettingsListOptions) {
132
+ this.theme = theme;
133
+ this.config = options.savedConfig;
134
+ this.configPath = options.configPath;
135
+ this.notice = options.notice;
136
+ this.onClose = options.onClose;
137
+ this.onSave = options.onSave;
138
+ this.tui = options.tui;
139
+ }
140
+
141
+ handleInput(data: string): void {
142
+ const bindings = getAskContextBindings(this.config, "settingsModal");
143
+ if (matchesBinding(data, bindings.close)) {
144
+ this.close();
145
+ return;
146
+ }
147
+ if (matchesBinding(data, bindings.previousOption)) {
148
+ this.moveFocus(-1);
149
+ return;
150
+ }
151
+ if (matchesBinding(data, bindings.nextOption)) {
152
+ this.moveFocus(1);
153
+ return;
154
+ }
155
+ if (matchesBinding(data, bindings.toggle)) {
156
+ this.activateSelectedSetting();
157
+ }
158
+ }
159
+
160
+ render(width: number): string[] {
161
+ const innerWidth = Math.max(24, width - 2);
162
+ const lines = [
163
+ this.topBorder(innerWidth),
164
+ this.line(
165
+ center(this.theme.fg("accent", "@eko24ive/pi-ask"), innerWidth),
166
+ innerWidth
167
+ ),
168
+ this.line("", innerWidth),
169
+ ];
170
+
171
+ this.appendWrapped(
172
+ lines,
173
+ this.theme.fg(
174
+ "muted",
175
+ "Edit this config file to customize keymaps, notifications, and extraction settings:"
176
+ ),
177
+ innerWidth
178
+ );
179
+ this.appendWrapped(
180
+ lines,
181
+ this.theme.fg("accent", this.configPath),
182
+ innerWidth,
183
+ {
184
+ center: true,
185
+ }
186
+ );
187
+
188
+ lines.push(this.line("", innerWidth));
189
+ let previousSection: string | undefined;
190
+ for (const [index, setting] of SETTINGS.entries()) {
191
+ if (setting.section !== previousSection) {
192
+ if (previousSection) {
193
+ lines.push(this.line("", innerWidth));
194
+ }
195
+ lines.push(
196
+ this.line(this.theme.fg("accent", ` ${setting.section}`), innerWidth)
197
+ );
198
+ previousSection = setting.section;
199
+ }
200
+ for (const settingLine of this.renderSetting(
201
+ setting,
202
+ index,
203
+ innerWidth
204
+ )) {
205
+ lines.push(this.line(settingLine, innerWidth));
206
+ }
207
+ if (innerWidth < COMPACT_WIDTH && index < SETTINGS.length - 1) {
208
+ lines.push(this.line("", innerWidth));
209
+ }
210
+ }
211
+
212
+ this.appendSelectedDescription(lines, innerWidth);
213
+ this.appendNotice(lines, innerWidth);
214
+
215
+ lines.push(this.line("", innerWidth));
216
+ this.appendFooter(lines, innerWidth);
217
+ lines.push(this.bottomBorder(innerWidth));
218
+ return lines.map((line) => truncateToWidth(line, width));
219
+ }
220
+
221
+ invalidate(): void {
222
+ // State-driven component; nothing cached across theme changes.
223
+ }
224
+
225
+ dispose(): void {
226
+ this.close();
227
+ }
228
+
229
+ private appendFooter(lines: string[], innerWidth: number): void {
230
+ if (innerWidth < COMPACT_WIDTH) {
231
+ const footer = renderSettingsFooterKeymaps(this.config);
232
+ for (const line of wrapTextWithAnsi(footer, innerWidth - 2)) {
233
+ lines.push(this.line(this.theme.fg("dim", ` ${line}`), innerWidth));
234
+ }
235
+ return;
236
+ }
237
+ this.appendWrapped(
238
+ lines,
239
+ this.theme.fg("dim", renderSettingsFooterKeymaps(this.config)),
240
+ innerWidth
241
+ );
242
+ }
243
+
244
+ private appendSelectedDescription(lines: string[], innerWidth: number): void {
245
+ const selectedSetting = SETTINGS[this.focusIndex];
246
+ if (!selectedSetting) {
247
+ return;
248
+ }
249
+ lines.push(this.line("", innerWidth));
250
+ const descriptionLines = wrapTextWithAnsi(
251
+ this.theme.fg("muted", selectedSetting.description),
252
+ innerWidth - 2
253
+ ).slice(0, DESCRIPTION_LINE_COUNT);
254
+ for (const line of descriptionLines) {
255
+ lines.push(this.line(` ${line}`, innerWidth));
256
+ }
257
+ for (
258
+ let index = descriptionLines.length;
259
+ index < DESCRIPTION_LINE_COUNT;
260
+ index++
261
+ ) {
262
+ lines.push(this.line("", innerWidth));
263
+ }
264
+ }
265
+
266
+ private appendNotice(lines: string[], innerWidth: number): void {
267
+ if (this.notice) {
268
+ lines.push(this.line("", innerWidth));
269
+ this.appendWrapped(
270
+ lines,
271
+ this.theme.fg(
272
+ this.notice.kind === "success" ? "accent" : "warning",
273
+ this.notice.text
274
+ ),
275
+ innerWidth
276
+ );
277
+ }
278
+ if (this.error) {
279
+ lines.push(this.line("", innerWidth));
280
+ this.appendWrapped(lines, this.theme.fg("error", this.error), innerWidth);
281
+ }
282
+ }
283
+
284
+ private appendWrapped(
285
+ lines: string[],
286
+ content: string,
287
+ innerWidth: number,
288
+ options: { center?: boolean } = {}
289
+ ): void {
290
+ for (const line of wrapTextWithAnsi(content, innerWidth - 2)) {
291
+ const contentLine = options.center
292
+ ? center(line, innerWidth)
293
+ : ` ${line}`;
294
+ lines.push(this.line(contentLine, innerWidth));
295
+ }
296
+ }
297
+
298
+ private renderSetting(
299
+ setting: Setting,
300
+ index: number,
301
+ innerWidth: number
302
+ ): string[] {
303
+ const selected = index === this.focusIndex;
304
+ const prefix = selected ? this.theme.fg("accent", "❯ ") : " ";
305
+ const continuationPrefix = " ";
306
+ if (setting.type === "action") {
307
+ return [center(this.renderSettingValue(setting, selected), innerWidth)];
308
+ }
309
+ const value = this.renderSettingValue(setting, selected);
310
+ const valueWidth = visibleWidth(value);
311
+ const labelWidth = Math.max(
312
+ 1,
313
+ innerWidth - visibleWidth(prefix) - valueWidth - 2
314
+ );
315
+ const labelLines = wrapTextWithAnsi(setting.label, labelWidth);
316
+ const firstLabelLine = labelLines[0] ?? "";
317
+ const gap = " ".repeat(
318
+ Math.max(1, labelWidth - visibleWidth(firstLabelLine) + 1)
319
+ );
320
+ const firstLine = `${prefix}${firstLabelLine}${gap}${value}`;
321
+ const continuationLines = labelLines
322
+ .slice(1)
323
+ .map((line) => `${continuationPrefix}${line}`);
324
+ return [firstLine, ...continuationLines];
325
+ }
326
+
327
+ private renderSettingValue(setting: Setting, selected: boolean): string {
328
+ if (setting.type === "action") {
329
+ return this.renderValue(
330
+ this.isResetConfirmationActive() ? "confirm reset" : "reset all",
331
+ selected
332
+ );
333
+ }
334
+ return this.renderValue(this.getSettingValue(setting.key), selected);
335
+ }
336
+
337
+ private renderValue(value: boolean | string, selected: boolean): string {
338
+ const valueText = formatSettingValue(value);
339
+ const styledValue = selected
340
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", valueText))
341
+ : this.theme.fg("muted", valueText);
342
+ return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`;
343
+ }
344
+
345
+ private getSettingValue(key: SettingKey): boolean {
346
+ return key === "notifications.enabled"
347
+ ? this.config.notifications.enabled
348
+ : this.config.behaviour[key];
349
+ }
350
+
351
+ private moveFocus(delta: 1 | -1): void {
352
+ this.resetConfirmUntil = 0;
353
+ this.focusIndex =
354
+ (this.focusIndex + delta + SETTINGS.length) % SETTINGS.length;
355
+ this.tui.requestRender();
356
+ }
357
+
358
+ private activateSelectedSetting(): void {
359
+ const setting = SETTINGS[this.focusIndex];
360
+ if (!setting) {
361
+ return;
362
+ }
363
+ if (setting.type === "action") {
364
+ this.resetConfigWithConfirmation();
365
+ return;
366
+ }
367
+ this.resetConfirmUntil = 0;
368
+ this.saveSetting(setting.key, !this.getSettingValue(setting.key));
369
+ this.tui.requestRender();
370
+ }
371
+
372
+ private resetConfigWithConfirmation(): void {
373
+ if (!this.isResetConfirmationActive()) {
374
+ this.resetConfirmUntil = Date.now() + RESET_CONFIRMATION_MS;
375
+ this.tui.requestRender();
376
+ return;
377
+ }
378
+ this.resetConfirmUntil = 0;
379
+ this.saveConfig(normalizeAskConfig(DEFAULT_ASK_CONFIG));
380
+ }
381
+
382
+ private isResetConfirmationActive(): boolean {
383
+ return Date.now() <= this.resetConfirmUntil;
384
+ }
385
+
386
+ private saveSetting(key: SettingKey, enabled: boolean): void {
387
+ const nextConfig =
388
+ key === "notifications.enabled"
389
+ ? {
390
+ ...this.config,
391
+ notifications: {
392
+ ...this.config.notifications,
393
+ enabled,
394
+ },
395
+ }
396
+ : {
397
+ ...this.config,
398
+ behaviour: {
399
+ ...this.config.behaviour,
400
+ [key]: enabled,
401
+ },
402
+ };
403
+ this.saveConfig(nextConfig);
404
+ }
405
+
406
+ private saveConfig(nextConfig: AskConfig): void {
407
+ this.error = undefined;
408
+ const previousConfig = this.config;
409
+ this.config = nextConfig;
410
+ this.onSave(nextConfig)
411
+ .then((savedConfig) => {
412
+ this.config = savedConfig;
413
+ this.notice = undefined;
414
+ this.tui.requestRender();
415
+ })
416
+ .catch((error: unknown) => {
417
+ this.config = previousConfig;
418
+ this.error = error instanceof Error ? error.message : String(error);
419
+ this.tui.requestRender();
420
+ });
421
+ }
422
+
423
+ private topBorder(innerWidth: number): string {
424
+ return this.theme.fg("muted", `╭${"─".repeat(innerWidth)}╮`);
425
+ }
426
+
427
+ private bottomBorder(innerWidth: number): string {
428
+ return this.theme.fg("muted", `╰${"─".repeat(innerWidth)}╯`);
429
+ }
430
+
431
+ private line(content: string, innerWidth: number): string {
432
+ const clipped = truncateToWidth(content, innerWidth, "…");
433
+ return `${this.theme.fg("muted", "│")}${padToWidth(clipped, innerWidth)}${this.theme.fg("muted", "│")}`;
434
+ }
435
+
436
+ private close(): void {
437
+ if (this.closed) {
438
+ return;
439
+ }
440
+ this.closed = true;
441
+ this.onClose();
442
+ }
443
+ }
444
+
445
+ function formatSettingValue(value: boolean | string): string {
446
+ if (typeof value === "string") {
447
+ return value;
448
+ }
449
+ return value ? "on" : "off";
450
+ }
451
+
452
+ function center(text: string, width: number): string {
453
+ const padding = Math.max(0, width - visibleWidth(text));
454
+ const left = Math.floor(padding / 2);
455
+ const right = padding - left;
456
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
457
+ }
458
+
459
+ function padToWidth(text: string, width: number): string {
460
+ return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
461
+ }
@@ -0,0 +1,37 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
3
+ import { AskSettingsList } from "./settings-list.ts";
4
+
5
+ export async function showAskSettings(
6
+ ctx: Pick<ExtensionContext, "mode" | "ui">
7
+ ): Promise<void> {
8
+ if (ctx.mode !== "tui") {
9
+ ctx.ui.notify("/ask-settings requires interactive TUI mode.", "error");
10
+ return;
11
+ }
12
+ const store = getAskConfigStore();
13
+ const { config, notice } = await store.ensureLoaded();
14
+ return ctx.ui.custom<void>(
15
+ (tui, theme, _keybindings, done) =>
16
+ new AskSettingsList(theme, {
17
+ configPath: getAskConfigPath(),
18
+ notice,
19
+ onClose: () => {
20
+ done();
21
+ },
22
+ onSave: async (nextConfig) => store.save(nextConfig),
23
+ savedConfig: config,
24
+ tui,
25
+ }),
26
+ {
27
+ overlay: true,
28
+ overlayOptions: {
29
+ anchor: "center",
30
+ margin: 1,
31
+ maxHeight: "90%",
32
+ minWidth: 26,
33
+ width: 72,
34
+ },
35
+ }
36
+ );
37
+ }
@@ -0,0 +1,203 @@
1
+ import { UI_DIMENSIONS, UI_TEXT } from "../../constants/ui.ts";
2
+ import { isOptionSelected } from "../../state/answers.ts";
3
+ import {
4
+ getAnswer,
5
+ getOptionNote,
6
+ getQuestionNote,
7
+ isInputOpenForQuestion,
8
+ isOptionNoteOpen,
9
+ isQuestionNoteOpen,
10
+ } from "../../state/selectors.ts";
11
+ import type { AskDisplayOption } from "../../types.ts";
12
+ import type { QuestionRenderContext } from "../render-types.ts";
13
+
14
+ export type QuestionNoteModel =
15
+ | { kind: "editor"; placeholder: string }
16
+ | { kind: "saved"; text: string };
17
+
18
+ export type OptionDetailModel =
19
+ | { kind: "editor"; placeholder: string; withGap: boolean }
20
+ | { kind: "saved-note"; text: string; withGap: boolean }
21
+ | { kind: "custom-text"; text: string; withGap: boolean };
22
+
23
+ export interface OptionRowModel {
24
+ color: "accent" | "text" | "success";
25
+ description?: string;
26
+ detail?: OptionDetailModel;
27
+ index: number;
28
+ isCustom: boolean;
29
+ isFreeformOnly: boolean;
30
+ label: string;
31
+ pointer: string;
32
+ prefix: string;
33
+ selected: boolean;
34
+ }
35
+
36
+ export interface QuestionScreenModel {
37
+ mode: "preview" | "standard";
38
+ previewLayout?: "custom" | "stacked" | "wide";
39
+ questionNote?: QuestionNoteModel;
40
+ rows: OptionRowModel[];
41
+ selectedOption?: AskDisplayOption;
42
+ selectedOptionDetail?: OptionDetailModel;
43
+ }
44
+
45
+ export function buildQuestionScreenModel(
46
+ context: QuestionRenderContext
47
+ ): QuestionScreenModel {
48
+ const rows = context.options.map((option, index) =>
49
+ buildOptionRowModel(context, option, index)
50
+ );
51
+ const questionNote = buildQuestionNoteModel(context);
52
+ if (context.question.type !== "preview") {
53
+ return {
54
+ mode: "standard",
55
+ questionNote,
56
+ rows,
57
+ };
58
+ }
59
+
60
+ const selectedOption = context.options[context.state.activeOptionIndex];
61
+ return {
62
+ mode: "preview",
63
+ previewLayout: getPreviewLayout(context, selectedOption),
64
+ questionNote,
65
+ rows,
66
+ selectedOption,
67
+ selectedOptionDetail: buildOptionDetailModel(context, selectedOption, true),
68
+ };
69
+ }
70
+
71
+ function buildQuestionNoteModel(
72
+ context: QuestionRenderContext
73
+ ): QuestionNoteModel | undefined {
74
+ const { state, question } = context;
75
+ if (isQuestionNoteOpen(state, question.id)) {
76
+ return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote };
77
+ }
78
+ const note = getQuestionNote(state, question.id);
79
+ return note ? { kind: "saved", text: note } : undefined;
80
+ }
81
+
82
+ function buildOptionRowModel(
83
+ context: QuestionRenderContext,
84
+ option: AskDisplayOption,
85
+ index: number
86
+ ): OptionRowModel {
87
+ const { question, state } = context;
88
+ const answer = getAnswer(state, question.id);
89
+ const selected = index === state.activeOptionIndex;
90
+ const answered = option.isCustomOption
91
+ ? !!(answer?.customSelected && answer.customText?.trim())
92
+ : isOptionSelected(answer, option.value);
93
+ const pointer = getOptionPointer(option, selected);
94
+ return {
95
+ color: getOptionColor(answered, selected),
96
+ description: option.description,
97
+ detail:
98
+ context.question.type === "preview"
99
+ ? undefined
100
+ : buildOptionDetailModel(context, option, selected),
101
+ index,
102
+ isCustom: !!option.isCustomOption,
103
+ isFreeformOnly: !!option.isFreeformOnlyOption,
104
+ label: option.label,
105
+ pointer,
106
+ prefix: getOptionPrefix(question.type, option, answered),
107
+ selected,
108
+ };
109
+ }
110
+
111
+ function getOptionPointer(option: AskDisplayOption, selected: boolean): string {
112
+ if (option.isFreeformOnlyOption) {
113
+ return "";
114
+ }
115
+ return selected ? "❯ " : " ";
116
+ }
117
+
118
+ function buildOptionDetailModel(
119
+ context: QuestionRenderContext,
120
+ option: AskDisplayOption | undefined,
121
+ selected: boolean
122
+ ): OptionDetailModel | undefined {
123
+ if (!option) {
124
+ return;
125
+ }
126
+
127
+ const { state, question } = context;
128
+ const answer = getAnswer(state, question.id);
129
+ const inputOpen =
130
+ !!option.isCustomOption &&
131
+ selected &&
132
+ isInputOpenForQuestion(state, question.id);
133
+ const noteOpen =
134
+ !option.isCustomOption &&
135
+ isOptionNoteOpen(state, question.id, option.value);
136
+ const customText = option.isCustomOption ? answer?.customText : undefined;
137
+ const note = option.isCustomOption
138
+ ? undefined
139
+ : getOptionNote(state, question.id, option.value);
140
+
141
+ if (noteOpen) {
142
+ return {
143
+ kind: "editor",
144
+ placeholder: UI_TEXT.editorPlaceholderNote,
145
+ withGap: false,
146
+ };
147
+ }
148
+ if (inputOpen) {
149
+ return {
150
+ kind: "editor",
151
+ placeholder: UI_TEXT.editorPlaceholderInput,
152
+ withGap: !!option.isFreeformOnlyOption,
153
+ };
154
+ }
155
+ if (customText) {
156
+ return {
157
+ kind: "custom-text",
158
+ text: customText,
159
+ withGap: !!option.isFreeformOnlyOption,
160
+ };
161
+ }
162
+ if (note) {
163
+ return { kind: "saved-note", text: note, withGap: false };
164
+ }
165
+ return;
166
+ }
167
+
168
+ function getPreviewLayout(
169
+ context: QuestionRenderContext,
170
+ selectedOption: AskDisplayOption | undefined
171
+ ): "custom" | "stacked" | "wide" {
172
+ if (selectedOption?.isCustomOption) {
173
+ return "custom";
174
+ }
175
+ return context.width >= UI_DIMENSIONS.previewWideMinWidth &&
176
+ context.options.length > 0
177
+ ? "wide"
178
+ : "stacked";
179
+ }
180
+
181
+ function getOptionPrefix(
182
+ questionType: QuestionRenderContext["question"]["type"],
183
+ _option: AskDisplayOption,
184
+ answered: boolean
185
+ ): string {
186
+ if (questionType !== "multi") {
187
+ return "";
188
+ }
189
+ return `[${answered ? "x" : " "}] `;
190
+ }
191
+
192
+ function getOptionColor(
193
+ answered: boolean,
194
+ selected: boolean
195
+ ): "accent" | "text" | "success" {
196
+ if (answered) {
197
+ return "success";
198
+ }
199
+ if (selected) {
200
+ return "accent";
201
+ }
202
+ return "text";
203
+ }