pi-cc-extensions 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +21 -5
  2. package/assets/readme/welcome.webp +0 -0
  3. package/extensions/ask-user-question/LICENSE +21 -0
  4. package/extensions/ask-user-question/README.md +63 -0
  5. package/extensions/ask-user-question/ask-user-question.ts +244 -0
  6. package/extensions/ask-user-question/config.ts +75 -0
  7. package/extensions/ask-user-question/events.ts +55 -0
  8. package/extensions/ask-user-question/index.ts +17 -0
  9. package/extensions/ask-user-question/reconcile.ts +47 -0
  10. package/extensions/ask-user-question/rpc-fallback.ts +160 -0
  11. package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
  12. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  13. package/extensions/ask-user-question/state/key-router.ts +269 -0
  14. package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
  15. package/extensions/ask-user-question/state/row-intent.ts +143 -0
  16. package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
  17. package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
  18. package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
  19. package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
  20. package/extensions/ask-user-question/state/state-reducer.ts +282 -0
  21. package/extensions/ask-user-question/state/state.ts +49 -0
  22. package/extensions/ask-user-question/tool/format-answer.ts +29 -0
  23. package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
  24. package/extensions/ask-user-question/tool/types.ts +145 -0
  25. package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
  26. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  27. package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
  28. package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
  29. package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
  30. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
  32. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
  33. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
  34. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
  35. package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
  36. package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
  37. package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
  38. package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
  39. package/extensions/ask-user-question/view/props-adapter.ts +123 -0
  40. package/extensions/ask-user-question/view/stateful-view.ts +24 -0
  41. package/extensions/ask-user-question/view/tab-components.ts +16 -0
  42. package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
  43. package/package.json +14 -4
  44. package/themes/github-dark-default.json +91 -0
