pi-ask-popup 0.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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- package/src/view/tab-content-strategy.ts +447 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { QuestionAnswer } from "../tool/types.js";
|
|
3
|
+
import { ROW_INTENT_META } from "./row-intent.js";
|
|
4
|
+
import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
const KEYBIND_UP = "tui.select.up";
|
|
7
|
+
const KEYBIND_DOWN = "tui.select.down";
|
|
8
|
+
const KEYBIND_CONFIRM = "tui.select.confirm";
|
|
9
|
+
const KEYBIND_SUBMIT = "tui.input.submit";
|
|
10
|
+
const KEYBIND_CANCEL = "tui.select.cancel";
|
|
11
|
+
const KEYBIND_NEW_LINE = "tui.input.newLine";
|
|
12
|
+
const KEYBIND_EDITOR_UP = "tui.editor.cursorUp";
|
|
13
|
+
const KEYBIND_EDITOR_DOWN = "tui.editor.cursorDown";
|
|
14
|
+
const KEYBIND_CLEAR = "tui.editor.deleteToLineStart";
|
|
15
|
+
const KEYBIND_EXTERNAL_EDITOR = "app.editor.external";
|
|
16
|
+
|
|
17
|
+
const NOTES_ACTIVATE_KEY = "n";
|
|
18
|
+
const SPACE_KEY = " ";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `autoAdvanceTab` is deliberately `number | undefined` rather than merely
|
|
22
|
+
* optional. An explicit `undefined` is a signal, not an omission: it means the
|
|
23
|
+
* questionnaire is on its last tab and confirming should finish rather than
|
|
24
|
+
* advance. `computeAutoAdvanceTab` returns it, and the reducer branches on
|
|
25
|
+
* `!== undefined` to choose between `switchTabResult` and `doneFor`.
|
|
26
|
+
*/
|
|
27
|
+
export type QuestionnaireAction =
|
|
28
|
+
| { kind: "nav"; nextIndex: number; inputValue: string }
|
|
29
|
+
| { kind: "input_clear" }
|
|
30
|
+
| { kind: "input_edit"; value: string }
|
|
31
|
+
| { kind: "input_replace"; value: string }
|
|
32
|
+
| { kind: "tab_switch"; nextTab: number }
|
|
33
|
+
| { kind: "confirm"; answer: QuestionAnswer; autoAdvanceTab?: number | undefined }
|
|
34
|
+
| { kind: "toggle"; index: number }
|
|
35
|
+
| { kind: "multi_confirm"; selected: string[]; autoAdvanceTab?: number | undefined }
|
|
36
|
+
| { kind: "cancel" }
|
|
37
|
+
| { kind: "notes_enter" }
|
|
38
|
+
| { kind: "notes_exit" }
|
|
39
|
+
| { kind: "submit" }
|
|
40
|
+
| { kind: "submit_nav"; nextIndex: 0 | 1 }
|
|
41
|
+
| { kind: "notes_forward"; data: string }
|
|
42
|
+
/** Flip `state.collapsed`. Always available, regardless of inner mode (see top intercept in `routeKey`). */
|
|
43
|
+
| { kind: "toggle_collapsed" }
|
|
44
|
+
| { kind: "tick"; now: number }
|
|
45
|
+
| { kind: "ignore" };
|
|
46
|
+
|
|
47
|
+
export interface QuestionnaireKeybindings {
|
|
48
|
+
matches(data: string, name: string): boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Confirm has two semantic sources: `tui.select.confirm` (the select-list default)
|
|
52
|
+
// and `tui.input.submit` (the user's "send" key). A Slack-style configuration folds
|
|
53
|
+
// `enter` into `tui.input.newLine` and moves submit elsewhere (e.g. `ctrl+enter`);
|
|
54
|
+
// the newline checks below stay first, so without the submit source every confirm
|
|
55
|
+
// branch would be shadowed by the collision and no key could confirm (#156). With
|
|
56
|
+
// pi defaults both names resolve to `enter`, so matching either is equivalent.
|
|
57
|
+
function isConfirm(kb: QuestionnaireKeybindings, data: string): boolean {
|
|
58
|
+
return kb.matches(data, KEYBIND_CONFIRM) || kb.matches(data, KEYBIND_SUBMIT);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function wrapTab(index: number, total: number): number {
|
|
62
|
+
if (total <= 0) return 0;
|
|
63
|
+
return ((index % total) + total) % total;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean {
|
|
67
|
+
if (runtime.questions.length === 0) return false;
|
|
68
|
+
for (let i = 0; i < runtime.questions.length; i++) {
|
|
69
|
+
if (!state.answers.has(i)) return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function totalTabs(runtime: QuestionnaireRuntime): number {
|
|
75
|
+
return runtime.isMulti ? runtime.questions.length + 1 : 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function computeAutoAdvanceTab(
|
|
79
|
+
state: QuestionnaireState,
|
|
80
|
+
runtime: QuestionnaireRuntime,
|
|
81
|
+
): number | undefined {
|
|
82
|
+
if (!runtime.isMulti) return undefined;
|
|
83
|
+
if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1;
|
|
84
|
+
return runtime.questions.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildSingleSelectAnswer(
|
|
88
|
+
state: QuestionnaireState,
|
|
89
|
+
runtime: QuestionnaireRuntime,
|
|
90
|
+
): QuestionAnswer | null {
|
|
91
|
+
const q = runtime.questions[state.currentTab];
|
|
92
|
+
if (!q) return null;
|
|
93
|
+
|
|
94
|
+
const item = runtime.currentItem;
|
|
95
|
+
|
|
96
|
+
if (state.inputMode) {
|
|
97
|
+
const label = runtime.inputBuffer;
|
|
98
|
+
return {
|
|
99
|
+
questionIndex: state.currentTab,
|
|
100
|
+
question: q.question,
|
|
101
|
+
kind: "custom",
|
|
102
|
+
answer: label.length > 0 ? label : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!item) return null;
|
|
106
|
+
if (item.kind === "other") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (item.kind === "next") {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
questionIndex: state.currentTab,
|
|
114
|
+
question: q.question,
|
|
115
|
+
kind: "option",
|
|
116
|
+
answer: item.label,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
|
|
121
|
+
const q = runtime.questions[state.currentTab];
|
|
122
|
+
if (!q) return [];
|
|
123
|
+
const out: string[] = [];
|
|
124
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
125
|
+
if (state.multiSelectChecked.has(i)) {
|
|
126
|
+
const label = q.options[i]?.label;
|
|
127
|
+
if (typeof label === "string") out.push(label);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tabSwitchAction(
|
|
134
|
+
data: string,
|
|
135
|
+
state: QuestionnaireState,
|
|
136
|
+
runtime: QuestionnaireRuntime,
|
|
137
|
+
): QuestionnaireAction | null {
|
|
138
|
+
if (!runtime.isMulti) return null;
|
|
139
|
+
const total = totalTabs(runtime);
|
|
140
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
141
|
+
return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
|
|
142
|
+
}
|
|
143
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
144
|
+
return { kind: "tab_switch", nextTab: wrapTab(state.currentTab - 1, total) };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// DOWN at the last item wraps to the first (cycle through [option0, …, optionLast]).
|
|
150
|
+
function nextNavOnDown(
|
|
151
|
+
state: QuestionnaireState,
|
|
152
|
+
runtime: QuestionnaireRuntime,
|
|
153
|
+
): QuestionnaireAction {
|
|
154
|
+
return {
|
|
155
|
+
kind: "nav",
|
|
156
|
+
nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)),
|
|
157
|
+
inputValue: runtime.inputBuffer,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// UP at the first item wraps to the last (symmetric with nextNavOnDown).
|
|
162
|
+
function prevNavOnUp(
|
|
163
|
+
state: QuestionnaireState,
|
|
164
|
+
runtime: QuestionnaireRuntime,
|
|
165
|
+
): QuestionnaireAction {
|
|
166
|
+
return {
|
|
167
|
+
kind: "nav",
|
|
168
|
+
nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)),
|
|
169
|
+
inputValue: runtime.inputBuffer,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so
|
|
174
|
+
// the user can read the now-uncovered transcript without accidentally mutating
|
|
175
|
+
// answers or notes. The collapse toggle itself is already handled above.
|
|
176
|
+
function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
|
|
177
|
+
if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
|
|
178
|
+
return { kind: "ignore" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
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" };
|
|
185
|
+
return { kind: "notes_forward", data };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function routeInputMode(
|
|
189
|
+
kb: QuestionnaireKeybindings,
|
|
190
|
+
data: string,
|
|
191
|
+
state: QuestionnaireState,
|
|
192
|
+
runtime: QuestionnaireRuntime,
|
|
193
|
+
): QuestionnaireAction {
|
|
194
|
+
// Newline takes precedence over confirmation if a user configuration binds
|
|
195
|
+
// the same physical key to both semantic actions.
|
|
196
|
+
if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
|
|
197
|
+
if (isConfirm(kb, data)) {
|
|
198
|
+
const answer = buildSingleSelectAnswer(state, runtime);
|
|
199
|
+
if (!answer) return { kind: "ignore" };
|
|
200
|
+
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
201
|
+
}
|
|
202
|
+
// Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
|
|
203
|
+
// 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))
|
|
206
|
+
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);
|
|
212
|
+
return { kind: "ignore" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function routeSubmitTab(
|
|
216
|
+
kb: QuestionnaireKeybindings,
|
|
217
|
+
data: string,
|
|
218
|
+
state: QuestionnaireState,
|
|
219
|
+
runtime: QuestionnaireRuntime,
|
|
220
|
+
): QuestionnaireAction {
|
|
221
|
+
if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
|
|
222
|
+
const tab = tabSwitchAction(data, state, runtime);
|
|
223
|
+
if (tab) return tab;
|
|
224
|
+
if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
|
|
225
|
+
const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
|
|
226
|
+
const next = wrapTab(state.submitChoiceIndex + delta, 2);
|
|
227
|
+
return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
|
|
228
|
+
}
|
|
229
|
+
if (isConfirm(kb, data)) {
|
|
230
|
+
// D1 (revised): Submit always submits; Cancel always cancels. The warning header
|
|
231
|
+
// is informational only — `allAnswered(state)` no longer gates submission. Partial
|
|
232
|
+
// answers flow through `orderedAnswers()` in the host.
|
|
233
|
+
return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
|
|
234
|
+
}
|
|
235
|
+
// Global note (#182): `n` on the Submit tab opens the notes editor scoped to the whole
|
|
236
|
+
// questionnaire at the pseudo-index (`questions.length` in notesByTab). Reachable only
|
|
237
|
+
// with the editor closed — `routeKey` dispatches notesVisible traffic to
|
|
238
|
+
// `routeNotesMode` before the submit-tab block runs. The branch shadows nothing above
|
|
239
|
+
// it (cancel, tab switch, submit-nav, confirm): a literal `n` byte matches none of them
|
|
240
|
+
// under default bindings, and a user who deliberately binds one of those actions to
|
|
241
|
+
// `n` keeps that mapping because the earlier branch still wins (Enter still submits).
|
|
242
|
+
if (data === NOTES_ACTIVATE_KEY) {
|
|
243
|
+
return { kind: "notes_enter" };
|
|
244
|
+
}
|
|
245
|
+
return { kind: "ignore" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function routeMultiSelectTab(
|
|
249
|
+
kb: QuestionnaireKeybindings,
|
|
250
|
+
data: string,
|
|
251
|
+
state: QuestionnaireState,
|
|
252
|
+
runtime: QuestionnaireRuntime,
|
|
253
|
+
): QuestionnaireAction {
|
|
254
|
+
const focusedKind = runtime.currentItem?.kind;
|
|
255
|
+
const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
|
|
256
|
+
// Space toggles the focused row's checkbox. Suppressed on rows whose META declares
|
|
257
|
+
// `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
|
|
258
|
+
// something." row — it is an inline input, not a checkable option).
|
|
259
|
+
if (data === SPACE_KEY) {
|
|
260
|
+
if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
|
|
261
|
+
if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
|
|
262
|
+
return { kind: "toggle", index: state.optionIndex };
|
|
263
|
+
}
|
|
264
|
+
if (isConfirm(kb, data)) {
|
|
265
|
+
// Enter on the "Type something." row is handled by the inputMode block above
|
|
266
|
+
// (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
|
|
267
|
+
// path for an inputMode-activating row.
|
|
268
|
+
if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
|
|
269
|
+
// Enter on a regular row toggles (matching Space) — committing the question is now
|
|
270
|
+
// gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
|
|
271
|
+
// (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
|
|
272
|
+
// without leaving the keyboard home row.
|
|
273
|
+
if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
|
|
274
|
+
// Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
|
|
275
|
+
// multi-question mode, OR submit the dialog in single-question mode
|
|
276
|
+
// (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
|
|
277
|
+
// question would have no way to commit at all.
|
|
278
|
+
return {
|
|
279
|
+
kind: "multi_confirm",
|
|
280
|
+
selected: buildMultiSelected(state, runtime),
|
|
281
|
+
autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
|
|
285
|
+
return { kind: "ignore" };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function routeSingleSelectTab(
|
|
289
|
+
kb: QuestionnaireKeybindings,
|
|
290
|
+
data: string,
|
|
291
|
+
state: QuestionnaireState,
|
|
292
|
+
runtime: QuestionnaireRuntime,
|
|
293
|
+
): QuestionnaireAction {
|
|
294
|
+
if (isConfirm(kb, data)) {
|
|
295
|
+
const answer = buildSingleSelectAnswer(state, runtime);
|
|
296
|
+
if (!answer) return { kind: "ignore" };
|
|
297
|
+
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
298
|
+
}
|
|
299
|
+
if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
|
|
300
|
+
return { kind: "ignore" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function routeKey(
|
|
304
|
+
data: string,
|
|
305
|
+
state: QuestionnaireState,
|
|
306
|
+
runtime: QuestionnaireRuntime,
|
|
307
|
+
): QuestionnaireAction {
|
|
308
|
+
const kb = runtime.keybindings;
|
|
309
|
+
|
|
310
|
+
// Collapse/expand toggle is a UI-level affordance — intercepted at the top so it
|
|
311
|
+
// works from every inner state (notes, inputMode, submit tab, multi-select)
|
|
312
|
+
// without reaching any branch that would otherwise consume the keystroke. The
|
|
313
|
+
// questionnaire overlay is fully hidden via `OverlayHandle.setHidden(true)` while
|
|
314
|
+
// collapsed; pi-tui's overlay stack updates accordingly, so overlay-aware consumers
|
|
315
|
+
// (e.g. `pi-station`) see no visible modal and chat scroll resumes. The toggle key is
|
|
316
|
+
// also captured at the raw terminal level via `ctx.ui.onTerminalInput` so it still
|
|
317
|
+
// routes here when the overlay is hidden (pi-tui does not deliver input to a hidden
|
|
318
|
+
// overlay's `component.handleInput`).
|
|
319
|
+
//
|
|
320
|
+
// The default `ctrl+]` is free in every mainstream macOS terminal (Terminal.app,
|
|
321
|
+
// iTerm2, Warp), every multiplexer (tmux, zellij, screen — none use it as a prefix),
|
|
322
|
+
// and the legacy telnet/ssh escape role doesn't apply because our overlay runs
|
|
323
|
+
// in-process. It is, however, awkward on keyboard layouts where `]` is on the
|
|
324
|
+
// shifted layer (Latin American `es-AR`/`es-MX` require `Ctrl+Shift+}` for `Ctrl+]`)
|
|
325
|
+
// — use the `collapseKey` config field to override.
|
|
326
|
+
// Treat a missing/non-string key as disabled. This can occur at runtime when
|
|
327
|
+
// a long-lived Pi process retains an older outer module while a package update
|
|
328
|
+
// replaces the lazily imported QuestionnaireSession graph on disk. Passing
|
|
329
|
+
// undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
|
|
330
|
+
// entire host process, so keep the runtime boundary defensive even though the
|
|
331
|
+
// 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.
|
|
334
|
+
if (
|
|
335
|
+
typeof runtime.collapseKey === "string" &&
|
|
336
|
+
runtime.collapseKey !== "off" &&
|
|
337
|
+
matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
|
|
338
|
+
) {
|
|
339
|
+
return { kind: "toggle_collapsed" };
|
|
340
|
+
}
|
|
341
|
+
|
|
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);
|
|
345
|
+
if (runtime.isMulti && state.currentTab === runtime.questions.length) {
|
|
346
|
+
return routeSubmitTab(kb, data, state, runtime);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const tab = tabSwitchAction(data, state, runtime);
|
|
350
|
+
if (tab) return tab;
|
|
351
|
+
|
|
352
|
+
const q = runtime.questions[state.currentTab];
|
|
353
|
+
if (!q) return { kind: "ignore" };
|
|
354
|
+
|
|
355
|
+
// Universal `n` activation (FR-1): the notes editor opens on every question tab
|
|
356
|
+
// (single- or multi-select, preview or no-preview). The blocks above already
|
|
357
|
+
// swallow `n` when it should NOT reach here — notesVisible forwards to the
|
|
358
|
+
// notes Input, inputMode forwards to the inline Input, the submit-tab block
|
|
359
|
+
// activates it via its own branch (the global note, ahead of its ignore
|
|
360
|
+
// fall-through), and tabSwitchAction ignores it — so by the time we reach this
|
|
361
|
+
// gate, `n` is unambiguously a notes-enter request. Row intent (Next sentinel,
|
|
362
|
+
// "Type something.") is irrelevant: the gate sits ABOVE the multi-select
|
|
363
|
+
// toggle block and the Next sentinel never activates inputMode, so `n` is
|
|
364
|
+
// neither swallowed earlier nor blocked by `blocksMultiToggle`.
|
|
365
|
+
if (data === NOTES_ACTIVATE_KEY) {
|
|
366
|
+
return { kind: "notes_enter" };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (kb.matches(data, KEYBIND_UP)) {
|
|
370
|
+
return prevNavOnUp(state, runtime);
|
|
371
|
+
}
|
|
372
|
+
if (kb.matches(data, KEYBIND_DOWN)) {
|
|
373
|
+
return nextNavOnDown(state, runtime);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (q.multiSelect) return routeMultiSelectTab(kb, data, state, runtime);
|
|
377
|
+
return routeSingleSelectTab(kb, data, state, runtime);
|
|
378
|
+
}
|