pi-ask-popup 0.1.0 → 0.1.1

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 (35) hide show
  1. package/README.md +95 -32
  2. package/docs/configuration.md +93 -0
  3. package/docs/hosts.md +71 -0
  4. package/docs/keyboard.md +65 -0
  5. package/docs/tool-schema.md +124 -0
  6. package/package.json +12 -2
  7. package/preview/popup-submit.webp +0 -0
  8. package/preview/popup-with-notes.webp +0 -0
  9. package/preview/popup-with-tab.webp +0 -0
  10. package/src/ask-user-question.ts +50 -24
  11. package/src/config.ts +66 -23
  12. package/src/rpc-fallback.ts +30 -15
  13. package/src/state/build-questionnaire.ts +3 -1
  14. package/src/state/external-editor.ts +6 -2
  15. package/src/state/key-router.ts +129 -41
  16. package/src/state/questionnaire-session.ts +35 -13
  17. package/src/state/row-intent.ts +6 -2
  18. package/src/state/selectors/derivations.ts +18 -6
  19. package/src/state/selectors/focus.ts +6 -2
  20. package/src/state/selectors/projections.ts +3 -1
  21. package/src/state/state-reducer.ts +99 -33
  22. package/src/tool/response-envelope.ts +32 -20
  23. package/src/tool/types.ts +13 -2
  24. package/src/view/component-binding.ts +3 -1
  25. package/src/view/components/inline-input.ts +3 -1
  26. package/src/view/components/multi-select-view.ts +15 -5
  27. package/src/view/components/preview/markdown-content-cache.ts +21 -6
  28. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  29. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  30. package/src/view/components/preview/preview-layout-decider.ts +20 -15
  31. package/src/view/components/preview/preview-pane.ts +54 -18
  32. package/src/view/components/wrapping-select.ts +15 -5
  33. package/src/view/dialog-builder.ts +24 -8
  34. package/src/view/props-adapter.ts +21 -7
  35. package/src/view/tab-content-strategy.ts +60 -20
@@ -2,6 +2,13 @@ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
2
  import type { QuestionAnswer } from "../tool/types.js";
3
3
  import { ROW_INTENT_META } from "./row-intent.js";
4
4
  import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
5
+ type JsonValue =
6
+ | string
7
+ | number
8
+ | boolean
9
+ | null
10
+ | JsonValue[]
11
+ | { readonly [key: string]: JsonValue };
5
12
 
6
13
  const KEYBIND_UP = "tui.select.up";
7
14
  const KEYBIND_DOWN = "tui.select.down";
@@ -59,14 +66,20 @@ function isConfirm(kb: QuestionnaireKeybindings, data: string): boolean {
59
66
  }
60
67
 
61
68
  export function wrapTab(index: number, total: number): number {
62
- if (total <= 0) return 0;
69
+ if (total <= 0) {
70
+ return 0;
71
+ }
63
72
  return ((index % total) + total) % total;
64
73
  }
65
74
 
66
75
  export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean {
67
- if (runtime.questions.length === 0) return false;
76
+ if (runtime.questions.length === 0) {
77
+ return false;
78
+ }
68
79
  for (let i = 0; i < runtime.questions.length; i++) {
69
- if (!state.answers.has(i)) return false;
80
+ if (!state.answers.has(i)) {
81
+ return false;
82
+ }
70
83
  }
71
84
  return true;
72
85
  }
@@ -79,8 +92,12 @@ function computeAutoAdvanceTab(
79
92
  state: QuestionnaireState,
80
93
  runtime: QuestionnaireRuntime,
81
94
  ): number | undefined {
82
- if (!runtime.isMulti) return undefined;
83
- if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1;
95
+ if (!runtime.isMulti) {
96
+ return undefined;
97
+ }
98
+ if (state.currentTab < runtime.questions.length - 1) {
99
+ return state.currentTab + 1;
100
+ }
84
101
  return runtime.questions.length;
85
102
  }
86
103
 
