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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +55 -0
  3. package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
  4. package/package.json +48 -0
  5. package/src/ask-user-question.ts +474 -0
  6. package/src/config.ts +250 -0
  7. package/src/events.ts +107 -0
  8. package/src/index.ts +25 -0
  9. package/src/reconcile.ts +31 -0
  10. package/src/rpc-fallback.ts +198 -0
  11. package/src/state/build-questionnaire.ts +346 -0
  12. package/src/state/external-editor.ts +94 -0
  13. package/src/state/key-router.ts +378 -0
  14. package/src/state/questionnaire-session.ts +382 -0
  15. package/src/state/row-intent.ts +156 -0
  16. package/src/state/selectors/contract.ts +40 -0
  17. package/src/state/selectors/derivations.ts +40 -0
  18. package/src/state/selectors/focus.ts +17 -0
  19. package/src/state/selectors/projections.ts +111 -0
  20. package/src/state/state-reducer.ts +421 -0
  21. package/src/state/state.ts +110 -0
  22. package/src/tool/format-answer.ts +28 -0
  23. package/src/tool/response-envelope.ts +123 -0
  24. package/src/tool/types.ts +193 -0
  25. package/src/tool/validate-questionnaire.ts +74 -0
  26. package/src/view/component-binding.ts +51 -0
  27. package/src/view/components/inline-input.ts +66 -0
  28. package/src/view/components/multi-select-view.ts +208 -0
  29. package/src/view/components/option-list-view.ts +77 -0
  30. package/src/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/src/view/components/preview/preview-block-renderer.ts +116 -0
  32. package/src/view/components/preview/preview-box-renderer.ts +88 -0
  33. package/src/view/components/preview/preview-layout-decider.ts +219 -0
  34. package/src/view/components/preview/preview-pane.ts +240 -0
  35. package/src/view/components/submit-picker.ts +66 -0
  36. package/src/view/components/tab-bar.ts +70 -0
  37. package/src/view/components/wrapping-select.ts +313 -0
  38. package/src/view/dialog-builder.ts +325 -0
  39. package/src/view/props-adapter.ts +124 -0
  40. package/src/view/stateful-view.ts +20 -0
  41. package/src/view/tab-components.ts +16 -0
  42. package/src/view/tab-content-strategy.ts +447 -0
