pi-cc-extensions 0.8.8 → 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 (42) hide show
  1. package/README.md +21 -4
  2. package/extensions/ask-user-question/LICENSE +21 -0
  3. package/extensions/ask-user-question/README.md +63 -0
  4. package/extensions/ask-user-question/ask-user-question.ts +244 -0
  5. package/extensions/ask-user-question/config.ts +75 -0
  6. package/extensions/ask-user-question/events.ts +55 -0
  7. package/extensions/ask-user-question/index.ts +17 -0
  8. package/extensions/ask-user-question/reconcile.ts +47 -0
  9. package/extensions/ask-user-question/rpc-fallback.ts +160 -0
  10. package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
  11. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  12. package/extensions/ask-user-question/state/key-router.ts +269 -0
  13. package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
  14. package/extensions/ask-user-question/state/row-intent.ts +143 -0
  15. package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
  16. package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
  17. package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
  18. package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
  19. package/extensions/ask-user-question/state/state-reducer.ts +282 -0
  20. package/extensions/ask-user-question/state/state.ts +49 -0
  21. package/extensions/ask-user-question/tool/format-answer.ts +29 -0
  22. package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
  23. package/extensions/ask-user-question/tool/types.ts +145 -0
  24. package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
  25. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  26. package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
  27. package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
  28. package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
  29. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
  30. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
  31. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
  32. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
  33. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
  34. package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
  35. package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
  36. package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
  37. package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
  38. package/extensions/ask-user-question/view/props-adapter.ts +123 -0
  39. package/extensions/ask-user-question/view/stateful-view.ts +24 -0
  40. package/extensions/ask-user-question/view/tab-components.ts +16 -0
  41. package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
  42. package/package.json +10 -4