@@ -89,7 +106,9 @@ function buildSingleSelectAnswer(
89
106
  runtime: QuestionnaireRuntime,
90
107
  ): QuestionAnswer | null {
91
108
  const q = runtime.questions[state.currentTab];
92
- if (!q) return null;
109
+ if (!q) {
110
+ return null;
111
+ }
93
112
 
94
113
  const item = runtime.currentItem;
95
114
 
@@ -102,7 +121,9 @@ function buildSingleSelectAnswer(
102
121
  answer: label.length > 0 ? label : null,
103
122
  };
104
123
  }
105
- if (!item) return null;
124
+ if (!item) {
125
+ return null;
126
+ }
106
127
  if (item.kind === "other") {
107
128
  return null;
108
129
  }
@@ -117,14 +138,22 @@ function buildSingleSelectAnswer(
117
138
  };
118
139
  }
119
140
 
141
+ function isString(value: JsonValue | undefined): value is string {
142
+ return typeof value === "string";
143
+ }
144
+
120
145
  function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
121
146
  const q = runtime.questions[state.currentTab];
122
- if (!q) return [];
147
+ if (!q) {
148
+ return [];
149
+ }
123
150
  const out: string[] = [];
124
151
  for (let i = 0; i < q.options.length; i++) {
125
152
  if (state.multiSelectChecked.has(i)) {
126
153
  const label = q.options[i]?.label;
127
- if (typeof label === "string") out.push(label);
154
+ if (isString(label)) {
155
+ out.push(label);
156
+ }
128
157
  }
129
158
  }
130
159
  return out;
@@ -135,7 +164,9 @@ function tabSwitchAction(
135
164
  state: QuestionnaireState,
136
165
  runtime: QuestionnaireRuntime,
137
166
  ): QuestionnaireAction | null {
138
- if (!runtime.isMulti) return null;
167
+ if (!runtime.isMulti) {
168
+ return null;
169
+ }
139
170
  const total = totalTabs(runtime);
140
171
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
141
172
  return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
@@ -174,14 +205,22 @@ function prevNavOnUp(
174
205
  // the user can read the now-uncovered transcript without accidentally mutating
175
206
  // answers or notes. The collapse toggle itself is already handled above.
176
207
  function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
177
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
208
+ if (kb.matches(data, KEYBIND_CANCEL)) {
209
+ return { kind: "cancel" };
210
+ }
178
211
  return { kind: "ignore" };
179
212
  }
180
213
 
181
214
  function routeNotesMode(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
182
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" };
183
- if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "notes_forward", data };
184
- if (isConfirm(kb, data)) return { kind: "notes_exit" };
215
+ if (kb.matches(data, KEYBIND_CANCEL)) {
216
+ return { kind: "notes_exit" };
217
+ }
218
+ if (kb.matches(data, KEYBIND_NEW_LINE)) {
219
+ return { kind: "notes_forward", data };
220
+ }
221
+ if (isConfirm(kb, data)) {
222
+ return { kind: "notes_exit" };
223
+ }
185
224
  return { kind: "notes_forward", data };
186
225
  }
187
226
 
