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,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
+ }