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,378 @@
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_SUBMIT = "tui.input.submit";
10
+ const KEYBIND_CANCEL = "tui.select.cancel";
11
+ const KEYBIND_NEW_LINE = "tui.input.newLine";
12
+ const KEYBIND_EDITOR_UP = "tui.editor.cursorUp";
13
+ const KEYBIND_EDITOR_DOWN = "tui.editor.cursorDown";
14
+ const KEYBIND_CLEAR = "tui.editor.deleteToLineStart";
15
+ const KEYBIND_EXTERNAL_EDITOR = "app.editor.external";
16
+
17
+ const NOTES_ACTIVATE_KEY = "n";
18
+ const SPACE_KEY = " ";
19
+
20
+ /**
21
+ * `autoAdvanceTab` is deliberately `number | undefined` rather than merely
22
+ * optional. An explicit `undefined` is a signal, not an omission: it means the
23
+ * questionnaire is on its last tab and confirming should finish rather than
24
+ * advance. `computeAutoAdvanceTab` returns it, and the reducer branches on
25
+ * `!== undefined` to choose between `switchTabResult` and `doneFor`.
26
+ */
27
+ export type QuestionnaireAction =
28
+ | { kind: "nav"; nextIndex: number; inputValue: string }
29
+ | { kind: "input_clear" }
30
+ | { kind: "input_edit"; value: string }
31
+ | { kind: "input_replace"; value: string }
32
+ | { kind: "tab_switch"; nextTab: number }
33
+ | { kind: "confirm"; answer: QuestionAnswer; autoAdvanceTab?: number | undefined }
34
+ | { kind: "toggle"; index: number }
35
+ | { kind: "multi_confirm"; selected: string[]; autoAdvanceTab?: number | undefined }
36
+ | { kind: "cancel" }
37
+ | { kind: "notes_enter" }
38
+ | { kind: "notes_exit" }
39
+ | { kind: "submit" }
40
+ | { kind: "submit_nav"; nextIndex: 0 | 1 }
41
+ | { kind: "notes_forward"; data: string }
42
+ /** Flip `state.collapsed`. Always available, regardless of inner mode (see top intercept in `routeKey`). */
43
+ | { kind: "toggle_collapsed" }
44
+ | { kind: "tick"; now: number }
45
+ | { kind: "ignore" };
46
+
47
+ export interface QuestionnaireKeybindings {
48
+ matches(data: string, name: string): boolean;
49
+ }
50
+
51
+ // Confirm has two semantic sources: `tui.select.confirm` (the select-list default)
52
+ // and `tui.input.submit` (the user's "send" key). A Slack-style configuration folds
53
+ // `enter` into `tui.input.newLine` and moves submit elsewhere (e.g. `ctrl+enter`);
54
+ // the newline checks below stay first, so without the submit source every confirm
55
+ // branch would be shadowed by the collision and no key could confirm (#156). With
56
+ // pi defaults both names resolve to `enter`, so matching either is equivalent.
57
+ function isConfirm(kb: QuestionnaireKeybindings, data: string): boolean {
58
+ return kb.matches(data, KEYBIND_CONFIRM) || kb.matches(data, KEYBIND_SUBMIT);
59
+ }
60
+
61
+ export function wrapTab(index: number, total: number): number {
62
+ if (total <= 0) return 0;
63
+ return ((index % total) + total) % total;
64
+ }
65
+
66
+ export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean {
67
+ if (runtime.questions.length === 0) return false;
68
+ for (let i = 0; i < runtime.questions.length; i++) {
69
+ if (!state.answers.has(i)) return false;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ function totalTabs(runtime: QuestionnaireRuntime): number {
75
+ return runtime.isMulti ? runtime.questions.length + 1 : 1;
76
+ }
77
+
78
+ function computeAutoAdvanceTab(
79
+ state: QuestionnaireState,
80
+ runtime: QuestionnaireRuntime,
81
+ ): number | undefined {
82
+ if (!runtime.isMulti) return undefined;
83
+ if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1;
84
+ return runtime.questions.length;
85
+ }
86
+
87
+ function buildSingleSelectAnswer(
88
+ state: QuestionnaireState,
89
+ runtime: QuestionnaireRuntime,
90
+ ): QuestionAnswer | null {
91
+ const q = runtime.questions[state.currentTab];
92
+ if (!q) return null;
93
+
94
+ const item = runtime.currentItem;
95
+
96
+ if (state.inputMode) {
97
+ const label = runtime.inputBuffer;
98
+ return {
99
+ questionIndex: state.currentTab,
100
+ question: q.question,
101
+ kind: "custom",
102
+ answer: label.length > 0 ? label : null,
103
+ };
104
+ }
105
+ if (!item) return null;
106
+ if (item.kind === "other") {
107
+ return null;
108
+ }
109
+ if (item.kind === "next") {
110
+ return null;
111
+ }
112
+ return {
113
+ questionIndex: state.currentTab,
114
+ question: q.question,
115
+ kind: "option",
116
+ answer: item.label,
117
+ };
118
+ }
119
+
120
+ function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
121
+ const q = runtime.questions[state.currentTab];
122
+ if (!q) return [];
123
+ const out: string[] = [];
124
+ for (let i = 0; i < q.options.length; i++) {
125
+ if (state.multiSelectChecked.has(i)) {
126
+ const label = q.options[i]?.label;
127
+ if (typeof label === "string") out.push(label);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function tabSwitchAction(
134
+ data: string,
135
+ state: QuestionnaireState,
136
+ runtime: QuestionnaireRuntime,
137
+ ): QuestionnaireAction | null {
138
+ if (!runtime.isMulti) return null;
139
+ const total = totalTabs(runtime);
140
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
141
+ return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
142
+ }
143
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
144
+ return { kind: "tab_switch", nextTab: wrapTab(state.currentTab - 1, total) };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // DOWN at the last item wraps to the first (cycle through [option0, …, optionLast]).
150
+ function nextNavOnDown(
151
+ state: QuestionnaireState,
152
+ runtime: QuestionnaireRuntime,
153
+ ): QuestionnaireAction {
154
+ return {
155
+ kind: "nav",
156
+ nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)),
157
+ inputValue: runtime.inputBuffer,
158
+ };
159
+ }
160
+
161
+ // UP at the first item wraps to the last (symmetric with nextNavOnDown).
162
+ function prevNavOnUp(
163
+ state: QuestionnaireState,
164
+ runtime: QuestionnaireRuntime,
165
+ ): QuestionnaireAction {
166
+ return {
167
+ kind: "nav",
168
+ nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)),
169
+ inputValue: runtime.inputBuffer,
170
+ };
171
+ }
172
+
173
+ // Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so
174
+ // the user can read the now-uncovered transcript without accidentally mutating
175
+ // answers or notes. The collapse toggle itself is already handled above.
176
+ function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
177
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
178
+ return { kind: "ignore" };
179
+ }
180
+
181
+ function routeNotesMode(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
182
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" };
183
+ if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "notes_forward", data };
184
+ if (isConfirm(kb, data)) return { kind: "notes_exit" };
185
+ return { kind: "notes_forward", data };
186
+ }
187
+
188
+ function routeInputMode(
189
+ kb: QuestionnaireKeybindings,
190
+ data: string,
191
+ state: QuestionnaireState,
192
+ runtime: QuestionnaireRuntime,
193
+ ): QuestionnaireAction {
194
+ // Newline takes precedence over confirmation if a user configuration binds
195
+ // the same physical key to both semantic actions.
196
+ if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
197
+ if (isConfirm(kb, data)) {
198
+ const answer = buildSingleSelectAnswer(state, runtime);
199
+ if (!answer) return { kind: "ignore" };
200
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
201
+ }
202
+ // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
203
+ // independent of the current cursor position.
204
+ if (kb.matches(data, KEYBIND_CLEAR)) return { kind: "input_clear" };
205
+ if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR))
206
+ return { kind: "input_edit", value: runtime.inputBuffer };
207
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
208
+ if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) return { kind: "ignore" };
209
+ if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) return { kind: "ignore" };
210
+ if (kb.matches(data, KEYBIND_UP)) return prevNavOnUp(state, runtime);
211
+ if (kb.matches(data, KEYBIND_DOWN)) return nextNavOnDown(state, runtime);
212
+ return { kind: "ignore" };
213
+ }
214
+
215
+ function routeSubmitTab(
216
+ kb: QuestionnaireKeybindings,
217
+ data: string,
218
+ state: QuestionnaireState,
219
+ runtime: QuestionnaireRuntime,
220
+ ): QuestionnaireAction {
221
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
222
+ const tab = tabSwitchAction(data, state, runtime);
223
+ if (tab) return tab;
224
+ if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
225
+ const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
226
+ const next = wrapTab(state.submitChoiceIndex + delta, 2);
227
+ return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
228
+ }
229
+ if (isConfirm(kb, data)) {
230
+ // D1 (revised): Submit always submits; Cancel always cancels. The warning header
231
+ // is informational only — `allAnswered(state)` no longer gates submission. Partial
232
+ // answers flow through `orderedAnswers()` in the host.
233
+ return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
234
+ }
235
+ // Global note (#182): `n` on the Submit tab opens the notes editor scoped to the whole
236
+ // questionnaire at the pseudo-index (`questions.length` in notesByTab). Reachable only
237
+ // with the editor closed — `routeKey` dispatches notesVisible traffic to
238
+ // `routeNotesMode` before the submit-tab block runs. The branch shadows nothing above
239
+ // it (cancel, tab switch, submit-nav, confirm): a literal `n` byte matches none of them
240
+ // under default bindings, and a user who deliberately binds one of those actions to
241
+ // `n` keeps that mapping because the earlier branch still wins (Enter still submits).
242
+ if (data === NOTES_ACTIVATE_KEY) {
243
+ return { kind: "notes_enter" };
244
+ }
245
+ return { kind: "ignore" };
246
+ }
247
+
248
+ function routeMultiSelectTab(
249
+ kb: QuestionnaireKeybindings,
250
+ data: string,
251
+ state: QuestionnaireState,
252
+ runtime: QuestionnaireRuntime,
253
+ ): QuestionnaireAction {
254
+ const focusedKind = runtime.currentItem?.kind;
255
+ const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
256
+ // Space toggles the focused row's checkbox. Suppressed on rows whose META declares
257
+ // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
258
+ // something." row — it is an inline input, not a checkable option).
259
+ if (data === SPACE_KEY) {
260
+ if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
261
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
262
+ return { kind: "toggle", index: state.optionIndex };
263
+ }
264
+ if (isConfirm(kb, data)) {
265
+ // Enter on the "Type something." row is handled by the inputMode block above
266
+ // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
267
+ // path for an inputMode-activating row.
268
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
269
+ // Enter on a regular row toggles (matching Space) — committing the question is now
270
+ // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
271
+ // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
272
+ // without leaving the keyboard home row.
273
+ if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
274
+ // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
275
+ // multi-question mode, OR submit the dialog in single-question mode
276
+ // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
277
+ // question would have no way to commit at all.
278
+ return {
279
+ kind: "multi_confirm",
280
+ selected: buildMultiSelected(state, runtime),
281
+ autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
282
+ };
283
+ }
284
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
285
+ return { kind: "ignore" };
286
+ }
287
+
288
+ function routeSingleSelectTab(
289
+ kb: QuestionnaireKeybindings,
290
+ data: string,
291
+ state: QuestionnaireState,
292
+ runtime: QuestionnaireRuntime,
293
+ ): QuestionnaireAction {
294
+ if (isConfirm(kb, data)) {
295
+ const answer = buildSingleSelectAnswer(state, runtime);
296
+ if (!answer) return { kind: "ignore" };
297
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
298
+ }
299
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
300
+ return { kind: "ignore" };
301
+ }
302
+
303
+ export function routeKey(
304
+ data: string,
305
+ state: QuestionnaireState,
306
+ runtime: QuestionnaireRuntime,
307
+ ): QuestionnaireAction {
308
+ const kb = runtime.keybindings;
309
+
310
+ // Collapse/expand toggle is a UI-level affordance — intercepted at the top so it
311
+ // works from every inner state (notes, inputMode, submit tab, multi-select)
312
+ // without reaching any branch that would otherwise consume the keystroke. The
313
+ // questionnaire overlay is fully hidden via `OverlayHandle.setHidden(true)` while
314
+ // collapsed; pi-tui's overlay stack updates accordingly, so overlay-aware consumers
315
+ // (e.g. `pi-station`) see no visible modal and chat scroll resumes. The toggle key is
316
+ // also captured at the raw terminal level via `ctx.ui.onTerminalInput` so it still
317
+ // routes here when the overlay is hidden (pi-tui does not deliver input to a hidden
318
+ // overlay's `component.handleInput`).
319
+ //
320
+ // The default `ctrl+]` is free in every mainstream macOS terminal (Terminal.app,
321
+ // iTerm2, Warp), every multiplexer (tmux, zellij, screen — none use it as a prefix),
322
+ // and the legacy telnet/ssh escape role doesn't apply because our overlay runs
323
+ // in-process. It is, however, awkward on keyboard layouts where `]` is on the
324
+ // shifted layer (Latin American `es-AR`/`es-MX` require `Ctrl+Shift+}` for `Ctrl+]`)
325
+ // — use the `collapseKey` config field to override.
326
+ // Treat a missing/non-string key as disabled. This can occur at runtime when
327
+ // a long-lived Pi process retains an older outer module while a package update
328
+ // replaces the lazily imported QuestionnaireSession graph on disk. Passing
329
+ // undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the
330
+ // entire host process, so keep the runtime boundary defensive even though the
331
+ // TypeScript contract requires a string. The "off" literal is deliberate:
332
+ // importing COLLAPSE_KEY_OFF would pull ../config.js (and its rpiv-config
333
+ // loader graph) into this pure module for a string that cannot change.
334
+ if (
335
+ typeof runtime.collapseKey === "string" &&
336
+ runtime.collapseKey !== "off" &&
337
+ matchesKey(data, runtime.collapseKey as Parameters<typeof matchesKey>[1])
338
+ ) {
339
+ return { kind: "toggle_collapsed" };
340
+ }
341
+
342
+ if (state.collapsed) return routeCollapsed(kb, data);
343
+ if (state.notesVisible) return routeNotesMode(kb, data);
344
+ if (state.inputMode) return routeInputMode(kb, data, state, runtime);
345
+ if (runtime.isMulti && state.currentTab === runtime.questions.length) {
346
+ return routeSubmitTab(kb, data, state, runtime);
347
+ }
348
+
349
+ const tab = tabSwitchAction(data, state, runtime);
350
+ if (tab) return tab;
351
+
352
+ const q = runtime.questions[state.currentTab];
353
+ if (!q) return { kind: "ignore" };
354
+
355
+ // Universal `n` activation (FR-1): the notes editor opens on every question tab
356
+ // (single- or multi-select, preview or no-preview). The blocks above already
357
+ // swallow `n` when it should NOT reach here — notesVisible forwards to the
358
+ // notes Input, inputMode forwards to the inline Input, the submit-tab block
359
+ // activates it via its own branch (the global note, ahead of its ignore
360
+ // fall-through), and tabSwitchAction ignores it — so by the time we reach this
361
+ // gate, `n` is unambiguously a notes-enter request. Row intent (Next sentinel,
362
+ // "Type something.") is irrelevant: the gate sits ABOVE the multi-select
363
+ // toggle block and the Next sentinel never activates inputMode, so `n` is
364
+ // neither swallowed earlier nor blocked by `blocksMultiToggle`.
365
+ if (data === NOTES_ACTIVATE_KEY) {
366
+ return { kind: "notes_enter" };
367
+ }
368
+
369
+ if (kb.matches(data, KEYBIND_UP)) {
370
+ return prevNavOnUp(state, runtime);
371
+ }
372
+ if (kb.matches(data, KEYBIND_DOWN)) {
373
+ return nextNavOnDown(state, runtime);
374
+ }
375
+
376
+ if (q.multiSelect) return routeMultiSelectTab(kb, data, state, runtime);
377
+ return routeSingleSelectTab(kb, data, state, runtime);
378
+ }