@@ -193,22 +232,39 @@ function routeInputMode(
193
232
  ): QuestionnaireAction {
194
233
  // Newline takes precedence over confirmation if a user configuration binds
195
234
  // the same physical key to both semantic actions.
196
- if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
235
+ if (kb.matches(data, KEYBIND_NEW_LINE)) {
236
+ return { kind: "ignore" };
237
+ }
197
238
  if (isConfirm(kb, data)) {
198
239
  const answer = buildSingleSelectAnswer(state, runtime);
199
- if (!answer) return { kind: "ignore" };
240
+ if (!answer) {
241
+ return { kind: "ignore" };
242
+ }
200
243
  return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
201
244
  }
202
245
  // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
203
246
  // independent of the current cursor position.
204
- if (kb.matches(data, KEYBIND_CLEAR)) return { kind: "input_clear" };
205
- if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR))
247
+ if (kb.matches(data, KEYBIND_CLEAR)) {
248
+ return { kind: "input_clear" };
249
+ }
250
+ if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) {
206
251
  return { kind: "input_edit", value: runtime.inputBuffer };
207
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
208
- if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) return { kind: "ignore" };
209
- if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) return { kind: "ignore" };
210
- if (kb.matches(data, KEYBIND_UP)) return prevNavOnUp(state, runtime);
211
- if (kb.matches(data, KEYBIND_DOWN)) return nextNavOnDown(state, runtime);
252
+ }
253
+ if (kb.matches(data, KEYBIND_CANCEL)) {
254
+ return { kind: "cancel" };
255
+ }
256
+ if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) {
257
+ return { kind: "ignore" };
258
+ }
259
+ if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) {
260
+ return { kind: "ignore" };
261
+ }
262
+ if (kb.matches(data, KEYBIND_UP)) {
263
+ return prevNavOnUp(state, runtime);
264
+ }
265
+ if (kb.matches(data, KEYBIND_DOWN)) {
266
+ return nextNavOnDown(state, runtime);
267
+ }
212
268
  return { kind: "ignore" };
213
269
  }
214
270
 
@@ -218,12 +274,17 @@ function routeSubmitTab(
218
274
  state: QuestionnaireState,
219
275
  runtime: QuestionnaireRuntime,
220
276
  ): QuestionnaireAction {
221
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
277
+ if (kb.matches(data, KEYBIND_CANCEL)) {
278
+ return { kind: "cancel" };
279
+ }
222
280
  const tab = tabSwitchAction(data, state, runtime);
223
- if (tab) return tab;
281
+ if (tab) {
282
+ return tab;
283
+ }
224
284
  if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
225
285
  const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
226
286
  const next = wrapTab(state.submitChoiceIndex + delta, 2);
287
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
227
288
  return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
228
289
  }
