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.
- package/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +54 -21
- package/src/config.ts +118 -24
- package/src/rpc-fallback.ts +85 -28
- package/src/state/build-questionnaire.ts +45 -8
- package/src/state/external-editor.ts +24 -12
- package/src/state/key-router.ts +152 -41
- package/src/state/questionnaire-session.ts +79 -14
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +9 -1
- package/src/state/state-reducer.ts +110 -38
- package/src/tool/response-envelope.ts +71 -22
- package/src/tool/types.ts +28 -4
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +32 -7
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +75 -23
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +52 -15
- package/src/view/components/preview/preview-pane.ts +72 -28
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +110 -37
- package/src/view/dialog-builder.ts +44 -10
- package/src/view/props-adapter.ts +29 -7
- 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 {
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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)
|
|
74
|
+
if (command.trim().length === 0) {
|
|
75
|
+
throw new Error("External editor command is empty");
|
|
76
|
+
}
|
|
68
77
|
|
|
69
|
-
const tempDir =
|
|
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
|
-
|
|
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
|
|
90
|
+
return (await readFile(tempFile, "utf8")).replace(/\r?\n$/, "");
|
|
82
91
|
} finally {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|
package/src/state/key-router.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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))
|
|
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)
|
|
83
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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 (
|
|
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)
|
|
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))
|
|
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))
|
|
183
|
-
|
|
184
|
-
|
|
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))
|
|
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)
|
|
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))
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
if (kb.matches(data,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (kb.matches(data,
|
|
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))
|
|
300
|
+
if (kb.matches(data, KEYBIND_CANCEL)) {
|
|
301
|
+
return { kind: "cancel" };
|
|
302
|
+
}
|
|
222
303
|
const tab = tabSwitchAction(data, state, runtime);
|
|
223
|
-
if (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)
|
|
261
|
-
|
|
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)
|
|
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)
|
|
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))
|
|
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)
|
|
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))
|
|
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
|
|
333
|
-
//
|
|
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
|
-
|
|
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)
|
|
343
|
-
|
|
344
|
-
|
|
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)
|
|
455
|
+
if (tab) {
|
|
456
|
+
return tab;
|
|
457
|
+
}
|
|
351
458
|
|
|
352
459
|
const q = runtime.questions[state.currentTab];
|
|
353
|
-
if (!q)
|
|
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)
|
|
485
|
+
if (q.multiSelect) {
|
|
486
|
+
return routeMultiSelectTab(kb, data, state, runtime);
|
|
487
|
+
}
|
|
377
488
|
return routeSingleSelectTab(kb, data, state, runtime);
|
|
378
489
|
}
|