@@ -0,0 +1,86 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g;
4
+ const ANSI_OSC8_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
5
+ const FENCE_MARKER_RE = /^`{3}/;
6
+
7
+ /** Top + bottom border rows consumed by `renderBorderedBox`. */
8
+ export const BORDER_VERTICAL_OVERHEAD = 2;
9
+ /** Left + right vertical bar columns (`│ ... │`) consumed by `renderBorderedBox`. */
10
+ export const BORDER_HORIZONTAL_OVERHEAD = 2;
11
+ /** Inner horizontal padding (1 col) between each border bar and content area. */
12
+ export const BORDER_INNER_PADDING_HORIZONTAL = 1;
13
+ /** Floor for the preview box's inner content width — CC parity (`PreviewBox.minWidth`). */
14
+ export const BOX_MIN_CONTENT_WIDTH = 40;
15
+
16
+ /**
17
+ * Drops fenced-code-block marker lines (` ``` ` opener/closer) from rendered markdown.
18
+ * pi-tui's Markdown emits literal opening ` ```lang ` and closing ` ``` ` lines around
19
+ * code blocks; this strip leaves only the highlighted code body. Inline code
20
+ * (`codespan`) is unaffected — pi-tui already renders it without backticks.
21
+ */
22
+ export function stripFenceMarkers(lines: readonly string[]): string[] {
23
+ return lines.filter((line) => {
24
+ const clean = line.replace(ANSI_SGR_RE, "").replace(ANSI_OSC8_RE, "");
25
+ return !FENCE_MARKER_RE.test(clean);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Wraps `lines` in a 4-sided ASCII border with 1 col of inner horizontal padding.
31
+ * Layout per content row: `│` + ` ` + content padded to `contentInner` + ` ` + `│`,
32
+ * where `contentInner = width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL`.
33
+ * Top/bottom dash runs span corner-to-corner (`width - BORDER_HORIZONTAL_OVERHEAD`). When
34
+ * `hidden > 0`, the bottom-row dash run is replaced with ` ✂ ── N lines hidden ── ` (corners stay).
35
+ */
36
+ export function renderBorderedBox(
37
+ lines: readonly string[],
38
+ width: number,
39
+ colorFn: (s: string) => string,
40
+ hidden = 0,
41
+ ): string[] {
42
+ const dashSpan = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD);
43
+ const contentInner = Math.max(1, dashSpan - 2 * BORDER_INNER_PADDING_HORIZONTAL);
44
+ const pad = " ".repeat(BORDER_INNER_PADDING_HORIZONTAL);
45
+ const top = colorFn(`┌${"─".repeat(dashSpan)}┐`);
46
+ const out: string[] = [top];
47
+ for (const line of lines) {
48
+ const padded = truncateToWidth(line, contentInner, "", true);
49
+ out.push(`${colorFn("│")}${pad}${padded}${pad}${colorFn("│")}`);
50
+ }
51
+ if (hidden > 0) {
52
+ const indicator = ` ✂ ── ${hidden} lines hidden ── `;
53
+ const space = dashSpan - indicator.length;
54
+ const leftFill = "─".repeat(Math.max(0, Math.floor(space / 2)));
55
+ const rightFill = "─".repeat(Math.max(0, dashSpan - leftFill.length - indicator.length));
56
+ out.push(colorFn(`└${leftFill}${indicator}${rightFill}┘`));
57
+ } else {
58
+ out.push(colorFn(`└${"─".repeat(dashSpan)}┘`));
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Compute box dimensions from content lines. Pure of args.
65
+ *
66
+ * CC parity:
67
+ * contentWidth = max(minWidth, widestRenderedLine)
68
+ * boxWidth = min(contentWidth + 4, effectiveMaxWidth)
69
+ *
70
+ * Trailing whitespace is stripped before measuring because pi-tui's
71
+ * `Markdown.render(width)` pads every line to `width`, which would otherwise force
72
+ * the box to fill the whole column allocation.
73
+ */
74
+ export function computeBoxDimensions(
75
+ contentLines: readonly string[],
76
+ maxInnerWidth: number,
77
+ ): { innerWidth: number; boxWidth: number } {
78
+ let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
79
+ for (const line of contentLines) {
80
+ const w = visibleWidth(line.replace(/\s+$/, ""));
81
+ if (w > widest) widest = w;
82
+ }
83
+ const innerWidth = Math.min(widest, maxInnerWidth);
84
+ const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
85
+ return { innerWidth, boxWidth };
86
+ }
@@ -0,0 +1,200 @@
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 { BORDER_HORIZONTAL_OVERHEAD, BORDER_INNER_PADDING_HORIZONTAL } from "./preview-box-renderer.js";
5
+
6
+ /** Min terminal/pane width for the side-by-side layout to engage. */
7
+ export const PREVIEW_MIN_WIDTH = 100;
8
+ /** Visual gap between options column and preview column in side-by-side. */
9
+ export const PREVIEW_COLUMN_GAP = 2;
10
+ /** 1 col padding inside the preview column (between gap and `│`). */
11
+ export const PREVIEW_PADDING_LEFT = 1;
12
+ /** Empty rows between options and preview blocks in stacked (narrow) layout. */
13
+ export const STACKED_GAP_ROWS = 1;
14
+
15
+ /** Floor for the adaptive left column width — prevents collapse on short labels. */
16
+ export const MIN_LEFT = 30;
17
+ /** Ceiling ratio: left column never exceeds this fraction of pane width. */
18
+ export const MAX_LEFT_RATIO = 0.5;
19
+ /** Floor for the preview column width — prevents right-side collapse on narrow terminals. */
20
+ export const MIN_PREVIEW_WIDTH = 45;
21
+ /** visibleWidth(" ✔") = 2 (space + ✔ codepoint). Reserved on the longest-label measurement
22
+ * so a confirmed row never gets truncated when MIN_LEFT clamps the column. */
23
+ export const CONFIRMED_OVERHEAD = 2;
24
+
25
+ export type PreviewLayoutMode = "side-by-side" | "stacked";
26
+
27
+ /**
28
+ * Decide layout mode from terminal + pane widths. Pure of inputs.
29
+ *
30
+ * The terminal-width gate is the AND check from the previous `preview-pane.ts` —
31
+ * lifted here so the decision is computed ONCE per render and threaded explicitly
32
+ * through `previewBlockHeight`. Removes the bug class where `previewBlockHeight`
33
+ * re-derived `sideBySide` from a column width (already < pane width post-split),
34
+ * capping height too short.
35
+ */
36
+ export function decideLayout(terminalWidth: number, paneWidth: number): PreviewLayoutMode {
37
+ return terminalWidth >= PREVIEW_MIN_WIDTH && paneWidth >= PREVIEW_MIN_WIDTH ? "side-by-side" : "stacked";
38
+ }
39
+
40
+ /**
41
+ * Compute the adaptive left column width from option labels.
42
+ * Pure function — deterministic for a given (items, totalForNumbering, paneWidth).
43
+ *
44
+ * Pipeline:
45
+ * 1. Measure: longest visible label width + prefix overhead + confirmed-mark overhead
46
+ * 2. Clamp: floor MIN_LEFT, ceiling paneWidth * MAX_LEFT_RATIO
47
+ * 3. Safety net: never exceed available = paneWidth - GAP - MIN_PREVIEW_WIDTH
48
+ */
49
+ export function adaptiveLeftWidth(
50
+ items: readonly WrappingSelectItem[],
51
+ totalForNumbering: number,
52
+ paneWidth: number,
53
+ ): number {
54
+ const prefixW = String(Math.max(1, totalForNumbering)).length + 4; // digits + "❯ " + ". "
55
+ const confirmedOverhead = CONFIRMED_OVERHEAD; // visibleWidth(" ✔") = 2 (space + ✔ codepoint)
56
+ let maxLabel = 0;
57
+ for (const item of items) {
58
+ const w = visibleWidth(item.label);
59
+ if (w > maxLabel) maxLabel = w;
60
+ }
61
+ const desired = maxLabel + prefixW + confirmedOverhead;
62
+ const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
63
+ const available = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
64
+ return Math.max(MIN_LEFT, Math.min(ratioCapped, Math.max(1, available)));
65
+ }
66
+
67
+ /**
68
+ * Cross-tab maximum left-column width. Aggregates `adaptiveLeftWidth` over every tab
69
+ * and returns the widest result, so the options column stays stable on tab switch.
70
+ *
71
+ * Pure function — `tabs.length` MUST equal `itemsByTab.length`. Numbering uses
72
+ * `items.length` for every tab (the chat row slot that once added +1 on single-select
73
+ * has been removed).
74
+ * Floor is `MIN_LEFT` so an all-empty input still produces a usable column.
75
+ */
76
+ export function crossTabMaxLeftWidth(
77
+ tabs: ReadonlyArray<{ multiSelect?: boolean }>,
78
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>,
79
+ paneWidth: number,
80
+ ): number {
81
+ let max = MIN_LEFT;
82
+ for (let i = 0; i < tabs.length; i++) {
83
+ const items = itemsByTab[i] ?? [];
84
+ const totalForNumbering = items.length;
85
+ const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
86
+ if (tabWidth > max) max = tabWidth;
87
+ }
88
+ return max;
89
+ }
90
+
91
+ /**
92
+ * Source-line probe: measures the widest source line across all options' previews.
93
+ * Returns 0 when no option carries a preview.
94
+ *
95
+ * V1 heuristic — works well for tree/table/heading/list content where source-line
96
+ * width ≈ rendered width. Paragraphs overstate (source width "wants" the full pane);
97
+ * the worst case is "donation does nothing useful" — fallback to the label-driven path.
98
+ * Upgrade path: replace with a render-width probe that invokes Markdown at a probe width.
99
+ *
100
+ * Pure function — O(total chars) per question; no Markdown invocation, no cache interaction.
101
+ */
102
+ export function previewSourceWidth(question: QuestionData): number {
103
+ let max = 0;
104
+ for (const option of question.options) {
105
+ const text = option.preview;
106
+ if (!text) continue;
107
+ for (const line of text.split("\n")) {
108
+ const w = visibleWidth(line);
109
+ if (w > max) max = w;
110
+ }
111
+ }
112
+ return max;
113
+ }
114
+
115
+ /**
116
+ * Cross-tab/cross-option preview budget. Iterates all questions, computes each question's
117
+ * preview appetite via `previewSourceWidth`, adds box + padding overhead (5 cols), and
118
+ * returns the widest result. Floor is `MIN_PREVIEW_WIDTH` so a previewless question still
119
+ * reserves a usable column. Ceiling ensures the left column retains its `MIN_LEFT` floor.
120
+ *
121
+ * Pure function — same cross-tab max pattern as `crossTabMaxLeftWidth`.
122
+ */
123
+ export function crossTabPreviewBudget(questions: readonly QuestionData[], paneWidth: number): number {
124
+ let max = MIN_PREVIEW_WIDTH;
125
+ for (const question of questions) {
126
+ const rawWidth = previewSourceWidth(question);
127
+ const capped = Math.min(rawWidth, paneWidth - PREVIEW_COLUMN_GAP - MIN_LEFT);
128
+ const budget = capped + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL + PREVIEW_PADDING_LEFT;
129
+ if (budget > max) max = budget;
130
+ }
131
+ return max;
132
+ }
133
+
134
+ /**
135
+ * Cross-tab left-column width with slack donation. Combines the label-driven width
136
+ * (from `crossTabMaxLeftWidth`) with the slack donated by narrow previews.
137
+ *
138
+ * Pipeline:
139
+ * 1. `labelDriven` = `crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth)`
140
+ * 2. `previewBudget` = `crossTabPreviewBudget(questions, paneWidth)`
141
+ * 3. `slackDonation` = `paneWidth − GAP − previewBudget`
142
+ * 4. Return `min(max(labelDriven, slackDonation), ceiling)` where `ceiling = paneWidth − GAP − MIN_PREVIEW_WIDTH`
143
+ *
144
+ * Invariants:
145
+ * - Floor: result ≥ MIN_LEFT (labelDriven ≥ MIN_LEFT)
146
+ * - Preview floor: right column ≥ MIN_PREVIEW_WIDTH (ceiling enforces this)
147
+ * - Cross-tab stability: both reductions are tab-independent
148
+ * - Determinism: pure of (questions, itemsByTab, paneWidth)
149
+ *
150
+ * `crossTabMaxLeftWidth` is NOT replaced — it continues to exist as the primitive.
151
+ * `MAX_LEFT_RATIO` caps the label-driven path inside `adaptiveLeftWidth`; donation
152
+ * operates above the cap when preview slack is available.
153
+ */
154
+ export function crossTabLeftWidthWithDonation(
155
+ tabs: ReadonlyArray<{ multiSelect?: boolean }>,
156
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>,
157
+ questions: readonly QuestionData[],
158
+ paneWidth: number,
159
+ ): number {
160
+ const labelDriven = crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth);
161
+ // Compact-content guard: labels at MIN_LEFT fit below the floor, so widening
162
+ // the column would only inject dead space between the option list and the
163
+ // preview box. Skip donation and preserve the compact intent.
164
+ if (labelDriven <= MIN_LEFT) return labelDriven;
165
+ const previewBudget = crossTabPreviewBudget(questions, paneWidth);
166
+ const slackDonation = paneWidth - PREVIEW_COLUMN_GAP - previewBudget;
167
+ const ceiling = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
168
+ return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
169
+ }
170
+
171
+ /**
172
+ * Width allocation for side-by-side mode.
173
+ * `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
174
+ * cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
175
+ * extreme inputs.
176
+ */
177
+ export function columnWidths(
178
+ paneWidth: number,
179
+ adaptiveLeft: number,
180
+ ): { leftWidth: number; rightWidth: number; gap: number } {
181
+ const gap = PREVIEW_COLUMN_GAP;
182
+ const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
183
+ const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
184
+ return { leftWidth, rightWidth, gap };
185
+ }
186
+
187
+ /**
188
+ * Returns the widths actually passed to `options.render` and `previewLines` inside
189
+ * `render()`. Stacked uses the full pane width for both; side-by-side splits via
190
+ * `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
191
+ */
192
+ export function bodyWidths(
193
+ paneWidth: number,
194
+ mode: PreviewLayoutMode,
195
+ adaptiveLeft: number,
196
+ ): { optionsWidth: number; previewWidth: number } {
197
+ if (mode === "stacked") return { optionsWidth: paneWidth, previewWidth: paneWidth };
198
+ const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
199
+ return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
200
+ }
@@ -0,0 +1,226 @@
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(previewWidth, this.props.selectedIndex, mode);
172
+ if (mode === "side-by-side") return Math.max(optionsHeight, previewBlockHeight);
173
+ return optionsHeight + STACKED_GAP_ROWS + previewBlockHeight;
174
+ }
175
+
176
+ maxNaturalHeight(width: number): number {
177
+ if (this.question.multiSelect === true) return this.optionListView.render(width).length;
178
+ if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width).length;
179
+ // `inputMode`: like naturalHeight — full-width option list only, so the
180
+ // `maxNaturalHeight >= naturalHeight` parity invariant holds (both equal the list height).
181
+ if (this.props.inputMode) return this.optionListView.render(width).length;
182
+ const mode = decideLayout(this.getTerminalWidth(), width);
183
+ const adaptiveLeft = this.getAdaptiveLeft(width);
184
+ const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
185
+ const optionsHeight = this.optionListView.render(optionsWidth).length;
186
+ let maxPreviewBlock = 0;
187
+ for (let i = 0; i < this.question.options.length; i++) {
188
+ const h = this.previewBlock.blockHeight(previewWidth, i, mode);
189
+ if (h > maxPreviewBlock) maxPreviewBlock = h;
190
+ }
191
+ if (mode === "side-by-side") return Math.max(optionsHeight, maxPreviewBlock);
192
+ return optionsHeight + STACKED_GAP_ROWS + maxPreviewBlock;
193
+ }
194
+
195
+ private renderSideBySide(width: number, mode: PreviewLayoutMode): string[] {
196
+ const adaptiveLeft = this.getAdaptiveLeft(width);
197
+ const { leftWidth, rightWidth, gap } = columnWidths(width, adaptiveLeft);
198
+ const leftLines = this.optionListView.render(leftWidth);
199
+ const rightLines = this.renderPaddedPreviewLines(rightWidth, mode);
200
+ const rows = Math.max(leftLines.length, rightLines.length);
201
+ const gapStr = " ".repeat(gap);
202
+ const out: string[] = [];
203
+ for (let i = 0; i < rows; i++) {
204
+ const leftRaw = leftLines[i] ?? "";
205
+ const rightRaw = rightLines[i] ?? "";
206
+ const leftClamped = truncateToWidth(leftRaw, leftWidth, "");
207
+ const leftPad = " ".repeat(Math.max(0, leftWidth - visibleWidth(leftClamped)));
208
+ const joined = `${leftClamped}${leftPad}${gapStr}${rightRaw}`;
209
+ out.push(truncateToWidth(joined, width, ""));
210
+ }
211
+ return out;
212
+ }
213
+
214
+ private renderPaddedPreviewLines(colWidth: number, mode: PreviewLayoutMode): string[] {
215
+ const inner = Math.max(1, colWidth - PREVIEW_PADDING_LEFT);
216
+ const contentLines = this.previewBlock.renderBlock(
217
+ inner,
218
+ this.props.selectedIndex,
219
+ mode,
220
+ this.props.focused,
221
+ this.props.notesVisible,
222
+ );
223
+ const pad = " ".repeat(PREVIEW_PADDING_LEFT);
224
+ return contentLines.map((l) => (l === "" ? "" : `${pad}${l}`));
225
+ }
226
+ }
@@ -0,0 +1,64 @@
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 ? this.theme.fg("accent", this.theme.bold(text)) : this.theme.fg("text", text);
60
+ lines.push(truncateToWidth(`${pointer}${number}${label}`, width, ""));
61
+ }
62
+ return lines;
63
+ }
64
+ }
@@ -0,0 +1,57 @@
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
+ export interface TabBarProps {
13
+ /** One per author-defined question, in order. */
14
+ tabs: ReadonlyArray<{ label: string; answered: boolean; active: boolean }>;
15
+ /** Submit-tab state. `allAnswered` drives the success/dim color picker. */
16
+ submit: { active: boolean; allAnswered: boolean };
17
+ }
18
+
19
+ export class TabBar implements StatefulView<TabBarProps> {
20
+ private props: TabBarProps;
21
+
22
+ constructor(private readonly theme: Theme) {
23
+ this.props = { tabs: [], submit: { active: false, allAnswered: false } };
24
+ }
25
+
26
+ setProps(props: TabBarProps): void {
27
+ this.props = props;
28
+ }
29
+
30
+ handleInput(_data: string): void {}
31
+
32
+ invalidate(): void {}
33
+
34
+ render(width: number): string[] {
35
+ const pieces: string[] = [" ← "];
36
+
37
+ for (const tab of this.props.tabs) {
38
+ const box = tab.answered ? "■" : "□";
39
+ const rawSeg = ` ${box} ${tab.label} `;
40
+ const styled = tab.active
41
+ ? this.theme.bg("selectedBg", this.theme.fg("text", rawSeg))
42
+ : this.theme.fg(tab.answered ? "success" : "muted", rawSeg);
43
+ pieces.push(styled);
44
+ pieces.push(" ");
45
+ }
46
+
47
+ const submitText = " ✓ Submit ";
48
+ const submitStyled = this.props.submit.active
49
+ ? this.theme.bg("selectedBg", this.theme.fg("text", submitText))
50
+ : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
51
+ pieces.push(submitStyled);
52
+ pieces.push(" →");
53
+
54
+ const tabLine = truncateToWidth(pieces.join(""), width, "");
55
+ return [tabLine, ""];
56
+ }
57
+ }