229
290
  if (isConfirm(kb, data)) {
@@ -257,20 +318,28 @@ function routeMultiSelectTab(
257
318
  // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
258
319
  // something." row — it is an inline input, not a checkable option).
259
320
  if (data === SPACE_KEY) {
260
- if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
261
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
321
+ if (focusedMeta?.blocksMultiToggle) {
322
+ return { kind: "ignore" };
323
+ }
324
+ if (focusedMeta?.activatesInputMode) {
325
+ return { kind: "ignore" };
326
+ }
262
327
  return { kind: "toggle", index: state.optionIndex };
263
328
  }
264
329
  if (isConfirm(kb, data)) {
265
330
  // Enter on the "Type something." row is handled by the inputMode block above
266
331
  // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
267
332
  // path for an inputMode-activating row.
268
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
333
+ if (focusedMeta?.activatesInputMode) {
334
+ return { kind: "ignore" };
335
+ }
269
336
  // Enter on a regular row toggles (matching Space) — committing the question is now
270
337
  // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
271
338
  // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
272
339
  // without leaving the keyboard home row.
273
- if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
340
+ if (!focusedMeta?.autoSubmitsInMulti) {
341
+ return { kind: "toggle", index: state.optionIndex };
342
+ }
274
343
  // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
275
344
  // multi-question mode, OR submit the dialog in single-question mode
276
345
  // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
@@ -281,7 +350,9 @@ function routeMultiSelectTab(
281
350
  autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
282
351
  };
283
352
  }
284
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
353
+ if (kb.matches(data, KEYBIND_CANCEL)) {
354
+ return { kind: "cancel" };
355
+ }
285
356
  return { kind: "ignore" };
286
357
  }
287
358
 
@@ -293,10 +364,14 @@ function routeSingleSelectTab(
293
364
  ): QuestionnaireAction {
294
365
  if (isConfirm(kb, data)) {
295
366
  const answer = buildSingleSelectAnswer(state, runtime);
296
- if (!answer) return { kind: "ignore" };
367
+ if (!answer) {
368
+ return { kind: "ignore" };
369
+ }
297
370
  return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
298
371
  }
299
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
372
+ if (kb.matches(data, KEYBIND_CANCEL)) {
373
+ return { kind: "cancel" };
374
+ }
300
375
  return { kind: "ignore" };
301
376
  }
302
377
 
@@ -329,28 +404,39 @@ export function routeKey(
329
404
  // undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
330
405
  // entire host process, so keep the runtime boundary defensive even though the
331
406
  // TypeScript contract requires a string. The "off" literal is deliberate:
332
- // importing COLLAPSE_KEY_OFF would pull ../config.js (and its rpiv-config
333
- // loader graph) into this pure module for a string that cannot change.
407
+ // importing COLLAPSE_KEY_OFF would pull ../config.js (and its config loader
408
+ // graph) into this pure module for a string that cannot change.
334
409
  if (
335
- typeof runtime.collapseKey === "string" &&
410
+ isString(runtime.collapseKey) &&
336
411
  runtime.collapseKey !== "off" &&
412
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
337
413
  matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
338
414
  ) {
339
415
  return { kind: "toggle_collapsed" };
340
416
  }
341
417
 
342
- if (state.collapsed) return routeCollapsed(kb, data);
343
- if (state.notesVisible) return routeNotesMode(kb, data);
344
- if (state.inputMode) return routeInputMode(kb, data, state, runtime);
418
+ if (state.collapsed) {
419
+ return routeCollapsed(kb, data);
420
+ }
421
+ if (state.notesVisible) {
422
+ return routeNotesMode(kb, data);
423
+ }
424
+ if (state.inputMode) {
425
+ return routeInputMode(kb, data, state, runtime);
426
+ }
345
427
  if (runtime.isMulti && state.currentTab === runtime.questions.length) {
346
428
  return routeSubmitTab(kb, data, state, runtime);
347
429
  }
348
430
 
349
431
  const tab = tabSwitchAction(data, state, runtime);
350
- if (tab) return tab;
432
+ if (tab) {
433
+ return tab;
434
+ }
351
435
 
352
436
  const q = runtime.questions[state.currentTab];
353
- if (!q) return { kind: "ignore" };
437
+ if (!q) {
438
+ return { kind: "ignore" };
439
+ }
354
440
 
355
441
  // Universal `n` activation (FR-1): the notes editor opens on every question tab
356
442
  // (single- or multi-select, preview or no-preview). The blocks above already
@@ -373,6 +459,8 @@ export function routeKey(
373
459
  return nextNavOnDown(state, runtime);
374
460
  }
375
461
 
376
- if (q.multiSelect) return routeMultiSelectTab(kb, data, state, runtime);
462
+ if (q.multiSelect) {
463
+ return routeMultiSelectTab(kb, data, state, runtime);
464
+ }
377
465
  return routeSingleSelectTab(kb, data, state, runtime);
378
466
  }
@@ -152,7 +152,9 @@ export class QuestionnaireSession {
152
152
 
153
153
  this.component = this.assembleComponent(built, config.theme);
154
154
  this.viewAdapter.apply(this.state);
155
- if (this.state.deadline !== undefined) this.startTimer();
155
+ if (this.state.deadline !== undefined) {
156
+ this.startTimer();
157
+ }
156
158
  }
157
159
 
158
160
  private assembleComponent(
@@ -180,15 +182,17 @@ export class QuestionnaireSession {
180
182
  * falls back to cancel-only rather than telling the user to press "Off".
181
183
  */
182
184
  private startTimer(): void {
183
- if (this.timer !== undefined) return;
185
+ if (this.timer !== undefined) {
186
+ return;
187
+ }
184
188
  this.timer = setInterval(() => {
185
189
  this.commit({ kind: "tick", now: Date.now() });
186
190
  }, 1000);
187
191
  // Don't keep the process alive after Pi exits.
188
192
  // SAFETY: Node's Timeout has unref, DOM/Bun number does not; guard ensures we only call when present.
189
- if (typeof (this.timer as unknown as { unref?: () => void }).unref === "function") {
193
+ if (typeof (this.timer as { unref?: () => void }).unref === "function") {
190
194
  // SAFETY: same guard as above — only called when unref is a function.
191
- (this.timer as unknown as { unref: () => void }).unref();
195
+ (this.timer as { unref: () => void }).unref();
192
196
  }
193
197
  }
194
198
 
@@ -200,9 +204,13 @@ export class QuestionnaireSession {
200
204
  }
201
205
 
202
206
  private formatRemaining(): string | undefined {
203
- if (this.state.timerCancelled) return undefined;
207
+ if (this.state.timerCancelled) {
208
+ return undefined;
209
+ }
204
210
  const ms = this.state.remainingMs;
205
- if (ms === undefined) return undefined;
211
+ if (ms === undefined) {
212
+ return undefined;
213
+ }
206
214
  const secs = Math.max(0, Math.ceil(ms / 1000));
207
215
  return `${secs}s`;
208
216
  }
@@ -221,7 +229,9 @@ export class QuestionnaireSession {
221
229
  }
222
230
 
223
231
  dispatch(data: string): void {
224
- if (this.inputEditorOpen) return;
232
+ if (this.inputEditorOpen) {
233
+ return;
234
+ }
225
235
  const action = routeKey(data, this.state, this.runtime());
226
236
  if (action.kind === "ignore") {
227
237
  this.handleIgnoreInline(data);
@@ -233,7 +243,9 @@ export class QuestionnaireSession {
233
243
  private commit(action: QuestionnaireAction): void {
234
244
  const result = reduce(this.state, action, this.applyContext());
235
245
  this.state = result.state;
236
- for (const effect of result.effects) this.runEffect(effect);
246
+ for (const effect of result.effects) {
247
+ this.runEffect(effect);
248
+ }
237
249
  this.state = this.mirrorNotesDraft(this.state);
238
250
  this.viewAdapter.apply(this.state);
239
251
  }
@@ -274,7 +286,9 @@ export class QuestionnaireSession {
274
286
  // A no-op until the handle arrives, and suppressed entirely without a
275
287
  // raw terminal listener: see `canReopenWhileHidden`. The state still
276
288
  // says collapsed either way, so the view renders the one-line row.
277
- if (this.canReopenWhileHidden) this.overlayHandle?.setHidden(effect.hidden);
289
+ if (this.canReopenWhileHidden) {
290
+ this.overlayHandle?.setHidden(effect.hidden);
291
+ }
278
292
  return;
279
293
  case "clear_timer":
280
294
  this.clearTimer();
@@ -294,12 +308,16 @@ export class QuestionnaireSession {
294
308
  * nothing.
295
309
  */
296
310
  private openInputEditorAsync(value: string): void {
297
- if (this.inputEditorOpen) return;
311
+ if (this.inputEditorOpen) {
312
+ return;
313
+ }
298
314
  this.inputEditorOpen = true;
299
315
  void this.editInput(value).then(
300
316
  (edited) => {
301
317
  this.inputEditorOpen = false;
302
- if (edited !== undefined) this.commit({ kind: "input_replace", value: edited });
318
+ if (edited !== undefined) {
319
+ this.commit({ kind: "input_replace", value: edited });
320
+ }
303
321
  },
304
322
  () => {
305
323
  this.inputEditorOpen = false;
@@ -326,7 +344,9 @@ export class QuestionnaireSession {
326
344
  timerWasCancelled = true;
327
345
  }
328
346
  if (!this.state.inputMode) {
329
- if (timerWasCancelled) this.viewAdapter.apply(this.state);
347
+ if (timerWasCancelled) {
348
+ this.viewAdapter.apply(this.state);
349
+ }
330
350
  return;
331
351
  }
332
352
  this.inlineInput.handleInput(data);
@@ -377,6 +397,8 @@ export class QuestionnaireSession {
377
397
  * else.
378
398
  */
379
399
  toggleCollapsedExternal(): void {
380
- if (!this.inputEditorOpen) this.commit({ kind: "toggle_collapsed" });
400
+ if (!this.inputEditorOpen) {
401
+ this.commit({ kind: "toggle_collapsed" });
402
+ }
381
403
  }
382
404
  }
@@ -147,10 +147,14 @@ export function sentinelsToAppend(question: QuestionData): SentinelKind[] {
147
147
  const out: SentinelKind[] = [];
148
148
  for (const kind of SENTINEL_KINDS) {
149
149
  const meta = ROW_INTENT_META[kind];
150
- if (!meta.livesInMainList) continue;
150
+ if (!meta.livesInMainList) {
151
+ continue;
152
+ }
151
153
  const appends =
152
154
  question.multiSelect === true ? meta.autoAppendOnMultiSelect : meta.autoAppendOnSingleSelect;
153
- if (appends) out.push(kind);
155
+ if (appends) {
156
+ out.push(kind);
157
+ }
154
158
  }
155
159
  return out;
156
160
  }
@@ -15,17 +15,27 @@ export function selectConfirmedIndicator(
15
15
  items: readonly WrappingSelectItem[],
16
16
  ): { index: number; labelOverride?: string } | undefined {
17
17
  const q = questions[currentTab];
18
- if (!q || q.multiSelect === true) return undefined;
18
+ if (!q || q.multiSelect === true) {
19
+ return undefined;
20
+ }
19
21
  const prior = answers.get(currentTab);
20
- if (!prior) return undefined;
22
+ if (!prior) {
23
+ return undefined;
24
+ }
21
25
  if (prior.kind === "custom") {
22
26
  const otherIndex = items.findIndex((it) => it.kind === "other");
23
- if (otherIndex < 0) return undefined;
27
+ if (otherIndex < 0) {
28
+ return undefined;
29
+ }
24
30
  return { index: otherIndex, labelOverride: prior.answer ?? "" };
25
31
  }
26
- if (prior.kind !== "option" || typeof prior.answer !== "string") return undefined;
32
+ if (prior.kind !== "option" || typeof prior.answer !== "string") {
33
+ return undefined;
34
+ }
27
35
  const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
28
- if (index < 0) return undefined;
36
+ if (index < 0) {
37
+ return undefined;
38
+ }
29
39
  return { index };
30
40
  }
31
41
 
@@ -35,6 +45,8 @@ export function selectConfirmedIndicator(
35
45
  * machinery picks the right body component independently. Defensive against `totalQuestions === 0`.
36
46
  */
37
47
  export function selectActivePreviewPaneIndex(currentTab: number, totalQuestions: number): number {
38
- if (totalQuestions <= 0) return 0;
48
+ if (totalQuestions <= 0) {
49
+ return 0;
50
+ }
39
51
  return Math.min(currentTab, totalQuestions - 1);
40
52
  }
@@ -11,7 +11,11 @@ export function selectActiveView(
11
11
  state: { notesVisible: boolean; currentTab: number },
12
12
  totalQuestions: number,
13
13
  ): ActiveView {
14
- if (state.notesVisible) return "notes";
15
- if (state.currentTab === totalQuestions) return "submit";
14
+ if (state.notesVisible) {
15
+ return "notes";
16
+ }
17
+ if (state.currentTab === totalQuestions) {
18
+ return "submit";
19
+ }
16
20
  return "options";
17
21
  }
@@ -44,7 +44,9 @@ function nextLabelFor(ctx: PerTabBindingContext): string {
44
44
 
45
45
  export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
46
46
  const question = ctx.questions[ctx.i];
47
- if (!question) return emptyMultiSelectProps(ctx);
47
+ if (!question) {
48
+ return emptyMultiSelectProps(ctx);
49
+ }
48
50
  const focused = ctx.activeView === "options";
49
51
  const rows = question.options.map((_option, i) => ({
50
52
  checked: state.multiSelectChecked.has(i),