pi-cc-extensions 0.8.7 → 0.8.9

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 (44) hide show
  1. package/README.md +21 -5
  2. package/assets/readme/welcome.webp +0 -0
  3. package/extensions/ask-user-question/LICENSE +21 -0
  4. package/extensions/ask-user-question/README.md +63 -0
  5. package/extensions/ask-user-question/ask-user-question.ts +244 -0
  6. package/extensions/ask-user-question/config.ts +75 -0
  7. package/extensions/ask-user-question/events.ts +55 -0
  8. package/extensions/ask-user-question/index.ts +17 -0
  9. package/extensions/ask-user-question/reconcile.ts +47 -0
  10. package/extensions/ask-user-question/rpc-fallback.ts +160 -0
  11. package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
  12. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  13. package/extensions/ask-user-question/state/key-router.ts +269 -0
  14. package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
  15. package/extensions/ask-user-question/state/row-intent.ts +143 -0
  16. package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
  17. package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
  18. package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
  19. package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
  20. package/extensions/ask-user-question/state/state-reducer.ts +282 -0
  21. package/extensions/ask-user-question/state/state.ts +49 -0
  22. package/extensions/ask-user-question/tool/format-answer.ts +29 -0
  23. package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
  24. package/extensions/ask-user-question/tool/types.ts +145 -0
  25. package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
  26. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  27. package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
  28. package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
  29. package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
  30. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
  32. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
  33. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
  34. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
  35. package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
  36. package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
  37. package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
  38. package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
  39. package/extensions/ask-user-question/view/props-adapter.ts +123 -0
  40. package/extensions/ask-user-question/view/stateful-view.ts +24 -0
  41. package/extensions/ask-user-question/view/tab-components.ts +16 -0
  42. package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
  43. package/package.json +14 -4
  44. package/themes/github-dark-default.json +91 -0