@@ -0,0 +1,382 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Editor, OverlayHandle, TUI } from "@earendil-works/pi-tui";
3
+ import { COLLAPSE_KEY_OFF, formatKeySpecForDisplay } from "../config.js";
4
+ import type { QuestionData, QuestionnaireResult, QuestionParams } from "../tool/types.js";
5
+ import {
6
+ COLLAPSED_HINT_TEMPLATE,
7
+ HINT_PART_CANCEL,
8
+ KEY_PLACEHOLDER,
9
+ } from "../view/dialog-builder.js";
10
+ import type { QuestionnairePropsAdapter } from "../view/props-adapter.js";
11
+ import { buildQuestionnaire, type QuestionnaireBuilt } from "./build-questionnaire.js";
12
+ import { type QuestionnaireAction, routeKey } from "./key-router.js";
13
+ import type { WrappingSelectItem } from "./row-intent.js";
14
+ import { type ApplyContext, type Effect, reduce } from "./state-reducer.js";
15
+ import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
16
+
17
+ export interface QuestionnaireSessionConfig {
18
+ tui: TUI;
19
+ theme: Theme;
20
+ params: QuestionParams;
21
+ itemsByTab: WrappingSelectItem[][];
22
+ done: (result: QuestionnaireResult) => void;
23
+ keybindings: QuestionnaireRuntime["keybindings"];
24
+ /** Opens Pi's configured external editor. Resolves undefined when the launch failed. */
25
+ editInput: (value: string) => Promise<string | undefined>;
26
+ /** Resolved collapse key, e.g. `"ctrl+]"`, `"alt+o"` or `"off"`. */
27
+ collapseKey: string;
28
+ /**
29
+ * Whether a raw terminal-input listener is registered, which is the only
30
+ * thing that can reach a hidden overlay.
31
+ *
32
+ * This gates hiding, and it has to. Pi routes no input to a hidden overlay,
33
+ * so on a host that hands out an `OverlayHandle` but no raw terminal input,
34
+ * hiding would put the dialog somewhere nothing can bring it back from. That
35
+ * host gets the visible one-line collapsed row instead, which keeps focus and
36
+ * keeps receiving keys.
37
+ */
38
+ canReopenWhileHidden: boolean;
39
+ }
40
+
41
+ export interface QuestionnaireSessionComponent {
42
+ render(width: number): string[];
43
+ invalidate(): void;
44
+ handleInput(data: string): void;
45
+ }
46
+
47
+ function initialState(timeout?: number): QuestionnaireState {
48
+ if (timeout !== undefined) {
49
+ const now = Date.now();
50
+ return {
51
+ currentTab: 0,
52
+ optionIndex: 0,
53
+ inputMode: false,
54
+ notesVisible: false,
55
+ answers: new Map(),
56
+ multiSelectChecked: new Set(),
57
+ customDraftsByTab: new Map(),
58
+ notesByTab: new Map(),
59
+ submitChoiceIndex: 0,
60
+ notesDraft: "",
61
+ collapsed: false,
62
+ timerCancelled: false,
63
+ deadline: now + timeout,
64
+ remainingMs: timeout,
65
+ };
66
+ }
67
+ return {
68
+ currentTab: 0,
69
+ optionIndex: 0,
70
+ inputMode: false,
71
+ notesVisible: false,
72
+ answers: new Map(),
73
+ multiSelectChecked: new Set(),
74
+ customDraftsByTab: new Map(),
75
+ notesByTab: new Map(),
76
+ submitChoiceIndex: 0,
77
+ notesDraft: "",
78
+ collapsed: false,
79
+ timerCancelled: false,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The runtime, and the only impure thing in the design.
85
+ *
86
+ * It owns the canonical state cell, the two headless editors, and the effect
87
+ * runner. Everything that decides what should happen is elsewhere and pure:
88
+ * `routeKey` turns a keystroke into an action, `reduce` turns an action into a
89
+ * new state plus a list of effects. This class does what those say, in order,
90
+ * and then asks the adapter to re-project.
91
+ */
92
+ export class QuestionnaireSession {
93
+ private state: QuestionnaireState;
94
+ private timer: ReturnType<typeof setInterval> | undefined;
95
+
96
+ private readonly questions: readonly QuestionData[];
97
+ private readonly isMulti: boolean;
98
+ private readonly itemsByTab: WrappingSelectItem[][];
99
+
100
+ private readonly notesInput: Editor;
101
+ private readonly inlineInput: Editor;
102
+ private readonly viewAdapter: QuestionnairePropsAdapter;
103
+ private readonly keybindings: QuestionnaireRuntime["keybindings"];
104
+ private readonly editInput: QuestionnaireSessionConfig["editInput"];
105
+ private readonly collapseKey: string;
106
+ private readonly canReopenWhileHidden: boolean;
107
+ private inputEditorOpen = false;
108
+
109
+ /**
110
+ * Arrives from `ctx.ui.custom`'s `onHandle` callback, just after the overlay
111
+ * exists. Lets the session tell Pi's overlay stack that the dialog is hidden,
112
+ * so overlay-aware extensions can behave normally while it is collapsed.
113
+ */
114
+ private overlayHandle: OverlayHandle | undefined;
115
+
116
+ private readonly tui: QuestionnaireSessionConfig["tui"];
117
+ private readonly done: QuestionnaireSessionConfig["done"];
118
+ readonly component: QuestionnaireSessionComponent;
119
+
120
+ constructor(config: QuestionnaireSessionConfig) {
121
+ this.state = initialState(config.params.timeout);
122
+ this.tui = config.tui;
123
+ // Wrap done so the interval is always cleared, even when the reducer's
124
+ // expiry path fires `done` directly.
125
+ const outerDone = config.done;
126
+ this.done = (result) => {
127
+ this.clearTimer();
128
+ outerDone(result);
129
+ };
130
+ this.questions = config.params.questions;
131
+ this.isMulti = this.questions.length > 1;
132
+ this.itemsByTab = config.itemsByTab;
133
+ this.keybindings = config.keybindings;
134
+ this.editInput = config.editInput;
135
+ this.collapseKey = config.collapseKey;
136
+ this.canReopenWhileHidden = config.canReopenWhileHidden;
137
+
138
+ const built = buildQuestionnaire({
139
+ tui: this.tui,
140
+ theme: config.theme,
141
+ questions: this.questions,
142
+ itemsByTab: this.itemsByTab,
143
+ isMulti: this.isMulti,
144
+ initialState: this.state,
145
+ getCurrentTab: () => this.state.currentTab,
146
+ collapseKey: this.collapseKey,
147
+ });
148
+
149
+ this.notesInput = built.notesInput;
150
+ this.inlineInput = built.inlineInput;
151
+ this.viewAdapter = built.adapter;
152
+
153
+ this.component = this.assembleComponent(built, config.theme);
154
+ this.viewAdapter.apply(this.state);
155
+ if (this.state.deadline !== undefined) this.startTimer();
156
+ }
157
+
158
+ private assembleComponent(
159
+ built: QuestionnaireBuilt,
160
+ theme: Theme,
161
+ ): QuestionnaireSessionComponent {
162
+ const collapsedRender = this.buildCollapsedRender(theme);
163
+ return {
164
+ render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
165
+ invalidate: built.invalidate,
166
+ handleInput: (data) => this.dispatch(data),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * The collapsed rendering: one dim row.
172
+ *
173
+ * Pi sizes an overlay to the number of lines it returns, so returning one
174
+ * line shrinks a full-height bottom-anchored dialog to a single row and makes
175
+ * the transcript behind it readable. The overlay stays focused and in the
176
+ * stack, so the collapse key still arrives here to expand it again.
177
+ *
178
+ * With the shortcut off the router never toggles this, but
179
+ * `toggleCollapsedExternal` is a public entry that is not gated, so the line
180
+ * falls back to cancel-only rather than telling the user to press "Off".
181
+ */
182
+ private startTimer(): void {
183
+ if (this.timer !== undefined) return;
184
+ this.timer = setInterval(() => {
185
+ this.commit({ kind: "tick", now: Date.now() });
186
+ }, 1000);
187
+ // Don't keep the process alive after Pi exits.
188
+ // SAFETY: Node's Timeout has unref, DOM/Bun number does not; guard ensures we only call when present.
189
+ if (typeof (this.timer as unknown as { unref?: () => void }).unref === "function") {
190
+ // SAFETY: same guard as above — only called when unref is a function.
191
+ (this.timer as unknown as { unref: () => void }).unref();
192
+ }
193
+ }
194
+
195
+ private clearTimer(): void {
196
+ if (this.timer !== undefined) {
197
+ clearInterval(this.timer);
198
+ this.timer = undefined;
199
+ }
200
+ }
201
+
202
+ private formatRemaining(): string | undefined {
203
+ if (this.state.timerCancelled) return undefined;
204
+ const ms = this.state.remainingMs;
205
+ if (ms === undefined) return undefined;
206
+ const secs = Math.max(0, Math.ceil(ms / 1000));
207
+ return `${secs}s`;
208
+ }
209
+
210
+ private buildCollapsedRender(theme: Theme): (width: number) => string[] {
211
+ const collapseKeyDisplay = formatKeySpecForDisplay(this.collapseKey);
212
+ const baseHint =
213
+ this.collapseKey === COLLAPSE_KEY_OFF
214
+ ? HINT_PART_CANCEL
215
+ : COLLAPSED_HINT_TEMPLATE.replace(KEY_PLACEHOLDER, collapseKeyDisplay);
216
+ return (_width: number): string[] => {
217
+ const rem = this.formatRemaining();
218
+ const hint = rem ? `${baseHint} \u00b7 ${rem} left` : baseHint;
219
+ return [theme.fg("dim", ` ${hint} `)];
220
+ };
221
+ }
222
+
223
+ dispatch(data: string): void {
224
+ if (this.inputEditorOpen) return;
225
+ const action = routeKey(data, this.state, this.runtime());
226
+ if (action.kind === "ignore") {
227
+ this.handleIgnoreInline(data);
228
+ return;
229
+ }
230
+ this.commit(action);
231
+ }
232
+
233
+ private commit(action: QuestionnaireAction): void {
234
+ const result = reduce(this.state, action, this.applyContext());
235
+ this.state = result.state;
236
+ for (const effect of result.effects) this.runEffect(effect);
237
+ this.state = this.mirrorNotesDraft(this.state);
238
+ this.viewAdapter.apply(this.state);
239
+ }
240
+
241
+ /**
242
+ * Keep the stored notes draft in step with the editor.
243
+ *
244
+ * Read expanded rather than plain: restoring a draft goes through
245
+ * `Editor.setText`, which clears the editor's paste map, so a draft stored in
246
+ * its collapsed form would come back with a paste marker pointing at nothing.
247
+ */
248
+ private mirrorNotesDraft(s: QuestionnaireState): QuestionnaireState {
249
+ const draft = this.notesInput.getExpandedText?.() ?? this.notesInput.getText();
250
+ return s.notesDraft === draft ? s : { ...s, notesDraft: draft };
251
+ }
252
+
253
+ private runEffect(effect: Effect): void {
254
+ switch (effect.kind) {
255
+ case "set_input_buffer":
256
+ this.inlineInput.setText(effect.value);
257
+ return;
258
+ case "clear_input_buffer":
259
+ this.inlineInput.setText("");
260
+ return;
261
+ case "open_input_editor":
262
+ this.openInputEditorAsync(effect.value);
263
+ return;
264
+ case "set_notes_value":
265
+ this.notesInput.setText(effect.value);
266
+ return;
267
+ case "set_notes_focused":
268
+ this.notesInput.focused = effect.focused;
269
+ return;
270
+ case "forward_notes_keystroke":
271
+ this.notesInput.handleInput(effect.data);
272
+ return;
273
+ case "set_overlay_hidden":
274
+ // A no-op until the handle arrives, and suppressed entirely without a
275
+ // raw terminal listener: see `canReopenWhileHidden`. The state still
276
+ // says collapsed either way, so the view renders the one-line row.
277
+ if (this.canReopenWhileHidden) this.overlayHandle?.setHidden(effect.hidden);
278
+ return;
279
+ case "clear_timer":
280
+ this.clearTimer();
281
+ return;
282
+ case "done":
283
+ this.done(effect.result);
284
+ return;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Hand the draft to the external editor and take back whatever comes out.
290
+ *
291
+ * Dispatch is suspended for the duration: the terminal belongs to the editor,
292
+ * and keystrokes meant for it must not also be routed into the dialog. A
293
+ * reported launch failure keeps the draft rather than replacing it with
294
+ * nothing.
295
+ */
296
+ private openInputEditorAsync(value: string): void {
297
+ if (this.inputEditorOpen) return;
298
+ this.inputEditorOpen = true;
299
+ void this.editInput(value).then(
300
+ (edited) => {
301
+ this.inputEditorOpen = false;
302
+ if (edited !== undefined) this.commit({ kind: "input_replace", value: edited });
303
+ },
304
+ () => {
305
+ this.inputEditorOpen = false;
306
+ },
307
+ );
308
+ }
309
+
310
+ /**
311
+ * The per-keystroke fast path for text the router does not claim.
312
+ *
313
+ * Editing is delegated wholesale to Pi's headless editor, which is what gives
314
+ * the custom-answer row paste, undo, cursor movement and the user's own
315
+ * newline keybinding for free. The adapter then reads its public text and
316
+ * cursor, so none of this needs a trip through the reducer.
317
+ */
318
+ private handleIgnoreInline(data: string): void {
319
+ // Any ignored keystroke counts as human-present, so cancel the timer even
320
+ // when not in inputMode. The normal `reduce` wrapper cannot see this path
321
+ // because `dispatch` bypasses it for `ignore`.
322
+ let timerWasCancelled = false;
323
+ if (this.state.deadline !== undefined && !this.state.timerCancelled) {
324
+ this.state = { ...this.state, timerCancelled: true, remainingMs: undefined };
325
+ this.clearTimer();
326
+ timerWasCancelled = true;
327
+ }
328
+ if (!this.state.inputMode) {
329
+ if (timerWasCancelled) this.viewAdapter.apply(this.state);
330
+ return;
331
+ }
332
+ this.inlineInput.handleInput(data);
333
+ this.viewAdapter.apply(this.state);
334
+ }
335
+
336
+ private runtime(): QuestionnaireRuntime {
337
+ const cursor = this.inlineInput.getCursor();
338
+ const lastLine = this.inlineInput.getLines().length - 1;
339
+ return {
340
+ keybindings: this.keybindings,
341
+ inputBuffer: this.inlineInput.getExpandedText?.() ?? this.inlineInput.getText(),
342
+ canMoveInputUp: cursor.line > 0,
343
+ canMoveInputDown: cursor.line < lastLine,
344
+ questions: this.questions,
345
+ isMulti: this.isMulti,
346
+ currentItem: this.currentItem(),
347
+ items: this.itemsByTab[this.state.currentTab] ?? [],
348
+ collapseKey: this.collapseKey,
349
+ };
350
+ }
351
+
352
+ private applyContext(): ApplyContext {
353
+ return { questions: this.questions, itemsByTab: this.itemsByTab };
354
+ }
355
+
356
+ private currentItem(): WrappingSelectItem | undefined {
357
+ const items = this.itemsByTab[this.state.currentTab] ?? [];
358
+ return this.state.optionIndex < items.length ? items[this.state.optionIndex] : undefined;
359
+ }
360
+
361
+ /**
362
+ * Called by `ctx.ui.custom`'s `onHandle` once the overlay exists. Before this,
363
+ * hide effects do nothing and the session simply tracks the collapsed flag.
364
+ */
365
+ setOverlayHandle(handle: OverlayHandle): void {
366
+ this.overlayHandle = handle;
367
+ }
368
+
369
+ /**
370
+ * The way in for the raw terminal listener.
371
+ *
372
+ * Pi does not route input to a hidden overlay's `handleInput`, so once the
373
+ * dialog is hidden the normal dispatch path can never see the key that would
374
+ * bring it back. The raw listener fires regardless of overlay visibility and
375
+ * comes in here instead. It still goes through `commit`, so the transition
376
+ * stays in the reducer and hiding still happens as an effect like everything
377
+ * else.
378
+ */
379
+ toggleCollapsedExternal(): void {
380
+ if (!this.inputEditorOpen) this.commit({ kind: "toggle_collapsed" });
381
+ }
382
+ }
@@ -0,0 +1,156 @@
1
+ import type { QuestionData } from "../tool/types.js";
2
+
3
+ /**
4
+ * Row kind discriminator, and the single source of truth for it.
5
+ *
6
+ * Upstream derived this from the view layer's `WrappingSelectItem` union. The
7
+ * direction is inverted here: intent is a property of the protocol, not of the
8
+ * renderer, so the union lives at the bottom of the dependency graph and the
9
+ * view builds its item type from it. The compile-time forcing gets stronger
10
+ * rather than weaker. Adding a kind here breaks `ROW_INTENT_META` (a
11
+ * `Record<RowKind, ...>`) AND every exhaustive switch in the renderer, so a new
12
+ * row cannot ship half-wired.
13
+ */
14
+ export type RowKind = "option" | "other" | "next";
15
+
16
+ /**
17
+ * Sentinel kinds: the protocol-driven rows, as opposed to author-defined
18
+ * `option` rows. The auto-append walker, the reserved-label derivation and
19
+ * `LABELS_BY_KIND` all iterate this list.
20
+ */
21
+ export type SentinelKind = Exclude<RowKind, "option">;
22
+ export const SENTINEL_KINDS: readonly SentinelKind[] = ["other", "next"];
23
+
24
+ /**
25
+ * One renderable row. Lives here rather than in the view because it is the
26
+ * protocol shape a row carries, and both the reducer and the key router read it
27
+ * without knowing anything about rendering. The option renderer re-exports this
28
+ * name so view-layer code reads unchanged.
29
+ *
30
+ * `kind` narrows exactly as a hand-written union would: `item.kind === "other"`
31
+ * still discriminates, and a `switch` over it is still exhaustiveness-checked.
32
+ */
33
+ export interface WrappingSelectItem {
34
+ kind: RowKind;
35
+ label: string;
36
+ description?: string;
37
+ }
38
+
39
+ /**
40
+ * Per-kind static metadata. Pure data. No closures, no per-kind handlers.
41
+ * The behavior-bearing code (answer construction in the key router, the Next
42
+ * row branch in the multi-select view, the inline editor branch in the option
43
+ * renderer) keeps its own exhaustive switches and READS these flags.
44
+ *
45
+ * Adding a sentinel:
46
+ * 1. Add the variant to `RowKind`.
47
+ * 2. Add an entry here. Compilation fails until both edits exist.
48
+ * 3. If user-facing, synthesize the row wherever it belongs, typically in
49
+ * the per-question item builder.
50
+ *
51
+ * Field semantics:
52
+ * - `label` — user-facing text. Empty for `option`, whose label is per-instance
53
+ * and comes from `QuestionData.options[i].label`. Every sentinel treats its
54
+ * entry here as the single source of truth.
55
+ * - `reserved` — an authored option carrying this label is rejected at
56
+ * validation time. `RESERVED_LABEL_SET` derives from this flag.
57
+ * - `livesInMainList` — the row appears in the tab's item array.
58
+ * - `numbered` — the row contributes to main-list numbering. The multi-select
59
+ * `Next` row is the only listed row that does not.
60
+ * - `activatesInputMode` — focusing the row flips `state.inputMode`, turning it
61
+ * into an inline editor. Read by the reducer's `nav` case.
62
+ * - `blocksMultiToggle` — in multi-select, Space and Enter-as-toggle are
63
+ * suppressed on this row. `Next` only.
64
+ * - `autoSubmitsInMulti` — in multi-select, Enter on this row commits the
65
+ * question. `Next` only.
66
+ * - `autoAppendOnSingleSelect` / `autoAppendOnMultiSelect` — whether the item
67
+ * builder appends this row in that mode.
68
+ */
69
+ export interface RowIntentMeta {
70
+ label: string;
71
+ reserved: boolean;
72
+ livesInMainList: boolean;
73
+ numbered: boolean;
74
+ activatesInputMode: boolean;
75
+ blocksMultiToggle: boolean;
76
+ autoSubmitsInMulti: boolean;
77
+ autoAppendOnSingleSelect: boolean;
78
+ autoAppendOnMultiSelect: boolean;
79
+ }
80
+
81
+ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
82
+ option: {
83
+ label: "",
84
+ reserved: false,
85
+ livesInMainList: true,
86
+ numbered: true,
87
+ activatesInputMode: false,
88
+ blocksMultiToggle: false,
89
+ autoSubmitsInMulti: false,
90
+ autoAppendOnSingleSelect: false,
91
+ autoAppendOnMultiSelect: false,
92
+ },
93
+ other: {
94
+ label: "Type something.",
95
+ reserved: true,
96
+ livesInMainList: true,
97
+ numbered: true,
98
+ activatesInputMode: true,
99
+ blocksMultiToggle: false,
100
+ autoSubmitsInMulti: false,
101
+ autoAppendOnSingleSelect: true,
102
+ autoAppendOnMultiSelect: true,
103
+ },
104
+ next: {
105
+ label: "Next",
106
+ reserved: true,
107
+ livesInMainList: true,
108
+ numbered: false,
109
+ activatesInputMode: false,
110
+ blocksMultiToggle: true,
111
+ autoSubmitsInMulti: true,
112
+ autoAppendOnSingleSelect: false,
113
+ autoAppendOnMultiSelect: true,
114
+ },
115
+ };
116
+
117
+ /**
118
+ * Kind-keyed label view. `option` is excluded because its label is
119
+ * per-instance rather than per-kind.
120
+ */
121
+ export const LABELS_BY_KIND: { readonly [K in SentinelKind]: string } = {
122
+ other: ROW_INTENT_META.other.label,
123
+ next: ROW_INTENT_META.next.label,
124
+ };
125
+
126
+ /**
127
+ * Reserved-label set for runtime validation. Every sentinel marked `reserved`,
128
+ * plus `"Other"`, which has no runtime row kind at all. `"Other"` is reserved
129
+ * because models are conditioned to author it as an escape-hatch option; the
130
+ * runtime sentinel must stay the only way to reach free text.
131
+ */
132
+ export const RESERVED_LABEL_SET: ReadonlySet<string> = new Set<string>([
133
+ "Other",
134
+ ...SENTINEL_KINDS.filter((k) => ROW_INTENT_META[k].reserved).map((k) => ROW_INTENT_META[k].label),
135
+ ]);
136
+
137
+ /**
138
+ * Walk the metadata table to decide which sentinel rows a question gets.
139
+ * The two append predicates are mutually exclusive in practice (multi-select
140
+ * versus single-select) but the walker does not enforce that, so adding a third
141
+ * bucket needs only a new flag.
142
+ *
143
+ * Returns kinds in `SENTINEL_KINDS` order. The caller wraps each into a
144
+ * renderable row.
145
+ */
146
+ export function sentinelsToAppend(question: QuestionData): SentinelKind[] {
147
+ const out: SentinelKind[] = [];
148
+ for (const kind of SENTINEL_KINDS) {
149
+ const meta = ROW_INTENT_META[kind];
150
+ if (!meta.livesInMainList) continue;
151
+ const appends =
152
+ question.multiSelect === true ? meta.autoAppendOnMultiSelect : meta.autoAppendOnSingleSelect;
153
+ if (appends) out.push(kind);
154
+ }
155
+ return out;
156
+ }
@@ -0,0 +1,40 @@
1
+ import type { PreviewPaneProps } from "../../view/components/preview/preview-pane.js";
2
+ import type { StatefulView } from "../../view/stateful-view.js";
3
+ import type { TabComponents } from "../../view/tab-components.js";
4
+ import type { QuestionData } from "../../tool/types.js";
5
+ import type { WrappingSelectItem } from "../row-intent.js";
6
+ import type { ActiveView, QuestionnaireState } from "../state.js";
7
+
8
+ /**
9
+ * Everything a selector may read besides canonical state. Per-tick and
10
+ * read-only: the adapter builds one of these per `apply()` and hands the same
11
+ * object to every binding.
12
+ *
13
+ * `activeView` and `WrappingSelectItem` come from the state layer, not from the
14
+ * view. Which surface owns the keyboard, and what a row means, are canonical
15
+ * facts; the components only render the answer.
16
+ */
17
+ export interface BindingContext {
18
+ readonly questions: readonly QuestionData[];
19
+ readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
20
+ readonly totalQuestions: number;
21
+ readonly activeView: ActiveView;
22
+ readonly inputBuffer: string;
23
+ /**
24
+ * Caret position in `inputBuffer`, always known: the adapter computes it from
25
+ * the editor every tick. The two row views that consume it disagree on
26
+ * whether their own prop is optional, so pinning it down here is what keeps
27
+ * one selector from having to satisfy both shapes.
28
+ */
29
+ readonly inputCursorOffset: number;
30
+ readonly activePreviewPane: StatefulView<PreviewPaneProps>;
31
+ }
32
+
33
+ /** `BindingContext` plus the tab a per-tab binding is currently visiting. */
34
+ export interface PerTabBindingContext extends BindingContext {
35
+ readonly tab: TabComponents;
36
+ readonly i: number;
37
+ }
38
+
39
+ export type GlobalSelector<P> = (state: QuestionnaireState, ctx: BindingContext) => P;
40
+ export type PerTabSelector<P> = (state: QuestionnaireState, ctx: PerTabBindingContext) => P;
@@ -0,0 +1,40 @@
1
+ import type { QuestionAnswer, QuestionData } from "../../tool/types.js";
2
+ import type { WrappingSelectItem } from "../row-intent.js";
3
+
4
+ /**
5
+ * Which row in the active tab should be marked as "previously confirmed"? Drives the
6
+ * `WrappingSelect` confirmed-row indicator (label + ` ✔`) when the user navigates back
7
+ * to a question they already answered. Returns `undefined` when no marker should be drawn —
8
+ * multi-select handles its own `[✔]` boxes via `multiSelectChecked`, and a missing/non-matching
9
+ * answer (defensive) silently skips the marker.
10
+ */
11
+ export function selectConfirmedIndicator(
12
+ questions: readonly QuestionData[],
13
+ currentTab: number,
14
+ answers: ReadonlyMap<number, QuestionAnswer>,
15
+ items: readonly WrappingSelectItem[],
16
+ ): { index: number; labelOverride?: string } | undefined {
17
+ const q = questions[currentTab];
18
+ if (!q || q.multiSelect === true) return undefined;
19
+ const prior = answers.get(currentTab);
20
+ if (!prior) return undefined;
21
+ if (prior.kind === "custom") {
22
+ const otherIndex = items.findIndex((it) => it.kind === "other");
23
+ if (otherIndex < 0) return undefined;
24
+ return { index: otherIndex, labelOverride: prior.answer ?? "" };
25
+ }
26
+ if (prior.kind !== "option" || typeof prior.answer !== "string") return undefined;
27
+ const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
28
+ if (index < 0) return undefined;
29
+ return { index };
30
+ }
31
+
32
+ /**
33
+ * Index of the preview pane to display for the current tab. The Submit tab (currentTab ===
34
+ * questions.length) reuses the last question's pane purely for layout — the strategy
35
+ * machinery picks the right body component independently. Defensive against `totalQuestions === 0`.
36
+ */
37
+ export function selectActivePreviewPaneIndex(currentTab: number, totalQuestions: number): number {
38
+ if (totalQuestions <= 0) return 0;
39
+ return Math.min(currentTab, totalQuestions - 1);
40
+ }
@@ -0,0 +1,17 @@
1
+ import type { ActiveView } from "../state.js";
2
+
3
+ /**
4
+ * Discriminated focus selector — single source of truth for "which view owns
5
+ * focus this tick?" Priority order matches the dispatcher cascade
6
+ * (`key-router.ts`) and the reducer's defensive clears (`state-reducer.ts`).
7
+ *
8
+ * Priority: notes > submit > options.
9
+ */
10
+ export function selectActiveView(
11
+ state: { notesVisible: boolean; currentTab: number },
12
+ totalQuestions: number,
13
+ ): ActiveView {
14
+ if (state.notesVisible) return "notes";
15
+ if (state.currentTab === totalQuestions) return "submit";
16
+ return "options";
17
+ }