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.
- package/README.md +21 -4
- package/extensions/ask-user-question/LICENSE +21 -0
- package/extensions/ask-user-question/README.md +63 -0
- package/extensions/ask-user-question/ask-user-question.ts +244 -0
- package/extensions/ask-user-question/config.ts +75 -0
- package/extensions/ask-user-question/events.ts +55 -0
- package/extensions/ask-user-question/index.ts +17 -0
- package/extensions/ask-user-question/reconcile.ts +47 -0
- package/extensions/ask-user-question/rpc-fallback.ts +160 -0
- package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
- package/extensions/ask-user-question/state/dialog-height.ts +16 -0
- package/extensions/ask-user-question/state/key-router.ts +269 -0
- package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
- package/extensions/ask-user-question/state/row-intent.ts +143 -0
- package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
- package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
- package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
- package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
- package/extensions/ask-user-question/state/state-reducer.ts +282 -0
- package/extensions/ask-user-question/state/state.ts +49 -0
- package/extensions/ask-user-question/tool/format-answer.ts +29 -0
- package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
- package/extensions/ask-user-question/tool/types.ts +145 -0
- package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
- package/extensions/ask-user-question/view/component-binding.ts +45 -0
- package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
- package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
- package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
- package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
- package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
- package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
- package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
- package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
- package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
- package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
- package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
- package/extensions/ask-user-question/view/props-adapter.ts +123 -0
- package/extensions/ask-user-question/view/stateful-view.ts +24 -0
- package/extensions/ask-user-question/view/tab-components.ts +16 -0
- package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
- 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
|
+
}
|