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,219 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { QuestionData } from "../../../tool/types.js";
3
+ import type { WrappingSelectItem } from "../wrapping-select.js";
4
+ import {
5
+ BORDER_HORIZONTAL_OVERHEAD,
6
+ BORDER_INNER_PADDING_HORIZONTAL,
7
+ } from "./preview-box-renderer.js";
8
+
9
+ /** Min terminal/pane width for the side-by-side layout to engage. */
10
+ export const PREVIEW_MIN_WIDTH = 100;
11
+ /** Visual gap between options column and preview column in side-by-side. */
12
+ export const PREVIEW_COLUMN_GAP = 2;
13
+ /** 1 col padding inside the preview column (between gap and `│`). */
14
+ export const PREVIEW_PADDING_LEFT = 1;
15
+ /** Empty rows between options and preview blocks in stacked (narrow) layout. */
16
+ export const STACKED_GAP_ROWS = 1;
17
+
18
+ /** Floor for the adaptive left column width — prevents collapse on short labels. */
19
+ export const MIN_LEFT = 30;
20
+ /** Ceiling ratio: left column never exceeds this fraction of pane width. */
21
+ export const MAX_LEFT_RATIO = 0.5;
22
+ /** Floor for the preview column width — prevents right-side collapse on narrow terminals. */
23
+ export const MIN_PREVIEW_WIDTH = 45;
24
+ /** visibleWidth(" ✔") = 2 (space + ✔ codepoint). Reserved on the longest-label measurement
25
+ * so a confirmed row never gets truncated when MIN_LEFT clamps the column. */
26
+ export const CONFIRMED_OVERHEAD = 2;
27
+
28
+ export type PreviewLayoutMode = "side-by-side" | "stacked";
29
+
30
+ /**
31
+ * Decide layout mode from terminal + pane widths. Pure of inputs.
32
+ *
33
+ * The terminal-width gate is the AND check from the previous `preview-pane.ts` —
34
+ * lifted here so the decision is computed ONCE per render and threaded explicitly
35
+ * through `previewBlockHeight`. Removes the bug class where `previewBlockHeight`
36
+ * re-derived `sideBySide` from a column width (already < pane width post-split),
37
+ * capping height too short.
38
+ */
39
+ export function decideLayout(terminalWidth: number, paneWidth: number): PreviewLayoutMode {
40
+ return terminalWidth >= PREVIEW_MIN_WIDTH && paneWidth >= PREVIEW_MIN_WIDTH
41
+ ? "side-by-side"
42
+ : "stacked";
43
+ }
44
+
45
+ /**
46
+ * Compute the adaptive left column width from option labels.
47
+ * Pure function — deterministic for a given (items, totalForNumbering, paneWidth).
48
+ *
49
+ * Pipeline:
50
+ * 1. Measure: longest visible label width + prefix overhead + confirmed-mark overhead
51
+ * 2. Clamp: floor MIN_LEFT, ceiling paneWidth * MAX_LEFT_RATIO
52
+ * 3. Safety net: never exceed available = paneWidth - GAP - MIN_PREVIEW_WIDTH
53
+ */
54
+ export function adaptiveLeftWidth(
55
+ items: readonly WrappingSelectItem[],
56
+ totalForNumbering: number,
57
+ paneWidth: number,
58
+ ): number {
59
+ const prefixW = String(Math.max(1, totalForNumbering)).length + 4; // digits + "❯ " + ". "
60
+ const confirmedOverhead = CONFIRMED_OVERHEAD; // visibleWidth(" ✔") = 2 (space + ✔ codepoint)
61
+ let maxLabel = 0;
62
+ for (const item of items) {
63
+ const w = visibleWidth(item.label);
64
+ if (w > maxLabel) maxLabel = w;
65
+ }
66
+ const desired = maxLabel + prefixW + confirmedOverhead;
67
+ const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
68
+ const available = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
69
+ return Math.max(MIN_LEFT, Math.min(ratioCapped, Math.max(1, available)));
70
+ }
71
+
72
+ /**
73
+ * Cross-tab maximum left-column width. Aggregates `adaptiveLeftWidth` over every tab
74
+ * and returns the widest result, so the options column stays stable on tab switch.
75
+ *
76
+ * Pure function — `tabs.length` MUST equal `itemsByTab.length`. Numbering uses
77
+ * `items.length` for every tab (the chat row slot that once added +1 on single-select
78
+ * has been removed).
79
+ * Floor is `MIN_LEFT` so an all-empty input still produces a usable column.
80
+ */
81
+ export function crossTabMaxLeftWidth(
82
+ tabs: ReadonlyArray<{ multiSelect?: boolean }>,
83
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>,
84
+ paneWidth: number,
85
+ ): number {
86
+ let max = MIN_LEFT;
87
+ for (let i = 0; i < tabs.length; i++) {
88
+ const items = itemsByTab[i] ?? [];
89
+ const totalForNumbering = items.length;
90
+ const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
91
+ if (tabWidth > max) max = tabWidth;
92
+ }
93
+ return max;
94
+ }
95
+
96
+ /**
97
+ * Source-line probe: measures the widest source line across all options' previews.
98
+ * Returns 0 when no option carries a preview.
99
+ *
100
+ * V1 heuristic — works well for tree/table/heading/list content where source-line
101
+ * width ≈ rendered width. Paragraphs overstate (source width "wants" the full pane);
102
+ * the worst case is "donation does nothing useful" — fallback to the label-driven path.
103
+ * Upgrade path: replace with a render-width probe that invokes Markdown at a probe width.
104
+ *
105
+ * Pure function — O(total chars) per question; no Markdown invocation, no cache interaction.
106
+ */
107
+ export function previewSourceWidth(question: QuestionData): number {
108
+ let max = 0;
109
+ for (const option of question.options) {
110
+ const text = option.preview;
111
+ if (!text) continue;
112
+ for (const line of text.split("\n")) {
113
+ const w = visibleWidth(line);
114
+ if (w > max) max = w;
115
+ }
116
+ }
117
+ return max;
118
+ }
119
+
120
+ /**
121
+ * Cross-tab/cross-option preview budget. Iterates all questions, computes each question's
122
+ * preview appetite via `previewSourceWidth`, adds box + padding overhead (5 cols), and
123
+ * returns the widest result. Floor is `MIN_PREVIEW_WIDTH` so a previewless question still
124
+ * reserves a usable column. Ceiling ensures the left column retains its `MIN_LEFT` floor.
125
+ *
126
+ * Pure function — same cross-tab max pattern as `crossTabMaxLeftWidth`.
127
+ */
128
+ export function crossTabPreviewBudget(
129
+ questions: readonly QuestionData[],
130
+ paneWidth: number,
131
+ ): number {
132
+ let max = MIN_PREVIEW_WIDTH;
133
+ for (const question of questions) {
134
+ const rawWidth = previewSourceWidth(question);
135
+ const capped = Math.min(rawWidth, paneWidth - PREVIEW_COLUMN_GAP - MIN_LEFT);
136
+ const budget =
137
+ capped +
138
+ BORDER_HORIZONTAL_OVERHEAD +
139
+ 2 * BORDER_INNER_PADDING_HORIZONTAL +
140
+ PREVIEW_PADDING_LEFT;
141
+ if (budget > max) max = budget;
142
+ }
143
+ return max;
144
+ }
145
+
146
+ /**
147
+ * Cross-tab left-column width with slack donation. Combines the label-driven width
148
+ * (from `crossTabMaxLeftWidth`) with the slack donated by narrow previews.
149
+ *
150
+ * Pipeline:
151
+ * 1. `labelDriven` = `crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth)`
152
+ * 2. `previewBudget` = `crossTabPreviewBudget(questions, paneWidth)`
153
+ * 3. `slackDonation` = `paneWidth − GAP − previewBudget`
154
+ * 4. Return `min(max(labelDriven, slackDonation), ceiling)` where `ceiling` is the
155
+ * tighter of the preview-width safety limit and `MAX_LEFT_RATIO`
156
+ *
157
+ * Invariants (hold at side-by-side widths, paneWidth ≥ PREVIEW_MIN_WIDTH — the only
158
+ * mode that consumes this value; narrower panes can pull both ceilings under MIN_LEFT):
159
+ * - Floor: result ≥ MIN_LEFT (ratioCeiling ≥ 50 once paneWidth ≥ 100)
160
+ * - Options cap: left column ≤ paneWidth × MAX_LEFT_RATIO
161
+ * - Preview floor: right column ≥ MIN_PREVIEW_WIDTH — enforced transitively by the
162
+ * MAX_LEFT_RATIO cap (right column keeps ≥ paneWidth/2 − GAP ≥ 48 cols);
163
+ * previewSafetyCeiling is defensive depth that binds only if MAX_LEFT_RATIO
164
+ * ever rises above ~0.53
165
+ * - Cross-tab stability: both reductions are tab-independent
166
+ * - Determinism: pure of (questions, itemsByTab, paneWidth)
167
+ *
168
+ * `crossTabMaxLeftWidth` is NOT replaced — it continues to exist as the primitive.
169
+ * Donation obeys the same `MAX_LEFT_RATIO` cap as the label-driven path; the preview
170
+ * composer right-aligns narrower boxes inside the remaining column.
171
+ */
172
+ export function crossTabLeftWidthWithDonation(
173
+ tabs: ReadonlyArray<{ multiSelect?: boolean }>,
174
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>,
175
+ questions: readonly QuestionData[],
176
+ paneWidth: number,
177
+ ): number {
178
+ const labelDriven = crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth);
179
+ const previewBudget = crossTabPreviewBudget(questions, paneWidth);
180
+ const slackDonation = paneWidth - PREVIEW_COLUMN_GAP - previewBudget;
181
+ // At side-by-side widths the ratio ceiling is always the tighter bound
182
+ // (paneWidth/2 ≤ paneWidth − GAP − MIN_PREVIEW_WIDTH once paneWidth ≥ 94);
183
+ // previewSafetyCeiling stays as defensive depth should MAX_LEFT_RATIO grow.
184
+ const previewSafetyCeiling = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
185
+ const ratioCeiling = Math.floor(paneWidth * MAX_LEFT_RATIO);
186
+ const ceiling = Math.min(previewSafetyCeiling, ratioCeiling);
187
+ return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
188
+ }
189
+
190
+ /**
191
+ * Width allocation for side-by-side mode.
192
+ * `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
193
+ * cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
194
+ * extreme inputs.
195
+ */
196
+ export function columnWidths(
197
+ paneWidth: number,
198
+ adaptiveLeft: number,
199
+ ): { leftWidth: number; rightWidth: number; gap: number } {
200
+ const gap = PREVIEW_COLUMN_GAP;
201
+ const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
202
+ const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
203
+ return { leftWidth, rightWidth, gap };
204
+ }
205
+
206
+ /**
207
+ * Returns the widths actually passed to `options.render` and `previewLines` inside
208
+ * `render()`. Stacked uses the full pane width for both; side-by-side splits via
209
+ * `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
210
+ */
211
+ export function bodyWidths(
212
+ paneWidth: number,
213
+ mode: PreviewLayoutMode,
214
+ adaptiveLeft: number,
215
+ ): { optionsWidth: number; previewWidth: number } {
216
+ if (mode === "stacked") return { optionsWidth: paneWidth, previewWidth: paneWidth };
217
+ const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
218
+ return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
219
+ }
@@ -0,0 +1,240 @@
1
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { QuestionData } from "../../../tool/types.js";
3
+ import type { StatefulView } from "../../stateful-view.js";
4
+ import type { OptionListView } from "../option-list-view.js";
5
+ import type { PreviewBlockRenderer } from "./preview-block-renderer.js";
6
+ import {
7
+ bodyWidths,
8
+ columnWidths,
9
+ decideLayout,
10
+ PREVIEW_PADDING_LEFT,
11
+ type PreviewLayoutMode,
12
+ STACKED_GAP_ROWS,
13
+ } from "./preview-layout-decider.js";
14
+
15
+ // ----- Re-exports for test imports — keep `./preview-pane.js` as the public surface -----
16
+ export {
17
+ MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
18
+ MAX_PREVIEW_HEIGHT_STACKED,
19
+ NO_PREVIEW_TEXT,
20
+ NOTES_AFFORDANCE_OVERHEAD,
21
+ } from "./markdown-content-cache.js";
22
+ export { NOTES_AFFORDANCE_TEXT, PreviewBlockRenderer } from "./preview-block-renderer.js";
23
+ export {
24
+ BORDER_HORIZONTAL_OVERHEAD,
25
+ BORDER_INNER_PADDING_HORIZONTAL,
26
+ BORDER_VERTICAL_OVERHEAD,
27
+ BOX_MIN_CONTENT_WIDTH,
28
+ renderBorderedBox,
29
+ stripFenceMarkers,
30
+ } from "./preview-box-renderer.js";
31
+ export {
32
+ PREVIEW_COLUMN_GAP,
33
+ PREVIEW_MIN_WIDTH,
34
+ PREVIEW_PADDING_LEFT,
35
+ STACKED_GAP_ROWS,
36
+ } from "./preview-layout-decider.js";
37
+
38
+ /**
39
+ * Per-tick projection of PreviewPane state. Replaces the prior
40
+ * `setNotesVisible(boolean)` sliver-setter and the sibling reads of
41
+ * `optionListView.getSelectedIndex()` / `isFocused()`. The pane now reads
42
+ * `selectedIndex` and `focused` from its own props — both derived from
43
+ * canonical state via `selectPreviewPaneProps`. `OptionListView` and
44
+ * `PreviewPane` see the same source of truth without the cross-component
45
+ * live read.
46
+ */
47
+ export interface PreviewPaneProps {
48
+ notesVisible: boolean;
49
+ selectedIndex: number;
50
+ focused: boolean;
51
+ /**
52
+ * True while the "other" (custom-answer) row is focused and accepting input
53
+ * (`state.inputMode`, set by the reducer via `ROW_INTENT_META.other.activatesInputMode`).
54
+ * Surfaced by `selectPreviewPaneProps` from canonical state; drives the full-width
55
+ * early-returns below so the inline input isn't cramped into the narrow left column.
56
+ */
57
+ inputMode: boolean;
58
+ }
59
+
60
+ export interface PreviewPaneConfig {
61
+ question: QuestionData;
62
+ getTerminalWidth: () => number;
63
+ optionListView: OptionListView;
64
+ previewBlock: PreviewBlockRenderer;
65
+ }
66
+
67
+ /**
68
+ * Thin layout composer. Receives `selectedIndex` / `focused` / `notesVisible`
69
+ * via `setProps` per tick (computed by `selectPreviewPaneProps` from canonical
70
+ * state). Delegates option-side rendering to `OptionListView` (which still
71
+ * owns its render-time state — input buffer, confirmedIndex) and preview-side
72
+ * rendering to `PreviewBlockRenderer` (which owns the markdown cache and
73
+ * bordered-box composition).
74
+ *
75
+ * `naturalHeight` and `maxNaturalHeight` query both children's heights; `render`
76
+ * combines them via `decideLayout` (mode threaded into both calls — never
77
+ * re-derived).
78
+ */
79
+ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
80
+ private readonly question: QuestionData;
81
+ private readonly getTerminalWidth: () => number;
82
+ private readonly optionListView: OptionListView;
83
+ private readonly previewBlock: PreviewBlockRenderer;
84
+ private props: PreviewPaneProps;
85
+ /**
86
+ * Cross-tab max left-width getter. Set exactly once by `buildQuestionnaire.injectGlobalLeftWidth`
87
+ * before any render. Initialized to a throwing sentinel so missing injection is a hard fail
88
+ * rather than a silent fallback to a magic constant — render is illegal until injected.
89
+ */
90
+ private globalLeftWidth: (paneWidth: number) => number = () => {
91
+ throw new Error("PreviewPane.setGlobalLeftWidth must be called before render()");
92
+ };
93
+
94
+ constructor(config: PreviewPaneConfig) {
95
+ this.question = config.question;
96
+ this.getTerminalWidth = config.getTerminalWidth;
97
+ this.optionListView = config.optionListView;
98
+ this.previewBlock = config.previewBlock;
99
+ this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
100
+ }
101
+
102
+ setGlobalLeftWidth(getter: (paneWidth: number) => number): void {
103
+ this.globalLeftWidth = getter;
104
+ }
105
+
106
+ private getAdaptiveLeft(paneWidth: number): number {
107
+ return this.globalLeftWidth(paneWidth);
108
+ }
109
+
110
+ setProps(props: PreviewPaneProps): void {
111
+ this.props = props;
112
+ }
113
+
114
+ handleInput(_data: string): void {}
115
+
116
+ invalidate(): void {
117
+ this.previewBlock.invalidate();
118
+ this.optionListView.invalidate();
119
+ }
120
+
121
+ render(width: number): string[] {
122
+ if (this.question.multiSelect === true) return this.optionListView.render(width);
123
+ // Spec: hide the preview pane entirely when no option carries a `preview`.
124
+ if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width);
125
+ // `inputMode` (typing on the "other" custom-answer row): the preview is irrelevant —
126
+ // the row sits at index `options.length`, out of bounds for any option's preview — so
127
+ // render the option list at the full pane width instead of the cramped left column.
128
+ // Side-by-side + preview block resume verbatim on nav-away (inputMode clears).
129
+ if (this.props.inputMode) return this.optionListView.render(width);
130
+
131
+ const mode = decideLayout(this.getTerminalWidth(), width);
132
+ if (mode === "side-by-side") return this.renderSideBySide(width, mode);
133
+
134
+ // Stacked: options + blank gap + preview block.
135
+ return [
136
+ ...this.optionListView.render(width),
137
+ ...Array(STACKED_GAP_ROWS).fill(""),
138
+ ...this.previewBlock.renderBlock(
139
+ width,
140
+ this.props.selectedIndex,
141
+ mode,
142
+ this.props.focused,
143
+ this.props.notesVisible,
144
+ ),
145
+ ];
146
+ }
147
+
148
+ focusedItemRowRange(width: number): [number, number] {
149
+ if (this.question.multiSelect === true) return this.optionListView.focusedItemRowRange(width);
150
+ if (!this.previewBlock.hasAnyPreview()) return this.optionListView.focusedItemRowRange(width);
151
+ // `inputMode`: compute the focused row range against the FULL pane width (not the
152
+ // side-by-side leftWidth), mirroring `render`'s full-width option list.
153
+ if (this.props.inputMode) return this.optionListView.focusedItemRowRange(width);
154
+ const mode = decideLayout(this.getTerminalWidth(), width);
155
+ if (mode === "stacked") return this.optionListView.focusedItemRowRange(width);
156
+ const adaptiveLeft = this.getAdaptiveLeft(width);
157
+ const { leftWidth } = columnWidths(width, adaptiveLeft);
158
+ return this.optionListView.focusedItemRowRange(leftWidth);
159
+ }
160
+
161
+ naturalHeight(width: number): number {
162
+ if (this.question.multiSelect === true) return this.optionListView.render(width).length;
163
+ if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width).length;
164
+ // `inputMode`: height is the full-width option list only (no preview block) — preserves
165
+ // the `naturalHeight === render.length` parity invariant.
166
+ if (this.props.inputMode) return this.optionListView.render(width).length;
167
+ const mode = decideLayout(this.getTerminalWidth(), width);
168
+ const adaptiveLeft = this.getAdaptiveLeft(width);
169
+ const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
170
+ const optionsHeight = this.optionListView.render(optionsWidth).length;
171
+ const previewBlockHeight = this.previewBlock.blockHeight(
172
+ previewWidth,
173
+ this.props.selectedIndex,
174
+ mode,
175
+ );
176
+ if (mode === "side-by-side") return Math.max(optionsHeight, previewBlockHeight);
177
+ return optionsHeight + STACKED_GAP_ROWS + previewBlockHeight;
178
+ }
179
+
180
+ maxNaturalHeight(width: number): number {
181
+ if (this.question.multiSelect === true) return this.optionListView.render(width).length;
182
+ if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width).length;
183
+ // `inputMode`: like naturalHeight — full-width option list only, so the
184
+ // `maxNaturalHeight >= naturalHeight` parity invariant holds (both equal the list height).
185
+ if (this.props.inputMode) return this.optionListView.render(width).length;
186
+ const mode = decideLayout(this.getTerminalWidth(), width);
187
+ const adaptiveLeft = this.getAdaptiveLeft(width);
188
+ const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
189
+ const optionsHeight = this.optionListView.render(optionsWidth).length;
190
+ let maxPreviewBlock = 0;
191
+ for (let i = 0; i < this.question.options.length; i++) {
192
+ const h = this.previewBlock.blockHeight(previewWidth, i, mode);
193
+ if (h > maxPreviewBlock) maxPreviewBlock = h;
194
+ }
195
+ if (mode === "side-by-side") return Math.max(optionsHeight, maxPreviewBlock);
196
+ return optionsHeight + STACKED_GAP_ROWS + maxPreviewBlock;
197
+ }
198
+
199
+ private renderSideBySide(width: number, mode: PreviewLayoutMode): string[] {
200
+ const adaptiveLeft = this.getAdaptiveLeft(width);
201
+ const { leftWidth, rightWidth, gap } = columnWidths(width, adaptiveLeft);
202
+ const leftLines = this.optionListView.render(leftWidth);
203
+ const rightLines = this.renderPaddedPreviewLines(rightWidth, mode);
204
+ const rows = Math.max(leftLines.length, rightLines.length);
205
+ const gapStr = " ".repeat(gap);
206
+ const out: string[] = [];
207
+ for (let i = 0; i < rows; i++) {
208
+ const leftRaw = leftLines[i] ?? "";
209
+ const rightRaw = rightLines[i] ?? "";
210
+ const leftClamped = truncateToWidth(leftRaw, leftWidth, "");
211
+ const leftPad = " ".repeat(Math.max(0, leftWidth - visibleWidth(leftClamped)));
212
+ const joined = `${leftClamped}${leftPad}${gapStr}${rightRaw}`;
213
+ out.push(truncateToWidth(joined, width, ""));
214
+ }
215
+ return out;
216
+ }
217
+
218
+ private renderPaddedPreviewLines(colWidth: number, mode: PreviewLayoutMode): string[] {
219
+ const inner = Math.max(1, colWidth - PREVIEW_PADDING_LEFT);
220
+ const contentLines = this.previewBlock.renderBlock(
221
+ inner,
222
+ this.props.selectedIndex,
223
+ mode,
224
+ this.props.focused,
225
+ this.props.notesVisible,
226
+ );
227
+ const boxWidth = Math.max(1, visibleWidth(contentLines[0] ?? ""));
228
+ const boxAlignedPad = Math.max(PREVIEW_PADDING_LEFT, colWidth - boxWidth);
229
+ return contentLines.map((line) => {
230
+ if (line === "") return "";
231
+ // A line wider than the box (a long-locale notes affordance) slides left to
232
+ // stay fully visible; truncation engages only when the column itself runs out.
233
+ const pad = Math.max(
234
+ PREVIEW_PADDING_LEFT,
235
+ Math.min(boxAlignedPad, colWidth - visibleWidth(line)),
236
+ );
237
+ return `${" ".repeat(pad)}${truncateToWidth(line, colWidth - pad, "")}`;
238
+ });
239
+ }
240
+ }
@@ -0,0 +1,66 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { StatefulView } from "../stateful-view.js";
4
+
5
+ const ACTIVE_POINTER = "❯ ";
6
+ const INACTIVE_POINTER = " ";
7
+ const NUMBER_SEPARATOR = ". ";
8
+
9
+ export const SUBMIT_LABEL = "Submit answers";
10
+ export const CANCEL_LABEL = "Cancel";
11
+
12
+ /**
13
+ * Per-tick projection of SubmitPicker state. The picker is a fixed 2-row
14
+ * structure (Submit / Cancel) — labels are static, only the active marker
15
+ * varies per tick. `selectSubmitPickerProps` precomputes `active` per row.
16
+ */
17
+ export interface SubmitPickerProps {
18
+ /** Per-row active flag. Length always 2: row 0 = Submit, row 1 = Cancel. */
19
+ rows: ReadonlyArray<{ active: boolean }>;
20
+ }
21
+
22
+ /**
23
+ * Static 2-row picker rendered on the Submit Tab. Row 0 = "Submit answers", Row 1 = "Cancel".
24
+ *
25
+ * - Active pointer (❯) follows `props.rows[i].active` per tick.
26
+ * - Both rows render in normal style at all times — D1 (revised) allows partial submission,
27
+ * so Submit is never dimmed or visually marked as unselectable. The warning header in
28
+ * `buildSubmitContainer` is the sole signal of incompleteness.
29
+ * - `naturalHeight(width)` is state-INDEPENDENT and returns a constant 2, so the
30
+ * chrome-mirror layout in `buildSubmitContainer` can subtract a fixed 2 lines without
31
+ * re-rendering.
32
+ */
33
+ export class SubmitPicker implements StatefulView<SubmitPickerProps> {
34
+ private props: SubmitPickerProps;
35
+
36
+ constructor(private readonly theme: Theme) {
37
+ this.props = { rows: [{ active: false }, { active: false }] };
38
+ }
39
+
40
+ setProps(props: SubmitPickerProps): void {
41
+ this.props = props;
42
+ }
43
+
44
+ handleInput(_data: string): void {}
45
+
46
+ invalidate(): void {}
47
+
48
+ naturalHeight(_width: number): number {
49
+ return 2;
50
+ }
51
+
52
+ render(width: number): string[] {
53
+ const lines: string[] = [];
54
+ for (let i = 0; i < 2; i++) {
55
+ const text = i === 0 ? SUBMIT_LABEL : CANCEL_LABEL;
56
+ const active = this.props.rows[i]?.active ?? false;
57
+ const pointer = active ? ACTIVE_POINTER : INACTIVE_POINTER;
58
+ const number = `${i + 1}${NUMBER_SEPARATOR}`;
59
+ const label = active
60
+ ? this.theme.fg("accent", this.theme.bold(text))
61
+ : this.theme.fg("text", text);
62
+ lines.push(truncateToWidth(`${pointer}${number}${label}`, width, ""));
63
+ }
64
+ return lines;
65
+ }
66
+ }
@@ -0,0 +1,70 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { StatefulView } from "../stateful-view.js";
4
+
5
+ /**
6
+ * Per-tick projection of TabBar state. The selector
7
+ * (`selectTabBarProps`) hoists every render-time derivation
8
+ * (`allAnswered`, `answered`, `isActive`, `submitActive`) into props so
9
+ * `render()` is pure styling. Replaces the prior `setConfig(TabBarConfig)`
10
+ * snowflake and the inline `+ 1` magic at `props-adapter.ts:127`.
11
+ */
12
+ /**
13
+ * Marks a tab that carries a note.
14
+ *
15
+ * It occupies the separator slot between the answered box and the label rather
16
+ * than being appended, so a segment is the same width noted or not. Appending
17
+ * it would add one cell per tab, and four tabs at the schema's 16-character
18
+ * header limit already put this bar at 99 columns: four suffixes would push it
19
+ * to 103, past the 100 columns previews require. `truncateToWidth` below is
20
+ * called with an empty ellipsis and drops the tail, so what would go missing is
21
+ * the Submit tab, silently.
22
+ */
23
+ export const NOTED_MARKER = "*";
24
+
25
+ export interface TabBarProps {
26
+ /** One per author-defined question, in order. */
27
+ tabs: ReadonlyArray<{ label: string; answered: boolean; active: boolean; noted: boolean }>;
28
+ /** Submit-tab state. `allAnswered` drives the success/dim color picker. */
29
+ submit: { active: boolean; allAnswered: boolean };
30
+ }
31
+
32
+ export class TabBar implements StatefulView<TabBarProps> {
33
+ private props: TabBarProps;
34
+
35
+ constructor(private readonly theme: Theme) {
36
+ this.props = { tabs: [], submit: { active: false, allAnswered: false } };
37
+ }
38
+
39
+ setProps(props: TabBarProps): void {
40
+ this.props = props;
41
+ }
42
+
43
+ handleInput(_data: string): void {}
44
+
45
+ invalidate(): void {}
46
+
47
+ render(width: number): string[] {
48
+ const pieces: string[] = [" ← "];
49
+
50
+ for (const tab of this.props.tabs) {
51
+ const box = tab.answered ? "■" : "□";
52
+ const rawSeg = ` ${box}${tab.noted ? NOTED_MARKER : " "}${tab.label} `;
53
+ const styled = tab.active
54
+ ? this.theme.bg("selectedBg", this.theme.fg("text", rawSeg))
55
+ : this.theme.fg(tab.answered ? "success" : "muted", rawSeg);
56
+ pieces.push(styled);
57
+ pieces.push(" ");
58
+ }
59
+
60
+ const submitText = " ✓ Submit ";
61
+ const submitStyled = this.props.submit.active
62
+ ? this.theme.bg("selectedBg", this.theme.fg("text", submitText))
63
+ : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
64
+ pieces.push(submitStyled);
65
+ pieces.push(" →");
66
+
67
+ const tabLine = truncateToWidth(pieces.join(""), width, "");
68
+ return [tabLine, ""];
69
+ }
70
+ }