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
@@ -10,7 +10,10 @@ import {
10
10
  import { MultiSelectView } from "../view/components/multi-select-view.js";
11
11
  import { OptionListView } from "../view/components/option-list-view.js";
12
12
  import { PreviewBlockRenderer } from "../view/components/preview/preview-block-renderer.js";
13
- import { crossTabLeftWidthWithDonation } from "../view/components/preview/preview-layout-decider.js";
13
+ import {
14
+ crossTabLeftWidthWithDonation,
15
+ memoizeByPaneWidth,
16
+ } from "../view/components/preview/preview-layout-decider.js";
14
17
  import { PreviewPane, type PreviewPaneProps } from "../view/components/preview/preview-pane.js";
15
18
  import { SubmitPicker } from "../view/components/submit-picker.js";
16
19
  import { TabBar } from "../view/components/tab-bar.js";
@@ -121,8 +124,29 @@ class QuestionnaireBuilder {
121
124
  private readonly markdownTheme = getMarkdownTheme();
122
125
  private readonly notesInput: Editor;
123
126
  private readonly inlineInput: Editor;
124
- private readonly getTerminalWidth = () => this.tui.terminal.columns;
125
- private readonly getTerminalRows = () => this.tui.terminal.rows;
127
+ /**
128
+ * Terminal size for the frame being painted, refreshed by `beginFrame` and
129
+ * read back by everything in that frame.
130
+ *
131
+ * Components used to reach `tui.terminal` themselves, each at the moment it
132
+ * happened to run: the pane deciding side-by-side against one reading, the
133
+ * dialog cutting the scroll window against a later one. A resize landing
134
+ * between the two split the frame in half. One read at the top of
135
+ * `DialogView.render` cannot.
136
+ *
137
+ * Do not debounce this. A drag fires a resize every few milliseconds, but
138
+ * each one only reaches `tui.requestRender()`, which sets a flag, schedules
139
+ * on `process.nextTick` and holds a 16 ms floor between paints. The widths in
140
+ * between are never painted and never reach a cache; a debounce on top would
141
+ * buy nothing and delay the frame the user is dragging towards.
142
+ */
143
+ private readonly frameTerminal = { columns: 0, rows: 0 };
144
+ private readonly beginFrame = (): void => {
145
+ this.frameTerminal.columns = this.tui.terminal.columns;
146
+ this.frameTerminal.rows = this.tui.terminal.rows;
147
+ };
148
+ private readonly getFrameTerminalWidth = () => this.frameTerminal.columns;
149
+ private readonly getFrameTerminalRows = () => this.frameTerminal.rows;
126
150
 
127
151
  constructor(config: QuestionnaireBuildConfig) {
128
152
  this.tui = config.tui;
@@ -133,6 +157,9 @@ class QuestionnaireBuilder {
133
157
  this.initialState = config.initialState;
134
158
  this.getCurrentTab = config.getCurrentTab;
135
159
  this.collapseKey = config.collapseKey;
160
+ // Seeded here so a pane that renders before any frame begins sees a real
161
+ // terminal rather than a zero-width one.
162
+ this.beginFrame();
136
163
 
137
164
  this.selectTheme = this.makeSelectTheme();
138
165
  const textEditorTheme = editorTheme(this.theme);
@@ -185,7 +212,7 @@ class QuestionnaireBuilder {
185
212
  });
186
213
  const preview = new PreviewPane({
187
214
  question,
188
- getTerminalWidth: this.getTerminalWidth,
215
+ getFrameTerminalWidth: this.getFrameTerminalWidth,
189
216
  optionListView: optionList,
190
217
  previewBlock,
191
218
  });
@@ -215,8 +242,11 @@ class QuestionnaireBuilder {
215
242
  // objects first, as upstream did, only produced a shape that already
216
243
  // existed -- and produced it with an explicit undefined, which is a
217
244
  // different type from an absent key here.
218
- const globalLeftWidth = (paneWidth: number): number =>
219
- crossTabLeftWidthWithDonation(questions, itemsByTab, questions, paneWidth);
245
+ // Memoized: the questions and their rows are fixed for the life of the
246
+ // questionnaire, so the donation is pure of the pane width alone.
247
+ const globalLeftWidth = memoizeByPaneWidth((paneWidth: number): number =>
248
+ crossTabLeftWidthWithDonation(questions, itemsByTab, questions, paneWidth),
249
+ );
220
250
  for (const tab of tabs) {
221
251
  tab.preview.setGlobalLeftWidth(globalLeftWidth);
222
252
  }
@@ -235,7 +265,9 @@ class QuestionnaireBuilder {
235
265
  let max = 0;
236
266
  for (const tab of tabs) {
237
267
  const h = tab.bodyHeights(width).max;
238
- if (h > max) max = h;
268
+ if (h > max) {
269
+ max = h;
270
+ }
239
271
  }
240
272
  return Math.max(1, max);
241
273
  };
@@ -277,7 +309,8 @@ class QuestionnaireBuilder {
277
309
  ...(submitPicker === undefined ? {} : { submitPicker }),
278
310
  getBodyHeight: heights.global,
279
311
  getCurrentBodyHeight: heights.current,
280
- getTerminalRows: this.getTerminalRows,
312
+ beginFrame: this.beginFrame,
313
+ getFrameTerminalRows: this.getFrameTerminalRows,
281
314
  collapseKey: this.collapseKey,
282
315
  },
283
316
  { state: this.initialState, activePreviewPane },
@@ -312,6 +345,10 @@ class QuestionnaireBuilder {
312
345
  }),
313
346
  perTabBinding({
314
347
  resolve: (tab) => tab.multiSelect,
348
+ // Gated like the other two. A write to an inactive tab's view clears the
349
+ // layout it cached, and nothing was going to read the result: the props
350
+ // for an inactive tab only change while that tab is the active one.
351
+ predicate: isActiveTab,
315
352
  select: selectMultiSelectProps,
316
353
  }),
317
354
  ];
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -23,7 +23,9 @@ function runEditor(command: string, file: string): Promise<void> {
23
23
  const [editor, ...args] = command.split(" ");
24
24
  // Unreachable given the caller's check, but the destructure is typed as
25
25
  // possibly-undefined and a bare assertion here would be worse.
26
- if (!editor) return Promise.reject(new Error("External editor command is empty"));
26
+ if (!editor) {
27
+ return Promise.reject(new Error("External editor command is empty"));
28
+ }
27
29
 
28
30
  return new Promise((resolve, reject) => {
29
31
  const child = spawn(editor, [...args, file], {
@@ -51,6 +53,11 @@ function runEditor(command: string, file: string): Promise<void> {
51
53
  * editor, a failed temp write, anything at all must not leave the user in a
52
54
  * stopped TUI with no way back.
53
55
  *
56
+ * Every filesystem step is async. The TUI is stopped anyway, so nothing here is
57
+ * painting, but the event loop is shared with the rest of Pi and blocking it on
58
+ * a temp write over a slow or network-backed `TMPDIR` stalls every other
59
+ * extension in the process.
60
+ *
54
61
  * One trailing newline is stripped, matching Pi's main editor flow: most editors
55
62
  * add one on save, and keeping it would silently append a blank line to every
56
63
  * answer that went through here.
@@ -64,31 +71,36 @@ export async function editWithExternalEditor(
64
71
  // TUI has already been stopped, makes a misconfigured editor command flash
65
72
  // the screen off and back on before reporting a problem that was knowable
66
73
  // from the start.
67
- if (command.trim().length === 0) throw new Error("External editor command is empty");
74
+ if (command.trim().length === 0) {
75
+ throw new Error("External editor command is empty");
76
+ }
68
77
 
69
- const tempDir = mkdtempSync(join(tmpdir(), "pi-ask-popup-"));
78
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-ask-popup-"));
70
79
  const tempFile = join(tempDir, "answer.md");
71
80
  let tuiStopped = false;
72
81
 
73
82
  try {
74
- writeFileSync(tempFile, value, "utf8");
83
+ await writeFile(tempFile, value, "utf8");
75
84
  tui.stop();
76
85
  tuiStopped = true;
77
86
  process.stdout.write(
78
87
  `Launching external editor: ${command}\nPi will resume when the editor exits.\n`,
79
88
  );
80
89
  await runEditor(command, tempFile);
81
- return readFileSync(tempFile, "utf8").replace(/\r?\n$/, "");
90
+ return (await readFile(tempFile, "utf8")).replace(/\r?\n$/, "");
82
91
  } finally {
83
- try {
84
- rmSync(tempDir, { recursive: true, force: true });
85
- } catch {
86
- // Best effort. A temp directory left behind is a nuisance; a TUI left
87
- // stopped because cleanup threw is a hung session.
88
- }
92
+ // The terminal comes back before the cleanup, not after. Both are in the
93
+ // finally, but a slow or wedged unlink must not be what stands between the
94
+ // user and a working screen.
89
95
  if (tuiStopped) {
90
96
  tui.start();
91
97
  tui.requestRender(true);
92
98
  }
99
+ try {
100
+ await rm(tempDir, { recursive: true, force: true });
101
+ } catch {
102
+ // Best effort. A temp directory left behind is a nuisance; a failed
103
+ // cleanup that propagated would replace the answer with an error.
104
+ }
93
105
  }
94
106
  }
@@ -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,16 +138,36 @@ function buildSingleSelectAnswer(
117
138
  };
118
139
  }
119
140
 
141
+ function isString(value: JsonValue | undefined): value is string {
142
+ return typeof value === "string";
143
+ }
144
+
145
+ /**
146
+ * The ticked labels, plus the typed row's text when there is any.
147
+ *
148
+ * The typed row has no checkbox of its own to consult: text in it IS the tick,
149
+ * which is why an empty or whitespace-only draft contributes nothing. The text
150
+ * comes from the live editor rather than the reducer's copy, which is only
151
+ * refreshed when the user navigates off the row.
152
+ */
120
153
  function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
121
154
  const q = runtime.questions[state.currentTab];
122
- if (!q) return [];
155
+ if (!q) {
156
+ return [];
157
+ }
123
158
  const out: string[] = [];
124
159
  for (let i = 0; i < q.options.length; i++) {
125
160
  if (state.multiSelectChecked.has(i)) {
126
161
  const label = q.options[i]?.label;
127
- if (typeof label === "string") out.push(label);
162
+ if (isString(label)) {
163
+ out.push(label);
164
+ }
128
165
  }
129
166
  }
167
+ const typed = runtime.inputBuffer.trim();
168
+ if (typed.length > 0 && !out.includes(typed)) {
169
+ out.push(typed);
170
+ }
130
171
  return out;
131
172
  }
132
173
 
@@ -135,7 +176,9 @@ function tabSwitchAction(
135
176
  state: QuestionnaireState,
136
177
  runtime: QuestionnaireRuntime,
137
178
  ): QuestionnaireAction | null {
138
- if (!runtime.isMulti) return null;
179
+ if (!runtime.isMulti) {
180
+ return null;
181
+ }
139
182
  const total = totalTabs(runtime);
140
183
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
141
184
  return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
@@ -174,14 +217,22 @@ function prevNavOnUp(
174
217
  // the user can read the now-uncovered transcript without accidentally mutating
175
218
  // answers or notes. The collapse toggle itself is already handled above.
176
219
  function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
177
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
220
+ if (kb.matches(data, KEYBIND_CANCEL)) {
221
+ return { kind: "cancel" };
222
+ }
178
223
  return { kind: "ignore" };
179
224
  }
180
225
 
181
226
  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" };
227
+ if (kb.matches(data, KEYBIND_CANCEL)) {
228
+ return { kind: "notes_exit" };
229
+ }
230
+ if (kb.matches(data, KEYBIND_NEW_LINE)) {
231
+ return { kind: "notes_forward", data };
232
+ }
233
+ if (isConfirm(kb, data)) {
234
+ return { kind: "notes_exit" };
235
+ }
185
236
  return { kind: "notes_forward", data };
186
237
  }
187
238
 
@@ -193,22 +244,50 @@ function routeInputMode(
193
244
  ): QuestionnaireAction {
194
245
  // Newline takes precedence over confirmation if a user configuration binds
195
246
  // the same physical key to both semantic actions.
196
- if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
247
+ if (kb.matches(data, KEYBIND_NEW_LINE)) {
248
+ return { kind: "ignore" };
249
+ }
197
250
  if (isConfirm(kb, data)) {
251
+ // On a multi-select question, Enter here commits the question the way the
252
+ // Next row does, and `buildMultiSelected` carries the typed text along with
253
+ // whatever boxes are ticked. It used to commit the text ALONE, discarding
254
+ // every tick the user had made.
255
+ if (runtime.questions[state.currentTab]?.multiSelect === true) {
256
+ return {
257
+ kind: "multi_confirm",
258
+ selected: buildMultiSelected(state, runtime),
259
+ autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
260
+ };
261
+ }
198
262
  const answer = buildSingleSelectAnswer(state, runtime);
199
- if (!answer) return { kind: "ignore" };
263
+ if (!answer) {
264
+ return { kind: "ignore" };
265
+ }
200
266
  return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
201
267
  }
202
268
  // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
203
269
  // 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))
270
+ if (kb.matches(data, KEYBIND_CLEAR)) {
271
+ return { kind: "input_clear" };
272
+ }
273
+ if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) {
206
274
  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);
275
+ }
276
+ if (kb.matches(data, KEYBIND_CANCEL)) {
277
+ return { kind: "cancel" };
278
+ }
279
+ if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) {
280
+ return { kind: "ignore" };
281
+ }
282
+ if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) {
283
+ return { kind: "ignore" };
284
+ }
285
+ if (kb.matches(data, KEYBIND_UP)) {
286
+ return prevNavOnUp(state, runtime);
287
+ }
288
+ if (kb.matches(data, KEYBIND_DOWN)) {
289
+ return nextNavOnDown(state, runtime);
290
+ }
212
291
  return { kind: "ignore" };
213
292
  }
214
293
 
@@ -218,12 +297,17 @@ function routeSubmitTab(
218
297
  state: QuestionnaireState,
219
298
  runtime: QuestionnaireRuntime,
220
299
  ): QuestionnaireAction {
221
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
300
+ if (kb.matches(data, KEYBIND_CANCEL)) {
301
+ return { kind: "cancel" };
302
+ }
222
303
  const tab = tabSwitchAction(data, state, runtime);
223
- if (tab) return tab;
304
+ if (tab) {
305
+ return tab;
306
+ }
224
307
  if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
225
308
  const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
226
309
  const next = wrapTab(state.submitChoiceIndex + delta, 2);
310
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
227
311
  return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
228
312
  }
229
313
  if (isConfirm(kb, data)) {
@@ -257,20 +341,28 @@ function routeMultiSelectTab(
257
341
  // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
258
342
  // something." row — it is an inline input, not a checkable option).
259
343
  if (data === SPACE_KEY) {
260
- if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
261
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
344
+ if (focusedMeta?.blocksMultiToggle) {
345
+ return { kind: "ignore" };
346
+ }
347
+ if (focusedMeta?.activatesInputMode) {
348
+ return { kind: "ignore" };
349
+ }
262
350
  return { kind: "toggle", index: state.optionIndex };
263
351
  }
264
352
  if (isConfirm(kb, data)) {
265
353
  // Enter on the "Type something." row is handled by the inputMode block above
266
354
  // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
267
355
  // path for an inputMode-activating row.
268
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
356
+ if (focusedMeta?.activatesInputMode) {
357
+ return { kind: "ignore" };
358
+ }
269
359
  // Enter on a regular row toggles (matching Space) — committing the question is now
270
360
  // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
271
361
  // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
272
362
  // without leaving the keyboard home row.
273
- if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
363
+ if (!focusedMeta?.autoSubmitsInMulti) {
364
+ return { kind: "toggle", index: state.optionIndex };
365
+ }
274
366
  // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
275
367
  // multi-question mode, OR submit the dialog in single-question mode
276
368
  // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
@@ -281,7 +373,9 @@ function routeMultiSelectTab(
281
373
  autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
282
374
  };
283
375
  }
284
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
376
+ if (kb.matches(data, KEYBIND_CANCEL)) {
377
+ return { kind: "cancel" };
378
+ }
285
379
  return { kind: "ignore" };
286
380
  }
287
381
 
@@ -293,10 +387,14 @@ function routeSingleSelectTab(
293
387
  ): QuestionnaireAction {
294
388
  if (isConfirm(kb, data)) {
295
389
  const answer = buildSingleSelectAnswer(state, runtime);
296
- if (!answer) return { kind: "ignore" };
390
+ if (!answer) {
391
+ return { kind: "ignore" };
392
+ }
297
393
  return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
298
394
  }
299
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
395
+ if (kb.matches(data, KEYBIND_CANCEL)) {
396
+ return { kind: "cancel" };
397
+ }
300
398
  return { kind: "ignore" };
301
399
  }
302
400
 
@@ -329,28 +427,39 @@ export function routeKey(
329
427
  // undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
330
428
  // entire host process, so keep the runtime boundary defensive even though the
331
429
  // 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.
430
+ // importing COLLAPSE_KEY_OFF would pull ../config.js (and its config loader
431
+ // graph) into this pure module for a string that cannot change.
334
432
  if (
335
- typeof runtime.collapseKey === "string" &&
433
+ isString(runtime.collapseKey) &&
336
434
  runtime.collapseKey !== "off" &&
435
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
337
436
  matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
338
437
  ) {
339
438
  return { kind: "toggle_collapsed" };
340
439
  }
341
440
 
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);
441
+ if (state.collapsed) {
442
+ return routeCollapsed(kb, data);
443
+ }
444
+ if (state.notesVisible) {
445
+ return routeNotesMode(kb, data);
446
+ }
447
+ if (state.inputMode) {
448
+ return routeInputMode(kb, data, state, runtime);
449
+ }
345
450
  if (runtime.isMulti && state.currentTab === runtime.questions.length) {
346
451
  return routeSubmitTab(kb, data, state, runtime);
347
452
  }
348
453
 
349
454
  const tab = tabSwitchAction(data, state, runtime);
350
- if (tab) return tab;
455
+ if (tab) {
456
+ return tab;
457
+ }
351
458
 
352
459
  const q = runtime.questions[state.currentTab];
353
- if (!q) return { kind: "ignore" };
460
+ if (!q) {
461
+ return { kind: "ignore" };
462
+ }
354
463
 
355
464
  // Universal `n` activation (FR-1): the notes editor opens on every question tab
356
465
  // (single- or multi-select, preview or no-preview). The blocks above already
@@ -373,6 +482,8 @@ export function routeKey(
373
482
  return nextNavOnDown(state, runtime);
374
483
  }
375
484
 
376
- if (q.multiSelect) return routeMultiSelectTab(kb, data, state, runtime);
485
+ if (q.multiSelect) {
486
+ return routeMultiSelectTab(kb, data, state, runtime);
487
+ }
377
488
  return routeSingleSelectTab(kb, data, state, runtime);
378
489
  }