@@ -0,0 +1,291 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { renderInlineInputRow } from "./inline-input.js";
4
+
5
+ /**
6
+ * Row-intent discriminated union. `kind` is the single discriminator —
7
+ * pre-1.0.3 boolean flags have been removed (see `banned-flags.test.ts`).
8
+ * Modeled after `QuestionnaireAction` (`key-router.ts:13-32`) and `Effect`
9
+ * (`state-reducer.ts:26-32`) — pure literal-tagged variants, no shared base,
10
+ * exhaustive-`switch` enforcement via non-`void` returns.
11
+ *
12
+ * Variant semantics:
13
+ * - `option`: a regular author-defined option row.
14
+ * - `other`: the inline free-text input row appended to every question
15
+ * (label is "Type something."). Renders as inline `Input` when active.
16
+ * - `next`: the explicit commit-and-advance row appended to multi-select questions
17
+ * (label is "Next"). Renders without a number / checkbox.
18
+ */
19
+ export type WrappingSelectItem =
20
+ | { kind: "option"; label: string; description?: string }
21
+ | { kind: "other"; label: string; description?: string }
22
+ | { kind: "next"; label: string; description?: string };
23
+
24
+ export interface WrappingSelectTheme {
25
+ selectedText: (text: string) => string;
26
+ description: (text: string) => string;
27
+ scrollInfo: (text: string) => string;
28
+ }
29
+
30
+ /**
31
+ * Numbering controls.
32
+ *
33
+ * Use `numberStartOffset` + `totalItemsForNumbering` when a list is logically a slice of a
34
+ * larger numbered sequence — e.g. to start numbering at an offset and pad the column as
35
+ * if the list were part of a longer continuous numbered sequence.
36
+ */
37
+ export interface WrappingSelectOptions {
38
+ /** Start numbering at this offset + 1 (default 0 → rows labeled 1, 2, 3 …). */
39
+ numberStartOffset?: number;
40
+ /** Override the total used to pad the number column (useful when items span multiple lists). */
41
+ totalItemsForNumbering?: number;
42
+ }
43
+
44
+ export class WrappingSelect implements Component {
45
+ private static readonly ACTIVE_POINTER = "❯ ";
46
+ private static readonly INACTIVE_POINTER = " ";
47
+ private static readonly NUMBER_SEPARATOR = ". ";
48
+ private static readonly CONFIRMED_MARK = " ✔";
49
+ private static readonly MIN_CONTENT_WIDTH = 1;
50
+
51
+ private readonly items: readonly WrappingSelectItem[];
52
+ private readonly maxVisible: number;
53
+ private readonly theme: WrappingSelectTheme;
54
+ private numberStartOffset: number;
55
+ private totalItemsForNumbering: number;
56
+
57
+ private selectedIndex = 0;
58
+ private focused = true;
59
+ private inputBuffer = "";
60
+ private inputCursorOffset: number | undefined = undefined;
61
+ /**
62
+ * Index of the row that was previously confirmed for this list (e.g. the user's prior
63
+ * answer when re-entering a multi-question tab). Renders `<label> ✔` in the active-row
64
+ * styling but WITHOUT the `❯` pointer — pointer is reserved for the live cursor. When
65
+ * `selectedIndex === confirmedIndex && focused`, the active rendering wins (no double-mark).
66
+ */
67
+ private confirmedIndex: number | undefined = undefined;
68
+ /**
69
+ * When set together with `confirmedIndex`, replaces the row's static label at render time.
70
+ * Used for the `kind: "other"` sentinel — its label is "Type something." but if the user's
71
+ * prior answer was custom text, we render that text instead (e.g. `4. Hello ✔`).
72
+ */
73
+ private confirmedLabelOverride: string | undefined = undefined;
74
+
75
+ constructor(
76
+ items: readonly WrappingSelectItem[],
77
+ maxVisible: number,
78
+ theme: WrappingSelectTheme,
79
+ options: WrappingSelectOptions = {},
80
+ ) {
81
+ this.items = items;
82
+ this.maxVisible = Math.max(1, maxVisible);
83
+ this.theme = theme;
84
+ this.numberStartOffset = options.numberStartOffset ?? 0;
85
+ this.totalItemsForNumbering = options.totalItemsForNumbering ?? items.length;
86
+ }
87
+
88
+ /**
89
+ * Update the numbering offset + total padding width without rebuilding the component.
90
+ * Lets the host realign the number column when the underlying item set changes.
91
+ */
92
+ setNumbering(numberStartOffset: number, totalItemsForNumbering: number): void {
93
+ this.numberStartOffset = numberStartOffset;
94
+ this.totalItemsForNumbering = Math.max(1, totalItemsForNumbering);
95
+ }
96
+
97
+ setSelectedIndex(index: number): void {
98
+ this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
99
+ }
100
+
101
+ setFocused(focused: boolean): void {
102
+ this.focused = focused;
103
+ }
104
+
105
+ /**
106
+ * Mark a previously-confirmed row. Pass `undefined` to clear. `labelOverride` replaces
107
+ * the row's static `item.label` at render time — used for the `kind: "other"` sentinel so
108
+ * the row reads `Hello ✔` instead of `Type something. ✔` when the prior answer was custom
109
+ * text.
110
+ */
111
+ setConfirmedIndex(index: number | undefined, labelOverride?: string): void {
112
+ if (index === undefined) {
113
+ this.confirmedIndex = undefined;
114
+ this.confirmedLabelOverride = undefined;
115
+ return;
116
+ }
117
+ this.confirmedIndex = Math.max(0, Math.min(index, this.items.length - 1));
118
+ this.confirmedLabelOverride = labelOverride;
119
+ }
120
+
121
+ setInputBuffer(text: string): void {
122
+ this.inputBuffer = text;
123
+ }
124
+
125
+ /** Set the cursor offset for the inline input row. `undefined` → end-of-buffer fallback. */
126
+ setInputCursorOffset(offset: number | undefined): void {
127
+ this.inputCursorOffset = offset;
128
+ }
129
+
130
+ /** Intentionally empty — input is routed at the container level. */
131
+ handleInput(_data: string): void {}
132
+
133
+ invalidate(): void {}
134
+
135
+ render(width: number): string[] {
136
+ if (this.items.length === 0) return [];
137
+
138
+ const { startIndex, endIndex } = this.computeVisibleWindow();
139
+ const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
140
+ const lines: string[] = [];
141
+
142
+ for (let i = startIndex; i < endIndex; i++) {
143
+ const item = this.items[i];
144
+ if (!item) continue;
145
+ const isActive = i === this.selectedIndex && this.focused;
146
+ lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
147
+ }
148
+
149
+ if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
150
+ lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
151
+ }
152
+ return lines;
153
+ }
154
+
155
+ /**
156
+ * Returns the [startRow, endRow) range of the focused (selected) item within
157
+ * the output of `render(width)`. Computed by iterating the visible window and
158
+ * summing per-item row counts — O(maxVisible) per call.
159
+ */
160
+ focusedItemRowRange(width: number): [number, number] {
161
+ if (this.items.length === 0) return [0, 0];
162
+ const { startIndex, endIndex } = this.computeVisibleWindow();
163
+ const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
164
+ let row = 0;
165
+ for (let i = startIndex; i < endIndex; i++) {
166
+ const item = this.items[i];
167
+ if (!item) continue;
168
+ const isActive = i === this.selectedIndex && this.focused;
169
+ const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
170
+ if (i === this.selectedIndex) {
171
+ return [row, row + itemRowCount];
172
+ }
173
+ row += itemRowCount;
174
+ }
175
+ return [0, 1];
176
+ }
177
+
178
+ /**
179
+ * Per-item row count. Delegates to `renderItem().length` so `renderItem` remains
180
+ * the single source of truth for per-item row math — eliminates the prior shadow-copy
181
+ * that risked silent miscounts when new `kind` values branch in `renderItem` but not here.
182
+ */
183
+ private computeItemRowCount(
184
+ item: WrappingSelectItem,
185
+ index: number,
186
+ isActive: boolean,
187
+ width: number,
188
+ numberWidth: number,
189
+ ): number {
190
+ return this.renderItem(item, index, isActive, width, numberWidth).length;
191
+ }
192
+
193
+ private computeVisibleWindow(): { startIndex: number; endIndex: number } {
194
+ const half = Math.floor(this.maxVisible / 2);
195
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - half, this.items.length - this.maxVisible));
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(WrappingSelect.MIN_CONTENT_WIDTH, width - visibleWidth(rowPrefix));
214
+
215
+ if (this.shouldRenderAsInlineInput(item, isActive)) {
216
+ return this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth);
217
+ }
218
+
219
+ // Confirmed row gets a trailing ` ✔` and accent+bold styling; pointer is independent
220
+ // (still ❯ when active). When `index === confirmedIndex` AND `isActive`, both `❯` and
221
+ // `✔` appear on the same row — load-bearing for the case where the prior answer was
222
+ // row 0 (cursor resets to 0 on tab-back, so the confirmed row IS the active row).
223
+ // Optional `confirmedLabelOverride` replaces the static label (used for `kind: "other"`
224
+ // + `kind: "custom"` answer); the inline-input branch above still wins for `kind: "other" + isActive`.
225
+ const isConfirmed = index === this.confirmedIndex;
226
+ const label = isConfirmed
227
+ ? `${this.confirmedLabelOverride ?? item.label}${WrappingSelect.CONFIRMED_MARK}`
228
+ : item.label;
229
+ const applySelectedStyle = isActive || isConfirmed;
230
+
231
+ return [
232
+ ...this.renderLabelBlock(label, rowPrefix, continuationPrefix, contentWidth, applySelectedStyle),
233
+ ...this.renderDescriptionBlock(item.description, continuationPrefix, contentWidth),
234
+ ];
235
+ }
236
+
237
+ private buildRowPrefix(index: number, isActive: boolean, numberWidth: number): string {
238
+ const pointer = isActive ? WrappingSelect.ACTIVE_POINTER : WrappingSelect.INACTIVE_POINTER;
239
+ const displayNumber = this.numberStartOffset + index + 1;
240
+ const paddedNumber = String(displayNumber).padStart(numberWidth, " ");
241
+ return `${pointer}${paddedNumber}${WrappingSelect.NUMBER_SEPARATOR}`;
242
+ }
243
+
244
+ private shouldRenderAsInlineInput(item: WrappingSelectItem, isActive: boolean): boolean {
245
+ return item.kind === "other" && isActive;
246
+ }
247
+
248
+ /**
249
+ * Render the inline input row across one or more lines, wrapping at `contentWidth`.
250
+ * Delegates to the shared `renderInlineInputRow` helper (`./inline-input.ts`) so the
251
+ * single-select wrap path and the multi-select single-line path share one cursor-
252
+ * building core. The `multiline: true` path is byte-identical to the pre-extraction
253
+ * output (cursor rationale lives in `inline-input.ts`).
254
+ */
255
+ private renderInlineInputRow(rowPrefix: string, continuationPrefix: string, contentWidth: number): string[] {
256
+ return renderInlineInputRow({
257
+ buffer: this.inputBuffer,
258
+ cursorOffset: this.inputCursorOffset,
259
+ rowPrefix,
260
+ continuationPrefix,
261
+ contentWidth,
262
+ selectedText: this.theme.selectedText,
263
+ multiline: true,
264
+ });
265
+ }
266
+
267
+ private renderLabelBlock(
268
+ label: string,
269
+ rowPrefix: string,
270
+ continuationPrefix: string,
271
+ contentWidth: number,
272
+ applySelectedStyle: boolean,
273
+ ): string[] {
274
+ const wrapped = wrapTextWithAnsi(label, contentWidth);
275
+ return wrapped.map((segment, index) => {
276
+ const prefix = index === 0 ? rowPrefix : continuationPrefix;
277
+ const line = `${prefix}${segment}`;
278
+ return applySelectedStyle ? this.theme.selectedText(line) : line;
279
+ });
280
+ }
281
+
282
+ private renderDescriptionBlock(
283
+ description: string | undefined,
284
+ continuationPrefix: string,
285
+ contentWidth: number,
286
+ ): string[] {
287
+ if (!description) return [];
288
+ const wrapped = wrapTextWithAnsi(description, contentWidth);
289
+ return wrapped.map((segment) => `${continuationPrefix}${this.theme.description(segment)}`);
290
+ }
291
+ }
@@ -0,0 +1,205 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Container, type Input, Spacer } from "@earendil-works/pi-tui";
3
+ import type { QuestionnaireState } from "../state/state.js";
4
+ import type { QuestionData } from "../tool/types.js";
5
+ import type { PreviewPaneProps } from "./components/preview/preview-pane.js";
6
+ import type { TabBar } from "./components/tab-bar.js";
7
+ import type { StatefulView } from "./stateful-view.js";
8
+ import type { TabComponents } from "./tab-components.js";
9
+ import { QuestionTabStrategy, SubmitTabStrategy, type TabContentStrategy } from "./tab-content-strategy.js";
10
+
11
+ export const HINT_PART_ENTER = "Enter to select";
12
+ export const HINT_PART_NAV = "↑/↓ to navigate";
13
+ export const HINT_PART_TOGGLE = "Space to toggle";
14
+ export const HINT_PART_NOTES = "n to add notes";
15
+ export const HINT_PART_TAB = "Tab to switch questions";
16
+ export const HINT_PART_CANCEL = "Esc to cancel";
17
+ export const HINT_PART_COLLAPSE = "Ctrl+] to collapse";
18
+ export const HINT_PART_EXPAND = "Ctrl+] to expand";
19
+ /**
20
+ * `HINT_SINGLE` / `HINT_MULTI` are the resting core hint for NON-multiSelect
21
+ * question tabs only: `buildHintText` drops `NOTES` while the notes editor is
22
+ * open (`state.notesVisible`) or the "Type something." row is capturing text
23
+ * (`state.inputMode`), and on multiSelect tabs it interleaves `TOGGLE` between
24
+ * `NAV` and `NOTES`, so neither composite is a substring there — assert on
25
+ * `HINT_PART_*` constants in those states instead. The collapse affordance is
26
+ * appended AFTER cancel by `buildHintText` so the resting core stays a contiguous
27
+ * prefix substring of the rendered line. On narrow terminals the collapse tail
28
+ * clips with `…` (`OneLineClippedText`); the core is preserved.
29
+ */
30
+ export const HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_CANCEL].join(" · ");
31
+ export const HINT_MULTI = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_TAB, HINT_PART_CANCEL].join(
32
+ " · ",
33
+ );
34
+ /** Single-line footer shown by `QuestionnaireSession` when `state.collapsed === true`. Bypasses `buildHintText`. */
35
+ export const COLLAPSED_HINT = [HINT_PART_EXPAND, HINT_PART_CANCEL].join(" · ");
36
+ export const REVIEW_HEADING = "Review your answers";
37
+ export const READY_PROMPT = "Ready to submit your answers?";
38
+ export const INCOMPLETE_WARNING_PREFIX = "⚠ Answer remaining questions before submitting:";
39
+
40
+ const OVERFLOW_UP = "↑";
41
+ const OVERFLOW_DOWN = "↓";
42
+ const OVERFLOW_BOTH = "↕";
43
+
44
+ export type DialogState = QuestionnaireState;
45
+
46
+ /** Per-tick projection of dialog state. Written by the adapter; read by the strategy thunk. */
47
+ export interface DialogProps {
48
+ state: DialogState;
49
+ activePreviewPane: StatefulView<PreviewPaneProps>;
50
+ }
51
+
52
+ /** Construction-time config for `DialogView`. Frozen after construction. */
53
+ export interface DialogConfig {
54
+ theme: Theme;
55
+ questions: readonly QuestionData[];
56
+ tabBar: TabBar | undefined;
57
+ notesInput: Input;
58
+ isMulti: boolean;
59
+ tabsByIndex: ReadonlyArray<TabComponents>;
60
+ /** Optional so single-question mode and non-submit tests can omit it; SubmitTabStrategy falls back to Spacer rows. */
61
+ submitPicker?: Component;
62
+ /** Body height of the currently active tab/option. */
63
+ getCurrentBodyHeight: (width: number) => number;
64
+ /** Terminal height getter. Mirrors `getTerminalWidth` — reads `tui.terminal.rows` at render time. */
65
+ getTerminalRows: () => number;
66
+ }
67
+
68
+ /**
69
+ * The 7th renderable, promoted from a structural literal to a named class so
70
+ * all view-layer components share one explicit `implements StatefulView<P>`
71
+ * contract. `setProps(DialogProps)` writes the live cell read by the
72
+ * strategy thunk during `render()`. `liveProps.activePreviewPane` is a
73
+ * resolved pane reference threaded by the adapter per tick — the dialog
74
+ * itself does not derive it.
75
+ */
76
+ export class DialogView implements StatefulView<DialogProps> {
77
+ private liveProps: DialogProps;
78
+ private readonly config: DialogConfig;
79
+ private readonly questionStrategy: TabContentStrategy;
80
+ private readonly submitStrategy: TabContentStrategy | undefined;
81
+
82
+ constructor(config: DialogConfig, initialProps: DialogProps) {
83
+ this.config = config;
84
+ this.liveProps = initialProps;
85
+ this.questionStrategy = new QuestionTabStrategy({
86
+ theme: config.theme,
87
+ questions: config.questions,
88
+ getPreviewPane: () => this.liveProps.activePreviewPane,
89
+ tabsByIndex: config.tabsByIndex,
90
+ notesInput: config.notesInput,
91
+ isMulti: config.isMulti,
92
+ getCurrentBodyHeight: config.getCurrentBodyHeight,
93
+ });
94
+ this.submitStrategy = config.isMulti
95
+ ? new SubmitTabStrategy({
96
+ theme: config.theme,
97
+ questions: config.questions,
98
+ submitPicker: config.submitPicker,
99
+ })
100
+ : undefined;
101
+ }
102
+
103
+ setProps(props: DialogProps): void {
104
+ this.liveProps = props;
105
+ }
106
+
107
+ handleInput(_data: string): void {}
108
+
109
+ // Invalidation is driven by `QuestionnairePropsAdapter.invalidate()`, which
110
+ // owns the full set of renderables (binding registries + extras like
111
+ // `notesInput`). DialogView has no cached layout of its own.
112
+ invalidate(): void {}
113
+
114
+ render(width: number): string[] {
115
+ const state = this.liveProps.state;
116
+ const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
117
+ const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
118
+
119
+ // Cache heading rows (avoid double construction in render and container build).
120
+ const headingRowCache = strategy.headingRows(state);
121
+ const headingCount = headingRowCache.length;
122
+
123
+ // Render only the active tab's natural content; blank height equalization would
124
+ // unnecessarily consume transcript rows above this full-width editor component.
125
+ const natural = this.buildContainerFromStrategy(strategy, headingRowCache).render(width);
126
+
127
+ // Fixed region sizes (deterministic from structure).
128
+ // TabBar.render() returns [tabLine, ""] — always 2 rows.
129
+ const topFixed = 1 + (this.config.isMulti && this.config.tabBar ? 2 : 0) + 1;
130
+ const bottomFixed = 1 + strategy.footerRowCount;
131
+ const middleRows = natural.length - topFixed - bottomFixed;
132
+
133
+ const termRows = this.config.getTerminalRows();
134
+ if (natural.length <= termRows) return natural;
135
+
136
+ // OVERFLOW — apply 3-region partition with scroll-to-focus.
137
+ const availableMiddle = Math.max(0, termRows - topFixed - bottomFixed);
138
+ if (availableMiddle === 0) {
139
+ // Terminal too small for any middle content — show just chrome.
140
+ const chromeOnly = [...natural.slice(0, topFixed), ...natural.slice(natural.length - bottomFixed)];
141
+ return chromeOnly.length > termRows ? chromeOnly.slice(0, termRows) : chromeOnly;
142
+ }
143
+
144
+ const bodyRange = strategy.focusedItemRowRange(width, state);
145
+
146
+ // Compute scroll window centered on the focused option.
147
+ let scrollStart: number;
148
+ if (bodyRange) {
149
+ const focusedRowInMiddle = headingCount + bodyRange[0];
150
+ const focusedHeight = bodyRange[1] - bodyRange[0];
151
+ // Center the focused item vertically in the available middle space.
152
+ const idealStart = focusedRowInMiddle - Math.floor(Math.max(0, availableMiddle - focusedHeight) / 2);
153
+ scrollStart = Math.max(0, Math.min(idealStart, middleRows - availableMiddle));
154
+ } else {
155
+ // No interactive focus (submit tab) — top-anchor the middle.
156
+ scrollStart = 0;
157
+ }
158
+
159
+ const scrollableMiddle = natural.slice(topFixed + scrollStart, topFixed + scrollStart + availableMiddle);
160
+
161
+ const hasUp = scrollStart > 0;
162
+ const hasDown = scrollStart + availableMiddle < middleRows;
163
+ if (hasUp && hasDown && scrollableMiddle.length === 1) {
164
+ // Single-row middle: combined ↕ avoids the prior collision where ↓ overwrote ↑.
165
+ scrollableMiddle[0] = this.config.theme.fg("dim", OVERFLOW_BOTH);
166
+ } else {
167
+ if (hasUp && scrollableMiddle.length > 0) {
168
+ scrollableMiddle[0] = this.config.theme.fg("dim", OVERFLOW_UP);
169
+ }
170
+ if (hasDown && scrollableMiddle.length > 0) {
171
+ scrollableMiddle[scrollableMiddle.length - 1] = this.config.theme.fg("dim", OVERFLOW_DOWN);
172
+ }
173
+ }
174
+
175
+ const result = [
176
+ ...natural.slice(0, topFixed),
177
+ ...scrollableMiddle,
178
+ ...natural.slice(natural.length - bottomFixed),
179
+ ];
180
+ // Safety: never exceed terminal rows (covers the availableMiddle === 0 case
181
+ // where topFixed + bottomFixed > termRows).
182
+ return result.length > termRows ? result.slice(0, termRows) : result;
183
+ }
184
+
185
+ private buildContainerFromStrategy(strategy: TabContentStrategy, headingRowCache: Component[]): Container {
186
+ const { theme, isMulti, tabBar } = this.config;
187
+ const state = this.liveProps.state;
188
+ const container = new Container();
189
+ const border = () => new DynamicBorder((s) => theme.fg("accent", s));
190
+
191
+ container.addChild(border());
192
+ if (isMulti && tabBar) container.addChild(tabBar);
193
+ container.addChild(new Spacer(1));
194
+
195
+ for (const c of headingRowCache) container.addChild(c);
196
+ container.addChild(strategy.bodyComponent(state));
197
+ container.addChild(new Spacer(1));
198
+ for (const c of strategy.midRows(state)) container.addChild(c);
199
+
200
+ container.addChild(border());
201
+ for (const c of strategy.footerRows(state)) container.addChild(c);
202
+
203
+ return container;
204
+ }
205
+ }
@@ -0,0 +1,123 @@
1
+ import type { Input } from "@earendil-works/pi-tui";
2
+ import type { BindingContext, PerTabBindingContext } from "../state/selectors/contract.js";
3
+ import { selectActivePreviewPaneIndex } from "../state/selectors/derivations.js";
4
+ import { selectActiveView } from "../state/selectors/focus.js";
5
+ import type { QuestionnaireState } from "../state/state.js";
6
+ import type { QuestionData } from "../tool/types.js";
7
+ import type { BoundGlobalBinding, BoundPerTabBinding } from "./component-binding.js";
8
+ import type { WrappingSelectItem } from "./components/wrapping-select.js";
9
+ import type { TabComponents } from "./tab-components.js";
10
+
11
+ /** Cache-invalidation contract used by the adapter. `pi-tui` `Component` already satisfies it. */
12
+ interface Invalidatable {
13
+ invalidate(): void;
14
+ }
15
+
16
+ /**
17
+ * Reads pi-tui Input's private `cursor` field via type escape with full runtime validation.
18
+ * Returns `undefined` on any failure → graceful degradation to end-of-buffer cursor.
19
+ * Follow-up: replace with Input.getCursorOffset() when pi-tui exposes a public API.
20
+ */
21
+ function getInputCursorOffset(input: Input): number | undefined {
22
+ const raw = (input as unknown as { cursor?: unknown }).cursor;
23
+ if (typeof raw !== "number") return undefined;
24
+ if (!Number.isSafeInteger(raw)) return undefined;
25
+ const value = input.getValue();
26
+ if (raw < 0 || raw > value.length) return undefined;
27
+ return raw;
28
+ }
29
+
30
+ export interface QuestionnairePropsAdapterConfig {
31
+ tui: { requestRender(): void };
32
+ questions: readonly QuestionData[];
33
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
34
+ tabsByIndex: ReadonlyArray<TabComponents>;
35
+ inlineInput: Input;
36
+ globalBindings: ReadonlyArray<BoundGlobalBinding>;
37
+ perTabBindings: ReadonlyArray<BoundPerTabBinding>;
38
+ /**
39
+ * Renderables not reached by the binding registries (e.g. the notes
40
+ * `Input`, which is typed into directly and has no props). Walked by
41
+ * `invalidate()` after the binding-driven components.
42
+ */
43
+ extraInvalidatables?: ReadonlyArray<Invalidatable>;
44
+ }
45
+
46
+ /**
47
+ * View fan-out: drives every component setter from the canonical state via
48
+ * two binding registries. `globalBindings` covers the cross-tab components
49
+ * (dialog, submitPicker?, tabBar?); `perTabBindings` covers the
50
+ * per-tab kinds (optionList, preview, multiSelect?). The hand-coded fan-out
51
+ * collapses to one global loop + one nested per-tab loop. The inline-Other
52
+ * value is read from the headless `inlineInput` instance per tick into ctx so
53
+ * `selectOptionListProps` sees the live value.
54
+ */
55
+ export class QuestionnairePropsAdapter {
56
+ private readonly tui: QuestionnairePropsAdapterConfig["tui"];
57
+ private readonly questions: readonly QuestionData[];
58
+ private readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
59
+ private readonly tabsByIndex: ReadonlyArray<TabComponents>;
60
+ private readonly inlineInput: Input;
61
+ private readonly globalBindings: ReadonlyArray<BoundGlobalBinding>;
62
+ private readonly perTabBindings: ReadonlyArray<BoundPerTabBinding>;
63
+ private readonly extraInvalidatables: ReadonlyArray<Invalidatable>;
64
+
65
+ constructor(config: QuestionnairePropsAdapterConfig) {
66
+ this.tui = config.tui;
67
+ this.questions = config.questions;
68
+ this.itemsByTab = config.itemsByTab;
69
+ this.tabsByIndex = config.tabsByIndex;
70
+ this.inlineInput = config.inlineInput;
71
+ this.globalBindings = config.globalBindings;
72
+ this.perTabBindings = config.perTabBindings;
73
+ this.extraInvalidatables = config.extraInvalidatables ?? [];
74
+ }
75
+
76
+ apply(state: QuestionnaireState): void {
77
+ const totalQuestions = this.questions.length;
78
+ const activeView = selectActiveView(state, totalQuestions);
79
+ const paneIndex = selectActivePreviewPaneIndex(state.currentTab, totalQuestions);
80
+ const activePreviewPane = this.tabsByIndex[paneIndex]?.preview ?? this.tabsByIndex[0]!.preview;
81
+
82
+ const ctx: BindingContext = {
83
+ questions: this.questions,
84
+ itemsByTab: this.itemsByTab,
85
+ totalQuestions,
86
+ activeView,
87
+ inputBuffer: this.inlineInput.getValue(),
88
+ inputCursorOffset: getInputCursorOffset(this.inlineInput),
89
+ activePreviewPane,
90
+ };
91
+
92
+ for (const binding of this.globalBindings) {
93
+ binding.apply(state, ctx);
94
+ }
95
+
96
+ for (let i = 0; i < this.tabsByIndex.length; i++) {
97
+ const tab = this.tabsByIndex[i]!;
98
+ const tabCtx: PerTabBindingContext = { ...ctx, tab, i };
99
+ for (const binding of this.perTabBindings) {
100
+ binding.apply(state, tabCtx);
101
+ }
102
+ }
103
+
104
+ this.tui.requestRender();
105
+ }
106
+
107
+ /**
108
+ * Invalidates every owned renderable. Called by the session in place of
109
+ * the old `dialog.invalidate()` forwarding chain — DialogView no longer
110
+ * reaches into siblings (tabBar, notesInput, activePreviewPane).
111
+ * Iterates the same registries used by `apply()` plus
112
+ * `extraInvalidatables` for components outside the binding system.
113
+ */
114
+ invalidate(): void {
115
+ for (const b of this.globalBindings) b.invalidate();
116
+ for (const tab of this.tabsByIndex) {
117
+ tab.optionList.invalidate();
118
+ tab.preview.invalidate();
119
+ tab.multiSelect?.invalidate();
120
+ }
121
+ for (const x of this.extraInvalidatables) x.invalidate();
122
+ }
123
+ }
@@ -0,0 +1,24 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Generic prop-driven component contract. Every renderable owns its own `P` shape;
5
+ * the adapter computes `P` from canonical state via per-component selectors and
6
+ * pushes it via `setProps`. `focused: boolean` is a field on `P` only where the
7
+ * component needs it.
8
+ */
9
+ export interface StatefulView<P> extends Component {
10
+ setProps(props: P): void;
11
+ }
12
+
13
+ /**
14
+ * Discriminated focus union — encodes the three-cell focus invariant
15
+ * (`notesVisible`, submit-tab, options). Dispatcher cascade (`key-router.ts`)
16
+ * and reducer's defensive clears (`state-reducer.ts`) enforce mutual exclusion;
17
+ * this type makes it explicit so per-component `focused: boolean` flags
18
+ * derive from one equality check against this discriminant rather than
19
+ * parallel boolean reads.
20
+ *
21
+ * Priority order: notes > submit > options. Matches the dispatcher
22
+ * cascade exactly so the union is observably equivalent to today's reads.
23
+ */
24
+ export type ActiveView = "notes" | "options" | "submit";
@@ -0,0 +1,16 @@
1
+ import type { MultiSelectView } from "./components/multi-select-view.js";
2
+ import type { OptionListViewProps } from "./components/option-list-view.js";
3
+ import type { PreviewPane } from "./components/preview/preview-pane.js";
4
+ import type { StatefulView } from "./stateful-view.js";
5
+
6
+ export interface TabBodyHeights {
7
+ current: number;
8
+ max: number;
9
+ }
10
+
11
+ export interface TabComponents {
12
+ optionList: StatefulView<OptionListViewProps>;
13
+ preview: PreviewPane;
14
+ multiSelect?: MultiSelectView;
15
+ bodyHeights: (width: number) => TabBodyHeights;
16
+ }