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.
- package/README.md +21 -5
- package/assets/readme/welcome.webp +0 -0
- package/extensions/ask-user-question/LICENSE +21 -0
- package/extensions/ask-user-question/README.md +63 -0
- package/extensions/ask-user-question/ask-user-question.ts +244 -0
- package/extensions/ask-user-question/config.ts +75 -0
- package/extensions/ask-user-question/events.ts +55 -0
- package/extensions/ask-user-question/index.ts +17 -0
- package/extensions/ask-user-question/reconcile.ts +47 -0
- package/extensions/ask-user-question/rpc-fallback.ts +160 -0
- package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
- package/extensions/ask-user-question/state/dialog-height.ts +16 -0
- package/extensions/ask-user-question/state/key-router.ts +269 -0
- package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
- package/extensions/ask-user-question/state/row-intent.ts +143 -0
- package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
- package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
- package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
- package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
- package/extensions/ask-user-question/state/state-reducer.ts +282 -0
- package/extensions/ask-user-question/state/state.ts +49 -0
- package/extensions/ask-user-question/tool/format-answer.ts +29 -0
- package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
- package/extensions/ask-user-question/tool/types.ts +145 -0
- package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
- package/extensions/ask-user-question/view/component-binding.ts +45 -0
- package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
- package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
- package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
- package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
- package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
- package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
- package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
- package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
- package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
- package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
- package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
- package/extensions/ask-user-question/view/props-adapter.ts +123 -0
- package/extensions/ask-user-question/view/stateful-view.ts +24 -0
- package/extensions/ask-user-question/view/tab-components.ts +16 -0
- package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
- package/package.json +14 -4
- 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
|
+
}
|