pi-ask-popup 0.1.0 → 0.2.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 (37) 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 +54 -21
  11. package/src/config.ts +118 -24
  12. package/src/rpc-fallback.ts +85 -28
  13. package/src/state/build-questionnaire.ts +45 -8
  14. package/src/state/external-editor.ts +24 -12
  15. package/src/state/key-router.ts +152 -41
  16. package/src/state/questionnaire-session.ts +79 -14
  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 +9 -1
  21. package/src/state/state-reducer.ts +110 -38
  22. package/src/tool/response-envelope.ts +71 -22
  23. package/src/tool/types.ts +28 -4
  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 +32 -7
  27. package/src/view/components/option-list-view.ts +5 -0
  28. package/src/view/components/preview/markdown-content-cache.ts +75 -23
  29. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  30. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  31. package/src/view/components/preview/preview-layout-decider.ts +52 -15
  32. package/src/view/components/preview/preview-pane.ts +72 -28
  33. package/src/view/components/tab-bar.ts +26 -8
  34. package/src/view/components/wrapping-select.ts +110 -37
  35. package/src/view/dialog-builder.ts +44 -10
  36. package/src/view/props-adapter.ts +29 -7
  37. package/src/view/tab-content-strategy.ts +69 -21
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Editor, OverlayHandle, TUI } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
4
  import { COLLAPSE_KEY_OFF, formatKeySpecForDisplay } from "../config.js";
4
5
  import type { QuestionData, QuestionnaireResult, QuestionParams } from "../tool/types.js";
