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
|
@@ -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)
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
245
|
+
if (this.state.timerCancelled) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
204
248
|
const ms = this.state.remainingMs;
|
|
205
|
-
if (ms === 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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
443
|
+
if (!this.inputEditorOpen) {
|
|
444
|
+
this.commit({ kind: "toggle_collapsed" });
|
|
445
|
+
}
|
|
381
446
|
}
|
|
382
447
|
}
|
package/src/state/row-intent.ts
CHANGED
|
@@ -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)
|
|
150
|
+
if (!meta.livesInMainList) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
151
153
|
const appends =
|
|
152
154
|
question.multiSelect === true ? meta.autoAppendOnMultiSelect : meta.autoAppendOnSingleSelect;
|
|
153
|
-
if (appends)
|
|
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)
|
|
18
|
+
if (!q || q.multiSelect === true) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
19
21
|
const prior = answers.get(currentTab);
|
|
20
|
-
if (!prior)
|
|
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)
|
|
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")
|
|
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)
|
|
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)
|
|
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)
|
|
15
|
-
|
|
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)
|
|
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)
|
|
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))
|
|
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)
|
|
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)
|
|
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))
|
|
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)
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
167
|
+
if (draft !== undefined) {
|
|
168
|
+
return draft;
|
|
169
|
+
}
|
|
131
170
|
const answer = state.answers.get(tab);
|
|
132
|
-
return answer?.kind === "custom" &&
|
|
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))
|
|
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
|
-
|
|
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)
|
|
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))
|
|
281
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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[]).
|
|
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) {
|