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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- package/src/view/tab-content-strategy.ts +447 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { WrappingSelectItem } from "../../state/row-intent.js";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderInlineInputRow } from "./inline-input.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Re-exported, never redeclared. The row shape lives in the state layer beside
|
|
8
|
+
* `RowKind` and `ROW_INTENT_META`, because row intent is part of the protocol
|
|
9
|
+
* that the reducer and key router read, not something a renderer owns. This
|
|
10
|
+
* module is only its most prominent consumer.
|
|
11
|
+
*
|
|
12
|
+
* Variant semantics, by `kind`:
|
|
13
|
+
* - `option` — a regular author-defined option row.
|
|
14
|
+
* - `other` — the inline free-text row appended to every question, labelled
|
|
15
|
+
* "Type something.". Renders the headless multiline editor while active.
|
|
16
|
+
* - `next` — the commit-and-advance row appended to multi-select questions,
|
|
17
|
+
* labelled "Next". Renders without a number or checkbox.
|
|
18
|
+
*/
|
|
19
|
+
export type { WrappingSelectItem } from "../../state/row-intent.js";
|
|
20
|
+
|
|
21
|
+
export interface WrappingSelectTheme {
|
|
22
|
+
selectedText: (text: string) => string;
|
|
23
|
+
description: (text: string) => string;
|
|
24
|
+
scrollInfo: (text: string) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Numbering controls.
|
|
29
|
+
*
|
|
30
|
+
* Use `numberStartOffset` + `totalItemsForNumbering` when a list is logically a slice of a
|
|
31
|
+
* larger numbered sequence — e.g. to start numbering at an offset and pad the column as
|
|
32
|
+
* if the list were part of a longer continuous numbered sequence.
|
|
33
|
+
*/
|
|
34
|
+
export interface WrappingSelectOptions {
|
|
35
|
+
/** Start numbering at this offset + 1 (default 0 → rows labeled 1, 2, 3 …). */
|
|
36
|
+
numberStartOffset?: number;
|
|
37
|
+
/** Override the total used to pad the number column (useful when items span multiple lists). */
|
|
38
|
+
totalItemsForNumbering?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class WrappingSelect implements Component {
|
|
42
|
+
private static readonly ACTIVE_POINTER = "❯ ";
|
|
43
|
+
private static readonly INACTIVE_POINTER = " ";
|
|
44
|
+
private static readonly NUMBER_SEPARATOR = ". ";
|
|
45
|
+
private static readonly CONFIRMED_MARK = " ✔";
|
|
46
|
+
private static readonly MIN_CONTENT_WIDTH = 1;
|
|
47
|
+
|
|
48
|
+
private readonly items: readonly WrappingSelectItem[];
|
|
49
|
+
private readonly maxVisible: number;
|
|
50
|
+
private readonly theme: WrappingSelectTheme;
|
|
51
|
+
private numberStartOffset: number;
|
|
52
|
+
private totalItemsForNumbering: number;
|
|
53
|
+
|
|
54
|
+
private selectedIndex = 0;
|
|
55
|
+
private focused = true;
|
|
56
|
+
private inputBuffer = "";
|
|
57
|
+
private inputCursorOffset: number | undefined = undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Index of the row that was previously confirmed for this list (e.g. the user's prior
|
|
60
|
+
* answer when re-entering a multi-question tab). Renders `<label> ✔` in the active-row
|
|
61
|
+
* styling but WITHOUT the `❯` pointer — pointer is reserved for the live cursor. When
|
|
62
|
+
* `selectedIndex === confirmedIndex && focused`, the active rendering wins (no double-mark).
|
|
63
|
+
*/
|
|
64
|
+
private confirmedIndex: number | undefined = undefined;
|
|
65
|
+
/**
|
|
66
|
+
* When set together with `confirmedIndex`, replaces the row's static label at render time.
|
|
67
|
+
* Used for the `kind: "other"` sentinel — its label is "Type something." but if the user's
|
|
68
|
+
* prior answer was custom text, we render that text instead (e.g. `4. Hello ✔`).
|
|
69
|
+
*/
|
|
70
|
+
private confirmedLabelOverride: string | undefined = undefined;
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
items: readonly WrappingSelectItem[],
|
|
74
|
+
maxVisible: number,
|
|
75
|
+
theme: WrappingSelectTheme,
|
|
76
|
+
options: WrappingSelectOptions = {},
|
|
77
|
+
) {
|
|
78
|
+
this.items = items;
|
|
79
|
+
this.maxVisible = Math.max(1, maxVisible);
|
|
80
|
+
this.theme = theme;
|
|
81
|
+
this.numberStartOffset = options.numberStartOffset ?? 0;
|
|
82
|
+
this.totalItemsForNumbering = options.totalItemsForNumbering ?? items.length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Update the numbering offset + total padding width without rebuilding the component.
|
|
87
|
+
* Lets the host realign the number column when the underlying item set changes.
|
|
88
|
+
*/
|
|
89
|
+
setNumbering(numberStartOffset: number, totalItemsForNumbering: number): void {
|
|
90
|
+
this.numberStartOffset = numberStartOffset;
|
|
91
|
+
this.totalItemsForNumbering = Math.max(1, totalItemsForNumbering);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setSelectedIndex(index: number): void {
|
|
95
|
+
this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setFocused(focused: boolean): void {
|
|
99
|
+
this.focused = focused;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mark a previously-confirmed row. Pass `undefined` to clear. `labelOverride` replaces
|
|
104
|
+
* the row's static `item.label` at render time — used for the `kind: "other"` sentinel so
|
|
105
|
+
* the row reads `Hello ✔` instead of `Type something. ✔` when the prior answer was custom
|
|
106
|
+
* text.
|
|
107
|
+
*/
|
|
108
|
+
setConfirmedIndex(index: number | undefined, labelOverride?: string): void {
|
|
109
|
+
if (index === undefined) {
|
|
110
|
+
this.confirmedIndex = undefined;
|
|
111
|
+
this.confirmedLabelOverride = undefined;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.confirmedIndex = Math.max(0, Math.min(index, this.items.length - 1));
|
|
115
|
+
this.confirmedLabelOverride = labelOverride;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setInputBuffer(text: string): void {
|
|
119
|
+
this.inputBuffer = text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Set the cursor offset for the inline input row. `undefined` → end-of-buffer fallback. */
|
|
123
|
+
setInputCursorOffset(offset: number | undefined): void {
|
|
124
|
+
this.inputCursorOffset = offset;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Intentionally empty — input is routed at the container level. */
|
|
128
|
+
handleInput(_data: string): void {}
|
|
129
|
+
|
|
130
|
+
invalidate(): void {}
|
|
131
|
+
|
|
132
|
+
render(width: number): string[] {
|
|
133
|
+
if (this.items.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
const { startIndex, endIndex } = this.computeVisibleWindow();
|
|
136
|
+
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
140
|
+
const item = this.items[i];
|
|
141
|
+
if (!item) continue;
|
|
142
|
+
const isActive = i === this.selectedIndex && this.focused;
|
|
143
|
+
lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
|
|
147
|
+
lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns the [startRow, endRow) range of the focused (selected) item within
|
|
154
|
+
* the output of `render(width)`. Computed by iterating the visible window and
|
|
155
|
+
* summing per-item row counts — O(maxVisible) per call.
|
|
156
|
+
*/
|
|
157
|
+
focusedItemRowRange(width: number): [number, number] {
|
|
158
|
+
if (this.items.length === 0) return [0, 0];
|
|
159
|
+
const { startIndex, endIndex } = this.computeVisibleWindow();
|
|
160
|
+
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
161
|
+
let row = 0;
|
|
162
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
163
|
+
const item = this.items[i];
|
|
164
|
+
if (!item) continue;
|
|
165
|
+
const isActive = i === this.selectedIndex && this.focused;
|
|
166
|
+
const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
|
|
167
|
+
if (i === this.selectedIndex) {
|
|
168
|
+
return [row, row + itemRowCount];
|
|
169
|
+
}
|
|
170
|
+
row += itemRowCount;
|
|
171
|
+
}
|
|
172
|
+
return [0, 1];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Per-item row count. Delegates to `renderItem().length` so `renderItem` remains
|
|
177
|
+
* the single source of truth for per-item row math — eliminates the prior shadow-copy
|
|
178
|
+
* that risked silent miscounts when new `kind` values branch in `renderItem` but not here.
|
|
179
|
+
*/
|
|
180
|
+
private computeItemRowCount(
|
|
181
|
+
item: WrappingSelectItem,
|
|
182
|
+
index: number,
|
|
183
|
+
isActive: boolean,
|
|
184
|
+
width: number,
|
|
185
|
+
numberWidth: number,
|
|
186
|
+
): number {
|
|
187
|
+
return this.renderItem(item, index, isActive, width, numberWidth).length;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private computeVisibleWindow(): { startIndex: number; endIndex: number } {
|
|
191
|
+
const half = Math.floor(this.maxVisible / 2);
|
|
192
|
+
const startIndex = Math.max(
|
|
193
|
+
0,
|
|
194
|
+
Math.min(this.selectedIndex - half, this.items.length - this.maxVisible),
|
|
195
|
+
);
|
|
196
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
|
|
197
|
+
return { startIndex, endIndex };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private hasItemsOutsideWindow(startIndex: number, endIndex: number): boolean {
|
|
201
|
+
return startIndex > 0 || endIndex < this.items.length;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private renderItem(
|
|
205
|
+
item: WrappingSelectItem,
|
|
206
|
+
index: number,
|
|
207
|
+
isActive: boolean,
|
|
208
|
+
width: number,
|
|
209
|
+
numberWidth: number,
|
|
210
|
+
): string[] {
|
|
211
|
+
const rowPrefix = this.buildRowPrefix(index, isActive, numberWidth);
|
|
212
|
+
const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
|
|
213
|
+
const contentWidth = Math.max(
|
|
214
|
+
WrappingSelect.MIN_CONTENT_WIDTH,
|
|
215
|
+
width - visibleWidth(rowPrefix),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (this.shouldRenderAsInlineInput(item, isActive)) {
|
|
219
|
+
return this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { label, isConfirmed } = this.deriveConfirmedState(item, index);
|
|
223
|
+
const applySelectedStyle = isActive || isConfirmed;
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
...this.renderLabelBlock(
|
|
227
|
+
label,
|
|
228
|
+
rowPrefix,
|
|
229
|
+
continuationPrefix,
|
|
230
|
+
contentWidth,
|
|
231
|
+
applySelectedStyle,
|
|
232
|
+
),
|
|
233
|
+
...this.renderDescriptionBlock(item.description, continuationPrefix, contentWidth),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Derive the confirmed-rendering facts for one row: the final label (custom draft
|
|
239
|
+
* or confirmed-override aware) and whether the ✔ mark applies.
|
|
240
|
+
*/
|
|
241
|
+
private deriveConfirmedState(
|
|
242
|
+
item: WrappingSelectItem,
|
|
243
|
+
index: number,
|
|
244
|
+
): { label: string; isConfirmed: boolean } {
|
|
245
|
+
// Keep an in-flight custom draft visible even while the cursor browses another row.
|
|
246
|
+
// If it differs from a previously confirmed custom answer, omit the confirmation
|
|
247
|
+
// mark so the pending draft is not presented as committed.
|
|
248
|
+
const customDraft = item.kind === "other" ? this.inputBuffer : undefined;
|
|
249
|
+
const customDraftDiffersFromConfirmed =
|
|
250
|
+
item.kind === "other" &&
|
|
251
|
+
customDraft !== "" &&
|
|
252
|
+
index === this.confirmedIndex &&
|
|
253
|
+
customDraft !== (this.confirmedLabelOverride ?? "");
|
|
254
|
+
const isConfirmed = index === this.confirmedIndex && !customDraftDiffersFromConfirmed;
|
|
255
|
+
const baseLabel = customDraft ? customDraft : item.label;
|
|
256
|
+
const label = isConfirmed
|
|
257
|
+
? `${this.confirmedLabelOverride ?? baseLabel}${WrappingSelect.CONFIRMED_MARK}`
|
|
258
|
+
: baseLabel;
|
|
259
|
+
return { label, isConfirmed };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private buildRowPrefix(index: number, isActive: boolean, numberWidth: number): string {
|
|
263
|
+
const pointer = isActive ? WrappingSelect.ACTIVE_POINTER : WrappingSelect.INACTIVE_POINTER;
|
|
264
|
+
const displayNumber = this.numberStartOffset + index + 1;
|
|
265
|
+
const paddedNumber = String(displayNumber).padStart(numberWidth, " ");
|
|
266
|
+
return `${pointer}${paddedNumber}${WrappingSelect.NUMBER_SEPARATOR}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private shouldRenderAsInlineInput(item: WrappingSelectItem, isActive: boolean): boolean {
|
|
270
|
+
return item.kind === "other" && isActive;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Render the inline editor across logical and visually wrapped lines. */
|
|
274
|
+
private renderInlineInputRow(
|
|
275
|
+
rowPrefix: string,
|
|
276
|
+
continuationPrefix: string,
|
|
277
|
+
contentWidth: number,
|
|
278
|
+
): string[] {
|
|
279
|
+
return renderInlineInputRow({
|
|
280
|
+
buffer: this.inputBuffer,
|
|
281
|
+
cursorOffset: this.inputCursorOffset,
|
|
282
|
+
rowPrefix,
|
|
283
|
+
continuationPrefix,
|
|
284
|
+
contentWidth,
|
|
285
|
+
selectedText: this.theme.selectedText,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private renderLabelBlock(
|
|
290
|
+
label: string,
|
|
291
|
+
rowPrefix: string,
|
|
292
|
+
continuationPrefix: string,
|
|
293
|
+
contentWidth: number,
|
|
294
|
+
applySelectedStyle: boolean,
|
|
295
|
+
): string[] {
|
|
296
|
+
const wrapped = wrapTextWithAnsi(label, contentWidth);
|
|
297
|
+
return wrapped.map((segment, index) => {
|
|
298
|
+
const prefix = index === 0 ? rowPrefix : continuationPrefix;
|
|
299
|
+
const line = `${prefix}${segment}`;
|
|
300
|
+
return applySelectedStyle ? this.theme.selectedText(line) : line;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private renderDescriptionBlock(
|
|
305
|
+
description: string | undefined,
|
|
306
|
+
continuationPrefix: string,
|
|
307
|
+
contentWidth: number,
|
|
308
|
+
): string[] {
|
|
309
|
+
if (!description) return [];
|
|
310
|
+
const wrapped = wrapTextWithAnsi(description, contentWidth);
|
|
311
|
+
return wrapped.map((segment) => `${continuationPrefix}${this.theme.description(segment)}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, Container, type Editor, Spacer } from "@earendil-works/pi-tui";
|
|
3
|
+
import { DEFAULT_COLLAPSE_KEY, formatKeySpecForDisplay } from "../config.js";
|
|
4
|
+
import type { QuestionnaireState } from "../state/state.js";
|
|
5
|
+
import type { QuestionData } from "../tool/types.js";
|
|
6
|
+
import type { PreviewPaneProps } from "./components/preview/preview-pane.js";
|
|
7
|
+
import type { TabBar } from "./components/tab-bar.js";
|
|
8
|
+
import type { StatefulView } from "./stateful-view.js";
|
|
9
|
+
import type { TabComponents } from "./tab-components.js";
|
|
10
|
+
import {
|
|
11
|
+
QuestionTabStrategy,
|
|
12
|
+
SubmitTabStrategy,
|
|
13
|
+
type TabContentStrategy,
|
|
14
|
+
} from "./tab-content-strategy.js";
|
|
15
|
+
|
|
16
|
+
export const HINT_PART_ENTER = "Enter to select";
|
|
17
|
+
export const HINT_PART_NAV = "↑/↓ to navigate";
|
|
18
|
+
export const HINT_PART_NEW_LINE = "Shift+Enter for newline";
|
|
19
|
+
export const HINT_PART_CLEAR = "Ctrl+U to clear";
|
|
20
|
+
export const HINT_PART_TOGGLE = "Space to toggle";
|
|
21
|
+
export const HINT_PART_NOTES = "n to add notes";
|
|
22
|
+
/** Replaces the add form once the tab already has a note. See `buildHintText`. */
|
|
23
|
+
export const HINT_PART_NOTES_EDIT = "n to edit notes";
|
|
24
|
+
export const HINT_PART_TAB = "Tab to switch questions";
|
|
25
|
+
export const HINT_PART_CANCEL = "Esc to cancel";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The collapse hint is templated because the shortcut is configurable. Call
|
|
29
|
+
* sites `.replace()` the placeholder with `formatKeySpecForDisplay` of the
|
|
30
|
+
* resolved key, so the hint always names the key that is actually bound.
|
|
31
|
+
*/
|
|
32
|
+
export const KEY_PLACEHOLDER = "{key}";
|
|
33
|
+
export const HINT_PART_COLLAPSE_TEMPLATE = `${KEY_PLACEHOLDER} to collapse`;
|
|
34
|
+
export const HINT_PART_EXPAND_TEMPLATE = `${KEY_PLACEHOLDER} to expand`;
|
|
35
|
+
/** The collapse template rendered with the default key, for hint assertions. */
|
|
36
|
+
export const HINT_PART_COLLAPSE = HINT_PART_COLLAPSE_TEMPLATE.replace(
|
|
37
|
+
KEY_PLACEHOLDER,
|
|
38
|
+
formatKeySpecForDisplay(DEFAULT_COLLAPSE_KEY),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The resting hint core for single-select question tabs. Resting means: notes
|
|
43
|
+
* closed and no custom-answer row capturing text. `buildHintText` drops NOTES
|
|
44
|
+
* in either of those states, and on a multiSelect tab it wedges TOGGLE between
|
|
45
|
+
* NAV and NOTES, so neither composite is a substring there — assert on the
|
|
46
|
+
* `HINT_PART_*` pieces in those cases instead.
|
|
47
|
+
*
|
|
48
|
+
* The collapse affordance is appended after cancel so the core stays a
|
|
49
|
+
* contiguous prefix of the rendered line. Narrow terminals clip the tail with
|
|
50
|
+
* `…` and keep the core.
|
|
51
|
+
*/
|
|
52
|
+
export const HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_CANCEL].join(
|
|
53
|
+
" · ",
|
|
54
|
+
);
|
|
55
|
+
export const HINT_MULTI = [
|
|
56
|
+
HINT_PART_ENTER,
|
|
57
|
+
HINT_PART_NAV,
|
|
58
|
+
HINT_PART_NOTES,
|
|
59
|
+
HINT_PART_TAB,
|
|
60
|
+
HINT_PART_CANCEL,
|
|
61
|
+
].join(" · ");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The whole footer while `state.collapsed` is true. The session renders this
|
|
65
|
+
* directly, bypassing `buildHintText`, and substitutes the configured key.
|
|
66
|
+
*/
|
|
67
|
+
export const COLLAPSED_HINT_TEMPLATE = [HINT_PART_EXPAND_TEMPLATE, HINT_PART_CANCEL].join(" · ");
|
|
68
|
+
|
|
69
|
+
export const REVIEW_HEADING = "Review your answers";
|
|
70
|
+
export const READY_PROMPT = "Ready to submit your answers?";
|
|
71
|
+
export const INCOMPLETE_WARNING_PREFIX = "⚠ Answer remaining questions before submitting:";
|
|
72
|
+
|
|
73
|
+
const OVERFLOW_UP = "↑";
|
|
74
|
+
const OVERFLOW_DOWN = "↓";
|
|
75
|
+
const OVERFLOW_BOTH = "↕";
|
|
76
|
+
|
|
77
|
+
/** Everything fits: pad after the footer so every tab occupies the same height. */
|
|
78
|
+
function renderFitsTerminal(natural: string[], spacerRows: number): string[] {
|
|
79
|
+
return spacerRows > 0 ? [...natural, ...Array<string>(spacerRows).fill("")] : natural;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** No room for any middle content — show the chrome alone, hard-clamped to the terminal. */
|
|
83
|
+
function renderChromeOnly(
|
|
84
|
+
natural: string[],
|
|
85
|
+
topFixed: number,
|
|
86
|
+
bottomFixed: number,
|
|
87
|
+
termRows: number,
|
|
88
|
+
): string[] {
|
|
89
|
+
const chromeOnly = [
|
|
90
|
+
...natural.slice(0, topFixed),
|
|
91
|
+
...natural.slice(natural.length - bottomFixed),
|
|
92
|
+
];
|
|
93
|
+
return chromeOnly.length > termRows ? chromeOnly.slice(0, termRows) : chromeOnly;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where the scroll window starts: centered on the focused option, clamped to
|
|
98
|
+
* the ends. Top-anchored when nothing is focused, which is the submit tab.
|
|
99
|
+
*/
|
|
100
|
+
function computeScrollStart(
|
|
101
|
+
bodyRange: [number, number] | undefined,
|
|
102
|
+
headingCount: number,
|
|
103
|
+
availableMiddle: number,
|
|
104
|
+
middleRows: number,
|
|
105
|
+
): number {
|
|
106
|
+
if (!bodyRange) return 0;
|
|
107
|
+
const focusedRowInMiddle = headingCount + bodyRange[0];
|
|
108
|
+
const focusedHeight = bodyRange[1] - bodyRange[0];
|
|
109
|
+
const idealStart =
|
|
110
|
+
focusedRowInMiddle - Math.floor(Math.max(0, availableMiddle - focusedHeight) / 2);
|
|
111
|
+
return Math.max(0, Math.min(idealStart, middleRows - availableMiddle));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mark the scroll window's edges. A one-row middle overflowing both ways gets
|
|
116
|
+
* the combined ↕: writing ↑ then ↓ to the same row would leave only the ↓ and
|
|
117
|
+
* hide the fact that there is anything above.
|
|
118
|
+
*/
|
|
119
|
+
function decorateOverflow(
|
|
120
|
+
scrollableMiddle: string[],
|
|
121
|
+
hasUp: boolean,
|
|
122
|
+
hasDown: boolean,
|
|
123
|
+
theme: Theme,
|
|
124
|
+
): void {
|
|
125
|
+
if (scrollableMiddle.length === 0) return;
|
|
126
|
+
if (hasUp && hasDown && scrollableMiddle.length === 1) {
|
|
127
|
+
scrollableMiddle[0] = theme.fg("dim", OVERFLOW_BOTH);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (hasUp) scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
|
|
131
|
+
if (hasDown) scrollableMiddle[scrollableMiddle.length - 1] = theme.fg("dim", OVERFLOW_DOWN);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type DialogState = QuestionnaireState;
|
|
135
|
+
|
|
136
|
+
/** Per-tick projection of dialog state. Written by the adapter, read in `render`. */
|
|
137
|
+
export interface DialogProps {
|
|
138
|
+
state: DialogState;
|
|
139
|
+
activePreviewPane: StatefulView<PreviewPaneProps>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Construction-time config. Frozen once the dialog exists. */
|
|
143
|
+
export interface DialogConfig {
|
|
144
|
+
theme: Theme;
|
|
145
|
+
questions: readonly QuestionData[];
|
|
146
|
+
tabBar: TabBar | undefined;
|
|
147
|
+
notesInput: Editor;
|
|
148
|
+
isMulti: boolean;
|
|
149
|
+
tabsByIndex: ReadonlyArray<TabComponents>;
|
|
150
|
+
/** Optional so single-question mode and focused tests can omit it; the submit strategy pads instead. */
|
|
151
|
+
submitPicker?: Component;
|
|
152
|
+
/** Worst-case body height across every tab and option. Sets the stable overall footprint. */
|
|
153
|
+
getBodyHeight: (width: number) => number;
|
|
154
|
+
/** Body height of the tab showing right now. The difference is absorbed outside the border. */
|
|
155
|
+
getCurrentBodyHeight: (width: number) => number;
|
|
156
|
+
/** Terminal height, read at render time — the mirror of the width getter. */
|
|
157
|
+
getTerminalRows: () => number;
|
|
158
|
+
/**
|
|
159
|
+
* Resolved collapse key (`"ctrl+]"`, `"alt+o"`, `"off"`). Construction-time
|
|
160
|
+
* config, deliberately not canonical state: the runtime's copy must never
|
|
161
|
+
* reach a `setProps` consumer. The footer interpolates it, and omits the
|
|
162
|
+
* collapse hint entirely when it is `"off"`.
|
|
163
|
+
*/
|
|
164
|
+
collapseKey: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The dialog frame. Owns the three-region layout and nothing else: a sticky
|
|
169
|
+
* heading, a scrolling middle and a sticky footer, assembled from whichever
|
|
170
|
+
* strategy the current tab selects.
|
|
171
|
+
*
|
|
172
|
+
* It is a `StatefulView` like every other renderable rather than a special
|
|
173
|
+
* case. `setProps` writes the cell that `render` reads, and
|
|
174
|
+
* `liveProps.activePreviewPane` is resolved by the adapter each tick — the
|
|
175
|
+
* dialog never derives it, and never reaches into a sibling component.
|
|
176
|
+
*/
|
|
177
|
+
export class DialogView implements StatefulView<DialogProps> {
|
|
178
|
+
private liveProps: DialogProps;
|
|
179
|
+
private readonly config: DialogConfig;
|
|
180
|
+
private readonly questionStrategy: TabContentStrategy;
|
|
181
|
+
private readonly submitStrategy: TabContentStrategy | undefined;
|
|
182
|
+
private readonly maxFooterRowCount: number;
|
|
183
|
+
|
|
184
|
+
constructor(config: DialogConfig, initialProps: DialogProps) {
|
|
185
|
+
this.config = config;
|
|
186
|
+
this.liveProps = initialProps;
|
|
187
|
+
this.questionStrategy = new QuestionTabStrategy({
|
|
188
|
+
theme: config.theme,
|
|
189
|
+
questions: config.questions,
|
|
190
|
+
getPreviewPane: () => this.liveProps.activePreviewPane,
|
|
191
|
+
tabsByIndex: config.tabsByIndex,
|
|
192
|
+
notesInput: config.notesInput,
|
|
193
|
+
isMulti: config.isMulti,
|
|
194
|
+
getCurrentBodyHeight: config.getCurrentBodyHeight,
|
|
195
|
+
collapseKey: config.collapseKey,
|
|
196
|
+
});
|
|
197
|
+
this.submitStrategy = config.isMulti
|
|
198
|
+
? new SubmitTabStrategy({
|
|
199
|
+
theme: config.theme,
|
|
200
|
+
questions: config.questions,
|
|
201
|
+
submitPicker: config.submitPicker,
|
|
202
|
+
notesInput: config.notesInput,
|
|
203
|
+
})
|
|
204
|
+
: undefined;
|
|
205
|
+
this.maxFooterRowCount = Math.max(
|
|
206
|
+
this.questionStrategy.footerRowCount,
|
|
207
|
+
this.submitStrategy?.footerRowCount ?? 0,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setProps(props: DialogProps): void {
|
|
212
|
+
this.liveProps = props;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
handleInput(_data: string): void {}
|
|
216
|
+
|
|
217
|
+
// No cached layout of its own. Refreshing is the adapter's job, which owns
|
|
218
|
+
// the full set of renderables.
|
|
219
|
+
invalidate(): void {}
|
|
220
|
+
|
|
221
|
+
render(width: number): string[] {
|
|
222
|
+
const state = this.liveProps.state;
|
|
223
|
+
const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
|
|
224
|
+
const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
|
|
225
|
+
|
|
226
|
+
// Built once and reused: constructing heading rows twice would build two
|
|
227
|
+
// sets of components for one frame.
|
|
228
|
+
const headingRowCache = strategy.headingRows(state);
|
|
229
|
+
const headingCount = headingRowCache.length;
|
|
230
|
+
|
|
231
|
+
// Without the residual spacer — whether it applies depends on overflow.
|
|
232
|
+
const natural = this.buildContainerFromStrategy(strategy, headingRowCache).render(width);
|
|
233
|
+
|
|
234
|
+
// Fixed regions, derived from structure rather than measured. `TabBar.render`
|
|
235
|
+
// always returns [tabLine, ""], so it is 2 rows whenever it is present.
|
|
236
|
+
const topFixed = 1 + (this.config.isMulti && this.config.tabBar ? 2 : 0) + 1;
|
|
237
|
+
const bottomFixed = 1 + strategy.footerRowCount;
|
|
238
|
+
const middleRows = natural.length - topFixed - bottomFixed;
|
|
239
|
+
|
|
240
|
+
// Keeps every tab the same total height, so switching tabs does not make
|
|
241
|
+
// the dialog jump. Only meaningful when nothing is being scrolled away.
|
|
242
|
+
//
|
|
243
|
+
// The resting-note term is here rather than folded into `bodyHeight`
|
|
244
|
+
// because the row lives below the body, in the slot the notes editor uses.
|
|
245
|
+
// Question tabs reserve it together, so this only ever levels a question
|
|
246
|
+
// tab against the submit tab, which never reserves one.
|
|
247
|
+
const maxRestingNoteRows = Math.max(
|
|
248
|
+
this.questionStrategy.restingNoteRowCount(state),
|
|
249
|
+
this.submitStrategy?.restingNoteRowCount(state) ?? 0,
|
|
250
|
+
);
|
|
251
|
+
const spacerRows = Math.max(
|
|
252
|
+
0,
|
|
253
|
+
this.config.getBodyHeight(width) +
|
|
254
|
+
this.maxFooterRowCount +
|
|
255
|
+
maxRestingNoteRows -
|
|
256
|
+
strategy.bodyHeight(width, state) -
|
|
257
|
+
strategy.footerRowCount -
|
|
258
|
+
strategy.restingNoteRowCount(state),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const termRows = this.config.getTerminalRows();
|
|
262
|
+
|
|
263
|
+
if (natural.length + spacerRows <= termRows) {
|
|
264
|
+
return renderFitsTerminal(natural, spacerRows);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const availableMiddle = Math.max(0, termRows - topFixed - bottomFixed);
|
|
268
|
+
if (availableMiddle === 0) {
|
|
269
|
+
return renderChromeOnly(natural, topFixed, bottomFixed, termRows);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const scrollStart = computeScrollStart(
|
|
273
|
+
strategy.focusedItemRowRange(width, state),
|
|
274
|
+
headingCount,
|
|
275
|
+
availableMiddle,
|
|
276
|
+
middleRows,
|
|
277
|
+
);
|
|
278
|
+
const scrollableMiddle = natural.slice(
|
|
279
|
+
topFixed + scrollStart,
|
|
280
|
+
topFixed + scrollStart + availableMiddle,
|
|
281
|
+
);
|
|
282
|
+
decorateOverflow(
|
|
283
|
+
scrollableMiddle,
|
|
284
|
+
scrollStart > 0,
|
|
285
|
+
scrollStart + availableMiddle < middleRows,
|
|
286
|
+
this.config.theme,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Exactly `termRows` rows: reaching here means `availableMiddle` was
|
|
290
|
+
// positive, so it is `termRows - topFixed - bottomFixed`, and the three
|
|
291
|
+
// slices add back up. The chrome-alone-too-tall case returned above, which
|
|
292
|
+
// is why there is no second clamp here.
|
|
293
|
+
return [
|
|
294
|
+
...natural.slice(0, topFixed),
|
|
295
|
+
...scrollableMiddle,
|
|
296
|
+
...natural.slice(natural.length - bottomFixed),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private buildContainerFromStrategy(
|
|
301
|
+
strategy: TabContentStrategy,
|
|
302
|
+
headingRowCache: Component[],
|
|
303
|
+
): Container {
|
|
304
|
+
const { theme, isMulti, tabBar } = this.config;
|
|
305
|
+
const state = this.liveProps.state;
|
|
306
|
+
const container = new Container();
|
|
307
|
+
const border = () => new DynamicBorder((s) => theme.fg("accent", s));
|
|
308
|
+
|
|
309
|
+
container.addChild(border());
|
|
310
|
+
if (isMulti && tabBar) container.addChild(tabBar);
|
|
311
|
+
container.addChild(new Spacer(1));
|
|
312
|
+
|
|
313
|
+
for (const c of headingRowCache) container.addChild(c);
|
|
314
|
+
container.addChild(strategy.bodyComponent(state));
|
|
315
|
+
container.addChild(new Spacer(1));
|
|
316
|
+
for (const c of strategy.midRows(state)) container.addChild(c);
|
|
317
|
+
|
|
318
|
+
container.addChild(border());
|
|
319
|
+
for (const c of strategy.footerRows(state)) container.addChild(c);
|
|
320
|
+
|
|
321
|
+
// The residual spacer lives in render(), not here: whether it applies
|
|
322
|
+
// depends on overflow, which the container cannot see.
|
|
323
|
+
return container;
|
|
324
|
+
}
|
|
325
|
+
}
|