5
6
  import {
@@ -38,6 +39,32 @@ export interface QuestionnaireSessionConfig {
38
39
  canReopenWhileHidden: boolean;
39
40
  }
40
41
 
42
+ /** Header of the screen shown when the overlay could not be painted. */
43
+ export const RENDER_FAILED_TEXT = "Questionnaire failed to render — press Esc to cancel";
44
+
45
+ /** Never throws, whatever was thrown: this runs inside the boundary. */
46
+ function describeThrown(thrown: unknown): string {
47
+ try {
48
+ return thrown instanceof Error ? thrown.message : String(thrown);
49
+ } catch {
50
+ return "unknown error";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The screen the boundary paints in place of the overlay.
56
+ *
57
+ * Deliberately themeless and unstyled: a theme call is one of the things that
58
+ * can have thrown, and a fallback that needs the failing machinery to work is
59
+ * not a fallback. Two plain rows, clipped to the width.
60
+ */
61
+ function renderFailureScreen(width: number, thrown: unknown): string[] {
62
+ return [
63
+ truncateToWidth(` ${RENDER_FAILED_TEXT} `, width, ""),
64
+ truncateToWidth(` ${describeThrown(thrown)} `, width, "…"),
65
+ ];
66
+ }
67
+
41
68
  export interface QuestionnaireSessionComponent {
42
69
  render(width: number): string[];
43
70
  invalidate(): void;
@@ -152,7 +179,9 @@ export class QuestionnaireSession {
152
179
 
153
180
  this.component = this.assembleComponent(built, config.theme);
154
181
  this.viewAdapter.apply(this.state);
155
- if (this.state.deadline !== undefined) this.startTimer();
182
+ if (this.state.deadline !== undefined) {
183
+ this.startTimer();
184
+ }
156
185
  }
157
186
 
158
187
  private assembleComponent(
@@ -161,7 +190,18 @@ export class QuestionnaireSession {
161
190
  ): QuestionnaireSessionComponent {
162
191
  const collapsedRender = this.buildCollapsedRender(theme);
163
192
  return {
164
- render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
193
+ // The boundary. Everything below it — markdown from the model, border
194
+ // arithmetic, width math — runs inside pi-tui's render loop, where a
195
+ // throw takes the overlay down and leaves the user with no way to answer
196
+ // or dismiss it. Input still routes through `handleInput`, so Esc works
197
+ // on the fallback screen.
198
+ render: (width) => {
199
+ try {
200
+ return this.state.collapsed ? collapsedRender(width) : built.render(width);
201
+ } catch (thrown) {
202
+ return renderFailureScreen(width, thrown);
203
+ }
204
+ },
165
205
  invalidate: built.invalidate,
166
206
  handleInput: (data) => this.dispatch(data),
167
207
  };
@@ -180,15 +220,17 @@ export class QuestionnaireSession {
180
220
  * falls back to cancel-only rather than telling the user to press "Off".
181
221
  */
182
222
  private startTimer(): void {
183
- if (this.timer !== undefined) return;
223
+ if (this.timer !== undefined) {
224
+ return;
225
+ }
184
226
  this.timer = setInterval(() => {
185
227
  this.commit({ kind: "tick", now: Date.now() });
186
228
  }, 1000);
187
229
  // Don't keep the process alive after Pi exits.
188
230
  // 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") {
231
+ if (typeof (this.timer as { unref?: () => void }).unref === "function") {
190
232
  // SAFETY: same guard as above — only called when unref is a function.
191
- (this.timer as unknown as { unref: () => void }).unref();
233
+ (this.timer as { unref: () => void }).unref();
192
234
  }
193
235
  }
194
236
 
@@ -200,9 +242,13 @@ export class QuestionnaireSession {
200
242
  }
201
243
 
202
244
  private formatRemaining(): string | undefined {
203
- if (this.state.timerCancelled) return undefined;
245
+ if (this.state.timerCancelled) {
246
+ return undefined;
247
+ }
204
248
  const ms = this.state.remainingMs;
205
- if (ms === undefined) return undefined;
249
+ if (ms === undefined) {
250
+ return undefined;
251
+ }
206
252
  const secs = Math.max(0, Math.ceil(ms / 1000));
207
253
  return `${secs}s`;
208
254
  }
@@ -221,7 +267,9 @@ export class QuestionnaireSession {
221
267
  }
222
268
 
223
269
  dispatch(data: string): void {
224
- if (this.inputEditorOpen) return;
270
+ if (this.inputEditorOpen) {
271
+ return;
272
+ }
225
273
  const action = routeKey(data, this.state, this.runtime());
226
274
  if (action.kind === "ignore") {
227
275
  this.handleIgnoreInline(data);
@@ -233,7 +281,9 @@ export class QuestionnaireSession {
233
281
  private commit(action: QuestionnaireAction): void {
234
282
  const result = reduce(this.state, action, this.applyContext());
235
283
  this.state = result.state;
236
- for (const effect of result.effects) this.runEffect(effect);
284
+ for (const effect of result.effects) {
285
+ this.runEffect(effect);
286
+ }
237
287
  this.state = this.mirrorNotesDraft(this.state);
238
288
  this.viewAdapter.apply(this.state);
239
289
  }
@@ -274,7 +324,9 @@ export class QuestionnaireSession {
274
324
  // A no-op until the handle arrives, and suppressed entirely without a
275
325
  // raw terminal listener: see `canReopenWhileHidden`. The state still
276
326
  // says collapsed either way, so the view renders the one-line row.
277
- if (this.canReopenWhileHidden) this.overlayHandle?.setHidden(effect.hidden);
327
+ if (this.canReopenWhileHidden) {
328
+ this.overlayHandle?.setHidden(effect.hidden);
329
+ }
278
330
  return;
279
331
  case "clear_timer":
280
332
  this.clearTimer();
@@ -294,12 +346,16 @@ export class QuestionnaireSession {
294
346
  * nothing.
295
347
  */
296
348
  private openInputEditorAsync(value: string): void {
297
- if (this.inputEditorOpen) return;
349
+ if (this.inputEditorOpen) {
350
+ return;
351
+ }
298
352
  this.inputEditorOpen = true;
299
353
  void this.editInput(value).then(
300
354
  (edited) => {
301
355
  this.inputEditorOpen = false;
302
- if (edited !== undefined) this.commit({ kind: "input_replace", value: edited });
356
+ if (edited !== undefined) {
357
+ this.commit({ kind: "input_replace", value: edited });
358
+ }
303
359
  },
304
360
  () => {
305
361
  this.inputEditorOpen = false;
@@ -326,9 +382,16 @@ export class QuestionnaireSession {
326
382
  timerWasCancelled = true;
327
383
  }
328
384
  if (!this.state.inputMode) {
329
- if (timerWasCancelled) this.viewAdapter.apply(this.state);
385
+ if (timerWasCancelled) {
386
+ this.viewAdapter.apply(this.state);
387
+ }
330
388
  return;
331
389
  }
390
+ // One apply, and the editor adds none of its own: pi-tui's `Editor`
391
+ // requests a render from its setters and its autocomplete callbacks, never
392
+ // from `handleInput`, and this editor has no autocomplete provider. So a
393
+ // keystroke here asks for exactly one render — which the session's tests
394
+ // pin, since a second would mean two owners of one tick.
332
395
  this.inlineInput.handleInput(data);
333
396
  this.viewAdapter.apply(this.state);
334
397
  }
@@ -377,6 +440,8 @@ export class QuestionnaireSession {
377
440
  * else.
378
441
  */
379
442
  toggleCollapsedExternal(): void {
380
- if (!this.inputEditorOpen) this.commit({ kind: "toggle_collapsed" });
443
+ if (!this.inputEditorOpen) {
444
+ this.commit({ kind: "toggle_collapsed" });
445
+ }
381
446
  }
382
447
  }
@@ -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
  }
@@ -22,6 +22,7 @@ function emptyMultiSelectProps(ctx: PerTabBindingContext): MultiSelectViewProps
22
22
  rows: [],
23
23
  other: {
24
24
  active: false,
25
+ checked: false,
25
26
  inputMode: false,
26
27
  inputBuffer: ctx.inputBuffer,
27
28
  inputCursorOffset: ctx.inputCursorOffset,
@@ -44,7 +45,9 @@ function nextLabelFor(ctx: PerTabBindingContext): string {
44
45
 
45
46
  export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
46
47
  const question = ctx.questions[ctx.i];
47
- if (!question) return emptyMultiSelectProps(ctx);
48
+ if (!question) {
49
+ return emptyMultiSelectProps(ctx);
50
+ }
48
51
  const focused = ctx.activeView === "options";
49
52
  const rows = question.options.map((_option, i) => ({
50
53
  checked: state.multiSelectChecked.has(i),
@@ -54,6 +57,11 @@ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (sta
54
57
  rows,
55
58
  other: {
56
59
  active: focused && state.optionIndex === question.options.length,
60
+ // Text in the row is the tick: it appears on the first character typed and
61
+ // goes on the first delete that empties the row, without a keystroke of
62
+ // its own. Read from the live editor text, because typing never reaches
63
+ // the reducer — `handleIgnoreInline` feeds the editor directly.
64
+ checked: ctx.inputBuffer.trim().length > 0,
57
65
  inputMode: state.inputMode,
58
66
  inputBuffer: ctx.inputBuffer,
59
67
  inputCursorOffset: ctx.inputCursorOffset,
@@ -8,6 +8,17 @@ import type { WrappingSelectItem } from "./row-intent.js";
8
8
  import type { QuestionnaireAction } from "./key-router.js";
9
9
  import { ROW_INTENT_META } from "./row-intent.js";
10
10
  import { noteForTab, type QuestionnaireState } from "./state.js";
11
+ type JsonValue =
12
+ | string
13
+ | number
14
+ | boolean
15
+ | null
16
+ | JsonValue[]
17
+ | { readonly [key: string]: JsonValue };
18
+
19
+ function isString(value: JsonValue | undefined): value is string {
20
+ return typeof value === "string";
21
+ }
11
22
 
12
23
  /** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */
13
24
  export interface ApplyContext {
@@ -51,7 +62,9 @@ function orderedAnswers(
51
62
  const out: QuestionAnswer[] = [];
52
63
  for (let i = 0; i < questions.length; i++) {
53
64
  const a = state.answers.get(i);
54
- if (a) out.push(a);
65
+ if (a) {
66
+ out.push(a);
67
+ }
55
68
  }
56
69
  return out;
57
70
  }
@@ -73,10 +86,14 @@ function unansweredNotesFor(
73
86
  ): UnansweredNote[] {
74
87
  const out: UnansweredNote[] = [];
75
88
  for (let i = 0; i < questions.length; i++) {
76
- if (state.answers.has(i)) continue;
89
+ if (state.answers.has(i)) {
90
+ continue;
91
+ }
77
92
  const question = questions[i];
78
93
  const note = state.notesByTab.get(i);
79
- if (!question || note === undefined || note.length === 0) continue;
94
+ if (!question || note === undefined || note.length === 0) {
95
+ continue;
96
+ }
80
97
  out.push({ questionIndex: i, question: question.question, note });
81
98
  }
82
99
  return out;
@@ -88,13 +105,19 @@ function syncMultiSelectFromAnswers(
88
105
  tab: number,
89
106
  ): ReadonlySet<number> {
90
107
  const q = questions[tab];
91
- if (!q?.multiSelect) return new Set();
108
+ if (!q?.multiSelect) {
109
+ return new Set();
110
+ }
92
111
  const saved = answers.get(tab);
93
112
  const labels = saved?.selected ?? [];
94
113
  const indices = new Set<number>();
95
114
  for (let i = 0; i < q.options.length; i++) {
96
- if (labels.includes(q.options[i]!.label)) indices.add(i);
115
+ if (labels.includes(q.options[i]!.label)) {
116
+ indices.add(i);
117
+ }
97
118
  }
119
+ // The typed row needs nothing here. Its tick is its text, and the text comes
120
+ // back with the tab's draft.
98
121
  return indices;
99
122
  }
100
123
 
@@ -103,10 +126,20 @@ function persistMultiSelectAnswer(
103
126
  ctx: ApplyContext,
104
127
  ): ReadonlyMap<number, QuestionAnswer> {
105
128
  const q = ctx.questions[state.currentTab];
106
- if (!q?.multiSelect) return state.answers;
129
+ if (!q?.multiSelect) {
130
+ return state.answers;
131
+ }
107
132
  const selected: string[] = [];
108
133
  for (let i = 0; i < q.options.length; i++) {
109
- if (state.multiSelectChecked.has(i)) selected.push(q.options[i]!.label);
134
+ if (state.multiSelectChecked.has(i)) {
135
+ selected.push(q.options[i]!.label);
136
+ }
137
+ }
138
+ // Text in the typed row is itself the tick, so a non-blank draft joins the
139
+ // selection. A draft that repeats an option label is not listed twice.
140
+ const typed = customDraftValueFor(state, state.currentTab).trim();
141
+ if (typed.length > 0 && !selected.includes(typed)) {
142
+ selected.push(typed);
110
143
  }
111
144
  const out = new Map(state.answers);
112
145
  if (selected.length === 0) {
@@ -114,22 +147,28 @@ function persistMultiSelectAnswer(
114
147
  return out;
115
148
  }
116
149
  const pendingNotes = state.notesByTab.get(state.currentTab);
117
- out.set(state.currentTab, {
150
+ const entry: QuestionAnswer = {
118
151
  questionIndex: state.currentTab,
119
152
  question: q.question,
120
153
  kind: "multi",
121
154
  answer: null,
122
155
  selected,
123
- ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
124
- });
156
+ };
157
+ if (pendingNotes && pendingNotes.length > 0) {
158
+ // SAFETY: notes is an optional string per QuestionAnswer contract; adding when present preserves the shape.
159
+ (entry as QuestionAnswer & { notes: string }).notes = pendingNotes;
160
+ }
161
+ out.set(state.currentTab, entry);
125
162
  return out;
126
163
  }
127
164
 
128
165
  function customDraftValueFor(state: QuestionnaireState, tab: number): string {
129
166
  const draft = state.customDraftsByTab.get(tab);
130
- if (draft !== undefined) return draft;
167
+ if (draft !== undefined) {
168
+ return draft;
169
+ }
131
170
  const answer = state.answers.get(tab);
132
- return answer?.kind === "custom" && typeof answer.answer === "string" ? answer.answer : "";
171
+ return answer?.kind === "custom" && isString(answer.answer) ? answer.answer : "";
133
172
  }
134
173
 
135
174
  function setCustomDraft(
@@ -143,7 +182,9 @@ function setCustomDraft(
143
182
  }
144
183
 
145
184
  function withoutCustomDraft(state: QuestionnaireState, tab: number): ReadonlyMap<number, string> {
146
- if (!state.customDraftsByTab.has(tab)) return state.customDraftsByTab;
185
+ if (!state.customDraftsByTab.has(tab)) {
186
+ return state.customDraftsByTab;
187
+ }
147
188
  const drafts = new Map(state.customDraftsByTab);
148
189
  drafts.delete(tab);
149
190
  return drafts;
@@ -189,9 +230,16 @@ function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolea
189
230
  const result: QuestionnaireResult = {
190
231
  answers: orderedAnswers(state, ctx.questions),
191
232
  cancelled,
192
- ...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
193
- ...(unansweredNotes.length > 0 ? { unansweredNotes } : {}),
194
233
  };
234
+ if (globalNote && globalNote.length > 0) {
235
+ // SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
236
+ (result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
237
+ }
238
+ if (unansweredNotes.length > 0) {
239
+ // SAFETY: unansweredNotes is optional per QuestionnaireResult; present only when non-empty.
240
+ (result as QuestionnaireResult & { unansweredNotes: UnansweredNote[] }).unansweredNotes =
241
+ unansweredNotes;
242
+ }
195
243
  return { state, effects: [{ kind: "done", result }] };
196
244
  }
197
245
 
@@ -218,7 +266,15 @@ const navHandler: Handler<"nav"> = (state, action, ctx) => {
218
266
  inputMode,
219
267
  customDraftsByTab,
220
268
  };
221
- if (!inputMode) return { state: next, effects: [] };
269
+ // Leaving the typed row: keystrokes never reached the reducer, so this is the
270
+ // first moment it sees the finished text. Re-state the answer here or the
271
+ // Submit review quotes a draft that is several characters out of date.
272
+ if (state.inputMode) {
273
+ next.answers = persistMultiSelectAnswer(next, ctx);
274
+ }
275
+ if (!inputMode) {
276
+ return { state: next, effects: [] };
277
+ }
222
278
  return {
223
279
  state: next,
224
280
  effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }],
@@ -256,11 +312,6 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
256
312
  }
257
313
  const answers = new Map(state.answers);
258
314
  answers.set(answer.questionIndex, answer);
259
- // Custom free-text on a multi-select tab is mutually exclusive with checkbox selections:
260
- // clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer
261
- // carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.)
262
- const isCustomMulti =
263
- answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
264
315
  const customDraftsByTab =
265
316
  answer.kind === "custom"
266
317
  ? withoutCustomDraft(state, answer.questionIndex)
@@ -269,16 +320,20 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
269
320
  ...state,
270
321
  answers,
271
322
  customDraftsByTab,
272
- ...(isCustomMulti ? { multiSelectChecked: new Set<number>() } : {}),
273
323
  };
274
- if (action.autoAdvanceTab !== undefined) return switchTabResult(next, action.autoAdvanceTab, ctx);
324
+ if (action.autoAdvanceTab !== undefined) {
325
+ return switchTabResult(next, action.autoAdvanceTab, ctx);
326
+ }
275
327
  return doneFor(next, ctx, false);
276
328
  };
277
329
 
278
330
  const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
279
331
  const checked = new Set(state.multiSelectChecked);
280
- if (checked.has(action.index)) checked.delete(action.index);
281
- else checked.add(action.index);
332
+ if (checked.has(action.index)) {
333
+ checked.delete(action.index);
334
+ } else {
335
+ checked.add(action.index);
336
+ }
282
337
  const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked };
283
338
  const answers = persistMultiSelectAnswer(intermediate, ctx);
284
339
  return { state: { ...intermediate, answers }, effects: [] };
@@ -286,24 +341,31 @@ const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
286
341
 
287
342
  const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
288
343
  const q = ctx.questions[state.currentTab];
289
- if (!q) return { state, effects: [] };
344
+ if (!q) {
345
+ return { state, effects: [] };
346
+ }
290
347
  const pendingNotes = state.notesByTab.get(state.currentTab);
291
348
  const answers = new Map(state.answers);
292
- answers.set(state.currentTab, {
349
+ const multiAnswer: QuestionAnswer = {
293
350
  questionIndex: state.currentTab,
294
351
  question: q.question,
295
352
  kind: "multi",
296
353
  answer: null,
297
354
  selected: action.selected,
298
- ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
299
- });
355
+ };
356
+ if (pendingNotes && pendingNotes.length > 0) {
357
+ // SAFETY: notes is optional per QuestionAnswer; adding when present preserves the shape.
358
+ (multiAnswer as QuestionAnswer & { notes: string }).notes = pendingNotes;
359
+ }
360
+ answers.set(state.currentTab, multiAnswer);
300
361
  const synced: QuestionnaireState = {
301
362
  ...state,
302
363
  answers,
303
364
  multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
304
365
  };
305
- if (action.autoAdvanceTab !== undefined)
366
+ if (action.autoAdvanceTab !== undefined) {
306
367
  return switchTabResult(synced, action.autoAdvanceTab, ctx);
368
+ }
307
369
  return doneFor(synced, ctx, false);
308
370
  };
309
371
 
@@ -327,13 +389,16 @@ const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
327
389
  const prev = answers.get(state.currentTab);
328
390
  if (prev?.notes) {
329
391
  const stripped = { ...prev };
330
- delete (stripped as { notes?: string }).notes;
331
- answers.set(state.currentTab, stripped);
392
+ const { notes: _removed, ...withoutNotes } = stripped;
393
+ // SAFETY: withoutNotes preserves all required QuestionAnswer fields; notes is optional and removed intentionally.
394
+ answers.set(state.currentTab, withoutNotes as QuestionAnswer);
332
395
  }
333
396
  } else {
334
397
  notes.set(state.currentTab, trimmed);
335
398
  const prev = answers.get(state.currentTab);
336
- if (prev) answers.set(state.currentTab, { ...prev, notes: trimmed });
399
+ if (prev) {
400
+ answers.set(state.currentTab, { ...prev, notes: trimmed });
401
+ }
337
402
  }
338
403
  return {
339
404
  state: { ...state, notesByTab: notes, answers, notesVisible: false },
@@ -356,7 +421,9 @@ const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({
356
421
  effects: [{ kind: "set_overlay_hidden", hidden: !s.collapsed }],
357
422
  });
358
423
  const tickHandler: Handler<"tick"> = (state, action, ctx) => {
359
- if (state.timerCancelled || state.deadline === undefined) return { state, effects: [] };
424
+ if (state.timerCancelled || state.deadline === undefined) {
425
+ return { state, effects: [] };
426
+ }
360
427
  const remaining = state.deadline - action.now;
361
428
  if (remaining > 0) {
362
429
  return { state: { ...state, remainingMs: remaining }, effects: [] };
@@ -366,8 +433,11 @@ const tickHandler: Handler<"tick"> = (state, action, ctx) => {
366
433
  answers: orderedAnswers(state, ctx.questions),
367
434
  cancelled: true,
368
435
  error: "timed_out",
369
- ...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
370
436
  };
437
+ if (globalNote && globalNote.length > 0) {
438
+ // SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
439
+ (result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
440
+ }
371
441
  return { state, effects: [{ kind: "done", result }] };
372
442
  };
373
443
  const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] });
@@ -378,7 +448,7 @@ const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: []
378
448
  * compile here until a handler is registered, mirroring the `Record<RowKind, …>`
379
449
  * pattern used by `ROW_INTENT_META`.
380
450
  */
381
- const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
451
+ const HANDLERS = {
382
452
  nav: navHandler,
383
453
  input_clear: inputClearHandler,
384
454
  input_edit: inputEditHandler,
@@ -396,10 +466,10 @@ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
396
466
  toggle_collapsed: toggleCollapsedHandler,
397
467
  tick: tickHandler,
398
468
  ignore: ignoreHandler,
399
- };
469
+ } satisfies { [K in QuestionnaireAction["kind"]]: Handler<K> };
400
470
 
401
471
  /**
402
- * Pure reducer: (state, action, ctx) → (state, Effect[]). Mirrors `rpiv-todo`'s `applyTaskMutation`.
472
+ * Pure reducer: (state, action, ctx) → (state, Effect[]).
403
473
  * Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
404
474
  * `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
405
475
  */
@@ -408,7 +478,9 @@ export function reduce(
408
478
  action: QuestionnaireAction,
409
479
  ctx: ApplyContext,
410
480
  ): ApplyResult {
481
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
411
482
  const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
483
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
412
484
  const result = handler(state, action as never, ctx);
413
485
  const isHumanKeystroke = action.kind !== "tick" && action.kind !== "toggle_collapsed";
414
486
  if (isHumanKeystroke && state.deadline !== undefined && !state.timerCancelled) {