@@ -0,0 +1,269 @@
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_CANCEL = "tui.select.cancel";
10
+
11
+ const NOTES_ACTIVATE_KEY = "n";
12
+ const SPACE_KEY = " ";
13
+
14
+ export type QuestionnaireAction =
15
+ | { kind: "nav"; nextIndex: number }
16
+ | { kind: "tab_switch"; nextTab: number }
17
+ | { kind: "confirm"; answer: QuestionAnswer; autoAdvanceTab?: number }
18
+ | { kind: "toggle"; index: number }
19
+ | { kind: "multi_confirm"; selected: string[]; autoAdvanceTab?: number }
20
+ | { kind: "cancel" }
21
+ | { kind: "notes_enter" }
22
+ | { kind: "notes_exit" }
23
+ | { kind: "submit" }
24
+ | { kind: "submit_nav"; nextIndex: 0 | 1 }
25
+ | { kind: "notes_forward"; data: string }
26
+ /** Flip `state.collapsed`. Always available, regardless of inner mode (see top intercept in `routeKey`). */
27
+ | { kind: "toggle_collapsed" }
28
+ | { kind: "ignore" };
29
+
30
+ export interface QuestionnaireKeybindings {
31
+ matches(data: string, name: string): boolean;
32
+ }
33
+
34
+ export function wrapTab(index: number, total: number): number {
35
+ if (total <= 0) return 0;
36
+ return ((index % total) + total) % total;
37
+ }
38
+
39
+ export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean {
40
+ if (runtime.questions.length === 0) return false;
41
+ for (let i = 0; i < runtime.questions.length; i++) {
42
+ if (!state.answers.has(i)) return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function totalTabs(runtime: QuestionnaireRuntime): number {
48
+ return runtime.isMulti ? runtime.questions.length + 1 : 1;
49
+ }
50
+
51
+ function computeAutoAdvanceTab(state: QuestionnaireState, runtime: QuestionnaireRuntime): number | undefined {
52
+ if (!runtime.isMulti) return undefined;
53
+ if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1;
54
+ return runtime.questions.length;
55
+ }
56
+
57
+ function buildSingleSelectAnswer(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionAnswer | null {
58
+ const q = runtime.questions[state.currentTab];
59
+ if (!q) return null;
60
+
61
+ const item = runtime.currentItem;
62
+
63
+ if (state.inputMode) {
64
+ const label = runtime.inputBuffer;
65
+ return {
66
+ questionIndex: state.currentTab,
67
+ question: q.question,
68
+ kind: "custom",
69
+ answer: label.length > 0 ? label : null,
70
+ };
71
+ }
72
+ if (!item) return null;
73
+ if (item.kind === "other") {
74
+ return null;
75
+ }
76
+ if (item.kind === "next") {
77
+ return null;
78
+ }
79
+ return {
80
+ questionIndex: state.currentTab,
81
+ question: q.question,
82
+ kind: "option",
83
+ answer: item.label,
84
+ };
85
+ }
86
+
87
+ function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
88
+ const q = runtime.questions[state.currentTab];
89
+ if (!q) return [];
90
+ const out: string[] = [];
91
+ for (let i = 0; i < q.options.length; i++) {
92
+ if (state.multiSelectChecked.has(i)) {
93
+ const label = q.options[i]?.label;
94
+ if (typeof label === "string") out.push(label);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function tabSwitchAction(
101
+ data: string,
102
+ state: QuestionnaireState,
103
+ runtime: QuestionnaireRuntime,
104
+ ): QuestionnaireAction | null {
105
+ if (!runtime.isMulti) return null;
106
+ const total = totalTabs(runtime);
107
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
108
+ return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
109
+ }
110
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
111
+ return { kind: "tab_switch", nextTab: wrapTab(state.currentTab - 1, total) };
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // DOWN at the last item wraps to the first (cycle through [option0, …, optionLast]).
117
+ function nextNavOnDown(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
118
+ return { kind: "nav", nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)) };
119
+ }
120
+
121
+ // UP at the first item wraps to the last (symmetric with nextNavOnDown).
122
+ function prevNavOnUp(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
123
+ return { kind: "nav", nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)) };
124
+ }
125
+
126
+ export function routeKey(data: string, state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
127
+ const kb = runtime.keybindings;
128
+
129
+ // Collapse/expand is intercepted before inner controls. The questionnaire stays
130
+ // focused in the temporary editor slot and shrinks to one row, so the same key
131
+ // can expand it without a raw terminal listener.
132
+ //
133
+ // The default `ctrl+]` is free in every mainstream macOS terminal (Terminal.app,
134
+ // iTerm2, Warp), every multiplexer (tmux, zellij, screen — none use it as a prefix),
135
+ // and the legacy telnet/ssh escape role doesn't apply because the questionnaire runs
136
+ // in-process. It is, however, awkward on keyboard layouts where `]` is on the
137
+ // shifted layer (Latin American `es-AR`/`es-MX` require `Ctrl+Shift+}` for `Ctrl+]`)
138
+ // — use the `collapseKey` config field to override.
139
+ // Treat a missing/non-string key as disabled. This can occur at runtime when
140
+ // a long-lived Pi process retains an older outer module while a package update
141
+ // replaces the lazily imported QuestionnaireSession graph on disk. Passing
142
+ // undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
143
+ // entire host process, so keep the runtime boundary defensive even though the
144
+ // TypeScript contract requires a string.
145
+ if (
146
+ typeof runtime.collapseKey === "string" &&
147
+ runtime.collapseKey !== "off" &&
148
+ matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
149
+ ) {
150
+ return { kind: "toggle_collapsed" };
151
+ }
152
+
153
+ // Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so
154
+ // the user can read the now-uncovered transcript without accidentally mutating
155
+ // answers or notes. The collapse toggle itself is already handled above.
156
+ if (state.collapsed) {
157
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
158
+ return { kind: "ignore" };
159
+ }
160
+
161
+ if (state.notesVisible) {
162
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" };
163
+ if (kb.matches(data, KEYBIND_CONFIRM)) return { kind: "notes_exit" };
164
+ return { kind: "notes_forward", data };
165
+ }
166
+
167
+ if (state.inputMode) {
168
+ if (kb.matches(data, KEYBIND_CONFIRM)) {
169
+ const answer = buildSingleSelectAnswer(state, runtime);
170
+ if (!answer) return { kind: "ignore" };
171
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
172
+ }
173
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
174
+ if (kb.matches(data, KEYBIND_UP)) {
175
+ return prevNavOnUp(state, runtime);
176
+ }
177
+ if (kb.matches(data, KEYBIND_DOWN)) {
178
+ return nextNavOnDown(state, runtime);
179
+ }
180
+ return { kind: "ignore" };
181
+ }
182
+
183
+ if (runtime.isMulti && state.currentTab === runtime.questions.length) {
184
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
185
+ const tab = tabSwitchAction(data, state, runtime);
186
+ if (tab) return tab;
187
+ if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
188
+ const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
189
+ const next = wrapTab(state.submitChoiceIndex + delta, 2);
190
+ return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
191
+ }
192
+ if (kb.matches(data, KEYBIND_CONFIRM)) {
193
+ // D1 (revised): Submit always submits; Cancel always cancels. The warning header
194
+ // is informational only — `allAnswered(state)` no longer gates submission. Partial
195
+ // answers flow through `orderedAnswers()` in the host.
196
+ return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
197
+ }
198
+ return { kind: "ignore" };
199
+ }
200
+
201
+ const tab = tabSwitchAction(data, state, runtime);
202
+ if (tab) return tab;
203
+
204
+ const q = runtime.questions[state.currentTab];
205
+ if (!q) return { kind: "ignore" };
206
+
207
+ // Universal `n` activation (FR-1): the notes editor opens on every question tab
208
+ // (single- or multi-select, preview or no-preview). The blocks above already
209
+ // swallow `n` when it should NOT reach here — notesVisible forwards to the
210
+ // notes Input, inputMode forwards to the inline Input, the submit-tab block
211
+ // ignores it, and tabSwitchAction ignores it — so by the time we reach this
212
+ // gate, `n` is unambiguously a notes-enter request. Row intent (Next sentinel,
213
+ // "Type something.") is irrelevant: the gate sits ABOVE the multi-select
214
+ // toggle block and the Next sentinel never activates inputMode, so `n` is
215
+ // neither swallowed earlier nor blocked by `blocksMultiToggle`.
216
+ if (data === NOTES_ACTIVATE_KEY) {
217
+ return { kind: "notes_enter" };
218
+ }
219
+
220
+ if (kb.matches(data, KEYBIND_UP)) {
221
+ return prevNavOnUp(state, runtime);
222
+ }
223
+ if (kb.matches(data, KEYBIND_DOWN)) {
224
+ return nextNavOnDown(state, runtime);
225
+ }
226
+
227
+ if (q.multiSelect) {
228
+ const focusedKind = runtime.currentItem?.kind;
229
+ const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
230
+ // Space toggles the focused row's checkbox. Suppressed on rows whose META declares
231
+ // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
232
+ // something." row — it is an inline input, not a checkable option).
233
+ if (data === SPACE_KEY) {
234
+ if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
235
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
236
+ return { kind: "toggle", index: state.optionIndex };
237
+ }
238
+ if (kb.matches(data, KEYBIND_CONFIRM)) {
239
+ // Enter on the "Type something." row is handled by the inputMode block above
240
+ // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
241
+ // path for an inputMode-activating row.
242
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
243
+ // Enter on a regular row toggles (matching Space) — committing the question is now
244
+ // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
245
+ // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
246
+ // without leaving the keyboard home row.
247
+ if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
248
+ // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
249
+ // multi-question mode, OR submit the dialog in single-question mode
250
+ // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
251
+ // question would have no way to commit at all.
252
+ return {
253
+ kind: "multi_confirm",
254
+ selected: buildMultiSelected(state, runtime),
255
+ autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
256
+ };
257
+ }
258
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
259
+ return { kind: "ignore" };
260
+ }
261
+
262
+ if (kb.matches(data, KEYBIND_CONFIRM)) {
263
+ const answer = buildSingleSelectAnswer(state, runtime);
264
+ if (!answer) return { kind: "ignore" };
265
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
266
+ }
267
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
268
+ return { kind: "ignore" };
269
+ }
@@ -0,0 +1,196 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings, type Input } from "@earendil-works/pi-tui";
3
+ import type { QuestionData, QuestionnaireResult, QuestionParams } from "../tool/types.js";
4
+ import type { WrappingSelectItem } from "../view/components/wrapping-select.js";
5
+ import { COLLAPSED_HINT } from "../view/dialog-builder.js";
6
+ import type { QuestionnairePropsAdapter } from "../view/props-adapter.js";
7
+ import { buildQuestionnaire } from "./build-questionnaire.js";
8
+ import { type QuestionnaireAction, routeKey } from "./key-router.js";
9
+ import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
10
+ import { type ApplyContext, type Effect, reduce } from "./state-reducer.js";
11
+
12
+ // Module-level constant; reused for cursor-end mutations after setValue rehydration.
13
+ // Ctrl-E → tui.editor.cursorLineEnd (public path; pi-tui keybindings.js:25-28).
14
+ const CURSOR_END = "\x05";
15
+
16
+ export interface QuestionnaireSessionConfig {
17
+ tui: { terminal: { columns: number; rows: number }; requestRender(): void };
18
+ theme: Theme;
19
+ params: QuestionParams;
20
+ itemsByTab: WrappingSelectItem[][];
21
+ done: (result: QuestionnaireResult) => void;
22
+ /** Key spec for the collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
23
+ collapseKey: string;
24
+ }
25
+
26
+ export interface QuestionnaireSessionComponent {
27
+ render(width: number): string[];
28
+ invalidate(): void;
29
+ handleInput(data: string): void;
30
+ }
31
+
32
+ function initialState(): QuestionnaireState {
33
+ return {
34
+ currentTab: 0,
35
+ optionIndex: 0,
36
+ inputMode: false,
37
+ notesVisible: false,
38
+ answers: new Map(),
39
+ multiSelectChecked: new Set(),
40
+ notesByTab: new Map(),
41
+ submitChoiceIndex: 0,
42
+ notesDraft: "",
43
+ collapsed: false,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Slim runtime: owns the canonical state cell, the input-buffer cell, the
49
+ * two-pass `notesVisible` dispatch loop, and the effect runner. State
50
+ * transitions go through the pure `reduce` reducer; UI fan-out goes through
51
+ * the `QuestionnairePropsAdapter` produced by `buildQuestionnaire`.
52
+ */
53
+ export class QuestionnaireSession {
54
+ private state: QuestionnaireState = initialState();
55
+
56
+ private readonly questions: readonly QuestionData[];
57
+ private readonly isMulti: boolean;
58
+ private readonly itemsByTab: WrappingSelectItem[][];
59
+
60
+ private readonly notesInput: Input;
61
+ private readonly inlineInput: Input;
62
+ private readonly viewAdapter: QuestionnairePropsAdapter;
63
+ private readonly collapseKey: string;
64
+
65
+ private readonly tui: QuestionnaireSessionConfig["tui"];
66
+ private readonly done: QuestionnaireSessionConfig["done"];
67
+ readonly component: QuestionnaireSessionComponent;
68
+
69
+ constructor(config: QuestionnaireSessionConfig) {
70
+ this.tui = config.tui;
71
+ this.done = config.done;
72
+ this.questions = config.params.questions;
73
+ this.isMulti = this.questions.length > 1;
74
+ this.itemsByTab = config.itemsByTab;
75
+ this.collapseKey = config.collapseKey;
76
+
77
+ const built = buildQuestionnaire({
78
+ tui: this.tui,
79
+ theme: config.theme,
80
+ questions: this.questions,
81
+ itemsByTab: this.itemsByTab,
82
+ isMulti: this.isMulti,
83
+ initialState: this.state,
84
+ getCurrentTab: () => this.state.currentTab,
85
+ });
86
+
87
+ this.notesInput = built.notesInput;
88
+ this.inlineInput = built.inlineInput;
89
+ this.viewAdapter = built.adapter;
90
+
91
+ const theme = config.theme;
92
+ // Collapsed render keeps one focused hint row in the temporary editor slot,
93
+ // so the transcript regains space while Ctrl+] can still expand the questionnaire.
94
+ const collapsedRender = (_width: number): string[] => [
95
+ theme.fg("dim", ` ${COLLAPSED_HINT} `),
96
+ ];
97
+
98
+ this.component = {
99
+ render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
100
+ invalidate: built.invalidate,
101
+ handleInput: (data) => this.dispatch(data),
102
+ };
103
+
104
+ this.viewAdapter.apply(this.state);
105
+ }
106
+
107
+ dispatch(data: string): void {
108
+ const action = routeKey(data, this.state, this.runtime());
109
+ if (action.kind === "ignore") {
110
+ this.handleIgnoreInline(data);
111
+ return;
112
+ }
113
+ this.commit(action);
114
+ }
115
+
116
+ private commit(action: QuestionnaireAction): void {
117
+ const result = reduce(this.state, action, this.applyContext());
118
+ this.state = result.state;
119
+ for (const effect of result.effects) this.runEffect(effect);
120
+ this.state = this.mirrorNotesDraft(this.state);
121
+ this.viewAdapter.apply(this.state);
122
+ }
123
+
124
+ private mirrorNotesDraft(s: QuestionnaireState): QuestionnaireState {
125
+ const draft = this.notesInput.getValue();
126
+ return s.notesDraft === draft ? s : { ...s, notesDraft: draft };
127
+ }
128
+
129
+ private runEffect(effect: Effect): void {
130
+ switch (effect.kind) {
131
+ case "set_input_buffer":
132
+ this.inlineInput.setValue(effect.value);
133
+ this.inlineInput.handleInput(CURSOR_END);
134
+ return;
135
+ case "clear_input_buffer":
136
+ this.inlineInput.setValue("");
137
+ return;
138
+ case "set_notes_value":
139
+ this.notesInput.setValue(effect.value);
140
+ return;
141
+ case "set_notes_focused":
142
+ this.notesInput.focused = effect.focused;
143
+ return;
144
+ case "forward_notes_keystroke":
145
+ this.notesInput.handleInput(effect.data);
146
+ return;
147
+ case "done":
148
+ this.done(effect.result);
149
+ return;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Per-keystroke `ignore` fast path: delegates to the headless `inlineInput`
155
+ * Input so bracketed-paste accumulator (`input.js:33-63`) and Kitty CSI-u
156
+ * decode (`input.js:155-163`) take effect. Cursor is NOT force-reset here —
157
+ * doing so would corrupt split-chunk pastes (a `\x05` byte mid-paste lands
158
+ * verbatim in `pasteBuffer` and survives `handlePaste`'s narrow strip).
159
+ * Cursor advances naturally via `insertCharacter` on typing/paste; cursor-
160
+ * movement keys (Left/Right/Home/End/word-jumps) are now functional, with
161
+ * the always-end visual cursor marker drawn independently by
162
+ * `WrappingSelect.renderInlineInputRow`. `viewAdapter.apply` is called
163
+ * directly without a reducer round-trip — preserves the D3 fast-path
164
+ * latency profile from Phase 11.
165
+ */
166
+ private handleIgnoreInline(data: string): void {
167
+ if (!this.state.inputMode) return;
168
+ this.inlineInput.handleInput(data);
169
+ this.viewAdapter.apply(this.state);
170
+ }
171
+
172
+ private runtime(): QuestionnaireRuntime {
173
+ return {
174
+ keybindings: getKeybindings(),
175
+ inputBuffer: this.inlineInput.getValue(),
176
+ questions: this.questions,
177
+ isMulti: this.isMulti,
178
+ currentItem: this.currentItem(),
179
+ items: this.itemsByTab[this.state.currentTab] ?? [],
180
+ collapseKey: this.collapseKey,
181
+ };
182
+ }
183
+
184
+ private applyContext(): ApplyContext {
185
+ return {
186
+ questions: this.questions,
187
+ itemsByTab: this.itemsByTab,
188
+ };
189
+ }
190
+
191
+ private currentItem(): WrappingSelectItem | undefined {
192
+ const arr = this.itemsByTab[this.state.currentTab] ?? [];
193
+ return this.state.optionIndex < arr.length ? arr[this.state.optionIndex] : undefined;
194
+ }
195
+
196
+ }
@@ -0,0 +1,143 @@
1
+ import type { QuestionData } from "../tool/types.js";
2
+ import type { WrappingSelectItem } from "../view/components/wrapping-select.js";
3
+
4
+ /**
5
+ * Row kind discriminator. Single source of truth — derived from the runtime
6
+ * `WrappingSelectItem` union (`wrapping-select.ts:18-22`) so adding a new
7
+ * variant there forces a `ROW_INTENT_META` entry here (compile-time
8
+ * exhaustiveness via `Record<RowKind, …>`).
9
+ */
10
+ export type RowKind = WrappingSelectItem["kind"];
11
+
12
+ /**
13
+ * Sentinel kinds — the subset of `RowKind` representing protocol-driven rows
14
+ * (vs author-defined `option` rows). The auto-append walker, the reserved-label
15
+ * derivation, and `LABELS_BY_KIND` consumers iterate this list.
16
+ */
17
+ export type SentinelKind = Exclude<RowKind, "option">;
18
+ export const SENTINEL_KINDS: readonly SentinelKind[] = ["other", "next"];
19
+
20
+ /**
21
+ * Per-kind static metadata. Pure data — no closures, no per-kind handler
22
+ * functions. Behavior-bearing branches (answer construction in
23
+ * `key-router.ts:69-99`, the Next-row render block in `multi-select-view.ts`,
24
+ * and the inline-Input render branch in `wrapping-select.ts`) keep their
25
+ * exhaustive switches and READ flags from this table.
26
+ *
27
+ * Adding a new sentinel:
28
+ * 1. Add the variant to `WrappingSelectItem` (`wrapping-select.ts:18-22`).
29
+ * 2. Add an entry here. Compile fails until both edits are present.
30
+ * 3. (If user-facing) author wherever the row is synthesized — typically
31
+ * `buildItemsForQuestion` for main-list residents.
32
+ *
33
+ * Field semantics:
34
+ * - `label` — user-facing string. For `option` it's an empty placeholder
35
+ * (per-instance labels come from `QuestionData.options[i].label`); every
36
+ * sentinel uses its META entry as the single source of truth.
37
+ * - `reserved` — author-facing labels matching this string trigger
38
+ * `reserved_label` at validation time. `RESERVED_LABEL_SET` is derived.
39
+ * - `livesInMainList` — true iff the row appears in `itemsByTab[i]`. Every
40
+ * current sentinel lives in the main list (the `next` row is synthesized by
41
+ * `sentinelsToAppend`).
42
+ * - `numbered` — true iff the row contributes to the main-list numbering.
43
+ * Multi-select Next is the only `numbered=false` row that lives in the list.
44
+ * - `activatesInputMode` — true iff focusing the row should toggle
45
+ * `state.inputMode = true`. Read by `state-reducer.ts` `nav` case.
46
+ * - `blocksMultiToggle` — in multiSelect mode, Space (and Enter-as-toggle)
47
+ * on this row is suppressed. The Next sentinel is the only true.
48
+ * - `autoSubmitsInMulti` — in multiSelect mode, Enter on this row commits
49
+ * the question (emits `multi_confirm`). The Next sentinel is the only true.
50
+ * - `autoAppendOnSingleSelect` — `buildItemsForQuestion` appends
51
+ * this row when the question is single-select, regardless of whether any
52
+ * option carries a `preview`. The "other" sentinel is the only true.
53
+ * - `autoAppendOnMultiSelect` — `buildItemsForQuestion` appends this row
54
+ * when the question is multi-select. The `other` and `next` sentinels are both true.
55
+ */
56
+ export interface RowIntentMeta {
57
+ label: string;
58
+ reserved: boolean;
59
+ livesInMainList: boolean;
60
+ numbered: boolean;
61
+ activatesInputMode: boolean;
62
+ blocksMultiToggle: boolean;
63
+ autoSubmitsInMulti: boolean;
64
+ autoAppendOnSingleSelect: boolean;
65
+ autoAppendOnMultiSelect: boolean;
66
+ }
67
+
68
+ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
69
+ option: {
70
+ label: "",
71
+ reserved: false,
72
+ livesInMainList: true,
73
+ numbered: true,
74
+ activatesInputMode: false,
75
+ blocksMultiToggle: false,
76
+ autoSubmitsInMulti: false,
77
+ autoAppendOnSingleSelect: false,
78
+ autoAppendOnMultiSelect: false,
79
+ },
80
+ other: {
81
+ label: "Type something.",
82
+ reserved: true,
83
+ livesInMainList: true,
84
+ numbered: true,
85
+ activatesInputMode: true,
86
+ blocksMultiToggle: false,
87
+ autoSubmitsInMulti: false,
88
+ autoAppendOnSingleSelect: true,
89
+ autoAppendOnMultiSelect: true,
90
+ },
91
+ next: {
92
+ label: "Next",
93
+ reserved: true,
94
+ livesInMainList: true,
95
+ numbered: false,
96
+ activatesInputMode: false,
97
+ blocksMultiToggle: true,
98
+ autoSubmitsInMulti: true,
99
+ autoAppendOnSingleSelect: false,
100
+ autoAppendOnMultiSelect: true,
101
+ },
102
+ };
103
+
104
+ /**
105
+ * Kind-keyed label view. `option` is excluded — its label is per-instance,
106
+ * not per-kind. `types.ts#SENTINEL_LABELS` re-sources from here.
107
+ */
108
+ export const LABELS_BY_KIND: { readonly [K in SentinelKind]: string } = {
109
+ other: ROW_INTENT_META.other.label,
110
+ next: ROW_INTENT_META.next.label,
111
+ };
112
+
113
+ /**
114
+ * Reserved-label set for runtime validation. Includes "Other" (a model-conditioned
115
+ * label that has no runtime kind) plus every sentinel with `reserved: true`.
116
+ */
117
+ export const RESERVED_LABEL_SET: ReadonlySet<string> = new Set<string>([
118
+ "Other",
119
+ ...SENTINEL_KINDS.filter((k) => ROW_INTENT_META[k].reserved).map((k) => ROW_INTENT_META[k].label),
120
+ ]);
121
+
122
+ /**
123
+ * Walk the META table to synthesize sentinel rows for one question. The two
124
+ * append predicates are mutually exclusive in practice (`multiSelect` vs
125
+ * single-select) but the walker doesn't enforce that — adding a third bucket
126
+ * only requires a new META flag.
127
+ *
128
+ * Returns sentinel descriptors in declaration order of `SENTINEL_KINDS`. The
129
+ * caller wraps each with the `WrappingSelectItem` shape (kind + label).
130
+ */
131
+ export function sentinelsToAppend(question: QuestionData): SentinelKind[] {
132
+ const out: SentinelKind[] = [];
133
+ for (const k of SENTINEL_KINDS) {
134
+ const meta = ROW_INTENT_META[k];
135
+ if (!meta.livesInMainList) continue;
136
+ if (question.multiSelect === true) {
137
+ if (meta.autoAppendOnMultiSelect) out.push(k);
138
+ } else {
139
+ if (meta.autoAppendOnSingleSelect) out.push(k);
140
+ }
141
+ }
142
+ return out;
143
+ }
@@ -0,0 +1,24 @@
1
+ import type { QuestionData } from "../../tool/types.js";
2
+ import type { PreviewPaneProps } from "../../view/components/preview/preview-pane.js";
3
+ import type { WrappingSelectItem } from "../../view/components/wrapping-select.js";
4
+ import type { ActiveView, StatefulView } from "../../view/stateful-view.js";
5
+ import type { TabComponents } from "../../view/tab-components.js";
6
+ import type { QuestionnaireState } from "../state.js";
7
+
8
+ export interface BindingContext {
9
+ readonly questions: readonly QuestionData[];
10
+ readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
11
+ readonly totalQuestions: number;
12
+ readonly activeView: ActiveView;
13
+ readonly inputBuffer: string;
14
+ readonly inputCursorOffset: number | undefined;
15
+ readonly activePreviewPane: StatefulView<PreviewPaneProps>;
16
+ }
17
+
18
+ export interface PerTabBindingContext extends BindingContext {
19
+ readonly tab: TabComponents;
20
+ readonly i: number;
21
+ }
22
+
23
+ export type GlobalSelector<P> = (state: QuestionnaireState, ctx: BindingContext) => P;
24
+ 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 "../../view/components/wrapping-select.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 "../../view/stateful-view.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
+ }