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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- package/src/view/tab-content-strategy.ts +447 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ROW_INTENT_META } from "../../state/row-intent.js";
|
|
4
|
+
import type { QuestionData } from "../../tool/types.js";
|
|
5
|
+
import type { StatefulView } from "../stateful-view.js";
|
|
6
|
+
import { renderInlineInputRow } from "./inline-input.js";
|
|
7
|
+
|
|
8
|
+
const ACTIVE_POINTER = "❯ ";
|
|
9
|
+
const INACTIVE_POINTER = " ";
|
|
10
|
+
const CHECKED = "[✔]";
|
|
11
|
+
const UNCHECKED = "[ ]";
|
|
12
|
+
const NUMBER_SEPARATOR = ". ";
|
|
13
|
+
const BOX_LABEL_GAP = " ";
|
|
14
|
+
// CC parity: description continuation indents to col 2 (past the pointer slot), NOT to the
|
|
15
|
+
// full prefix column. Wrap width still uses prefixVisibleWidth so naturalHeight matches render.
|
|
16
|
+
const CONTINUATION_INDENT = " ";
|
|
17
|
+
|
|
18
|
+
export const MULTI_SUBMIT_LABEL = "Submit";
|
|
19
|
+
|
|
20
|
+
export interface MultiSelectOtherRowProps {
|
|
21
|
+
/** The "Type something." row is the focused row (optionIndex === options.length). */
|
|
22
|
+
active: boolean;
|
|
23
|
+
/** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
|
|
24
|
+
inputMode: boolean;
|
|
25
|
+
/** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
|
|
26
|
+
inputBuffer: string;
|
|
27
|
+
inputCursorOffset: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MultiSelectViewProps {
|
|
31
|
+
rows: ReadonlyArray<{ checked: boolean; active: boolean }>;
|
|
32
|
+
other: MultiSelectOtherRowProps;
|
|
33
|
+
nextActive: boolean;
|
|
34
|
+
nextLabel: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Renders the multi-select option list (one row per option — pointer + checkbox + label —
|
|
39
|
+
* plus zero or more wrapped continuation lines per description).
|
|
40
|
+
*
|
|
41
|
+
* `naturalHeight(width)` is the rendered height for the current props. It grows when
|
|
42
|
+
* the custom-answer editor contains logical or visually wrapped lines, allowing the
|
|
43
|
+
* dialog to reserve exactly the space the active draft needs.
|
|
44
|
+
*
|
|
45
|
+
* One width-keyed layout supplies rendering, height, and focused-row measurement;
|
|
46
|
+
* `setProps` and `invalidate` discard that derived cache.
|
|
47
|
+
*/
|
|
48
|
+
interface MultiSelectLayout {
|
|
49
|
+
lines: string[];
|
|
50
|
+
focusedRange: [number, number];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Mutable row accumulator threaded through the append helpers during a layout miss. */
|
|
54
|
+
interface MultiSelectBuild {
|
|
55
|
+
lines: string[];
|
|
56
|
+
focusedRange: [number, number];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
60
|
+
private props: MultiSelectViewProps;
|
|
61
|
+
private cachedLayout: { width: number; value: MultiSelectLayout } | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly theme: Theme,
|
|
65
|
+
private readonly question: QuestionData,
|
|
66
|
+
) {
|
|
67
|
+
this.props = {
|
|
68
|
+
rows: [],
|
|
69
|
+
other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
|
|
70
|
+
nextActive: false,
|
|
71
|
+
nextLabel: ROW_INTENT_META.next.label,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setProps(props: MultiSelectViewProps): void {
|
|
76
|
+
this.props = props;
|
|
77
|
+
this.cachedLayout = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
handleInput(_data: string): void {}
|
|
81
|
+
|
|
82
|
+
invalidate(): void {
|
|
83
|
+
this.cachedLayout = undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
return this.layout(width).lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
focusedItemRowRange(width: number): [number, number] {
|
|
91
|
+
return this.layout(width).focusedRange;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
naturalHeight(width: number): number {
|
|
95
|
+
return this.layout(width).lines.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private layout(width: number): MultiSelectLayout {
|
|
99
|
+
if (this.cachedLayout?.width === width) return this.cachedLayout.value;
|
|
100
|
+
|
|
101
|
+
const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
|
|
102
|
+
const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
|
|
103
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
104
|
+
|
|
105
|
+
this.appendOptionRows(build, width, contentWidth, numberWidth);
|
|
106
|
+
|
|
107
|
+
const otherStart = build.lines.length;
|
|
108
|
+
build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
|
|
109
|
+
if (this.props.other.active) build.focusedRange = [otherStart, build.lines.length];
|
|
110
|
+
|
|
111
|
+
this.appendNextRow(build, width);
|
|
112
|
+
|
|
113
|
+
const value = { lines: build.lines, focusedRange: build.focusedRange };
|
|
114
|
+
this.cachedLayout = { width, value };
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private appendOptionRows(
|
|
119
|
+
build: MultiSelectBuild,
|
|
120
|
+
width: number,
|
|
121
|
+
contentWidth: number,
|
|
122
|
+
numberWidth: number,
|
|
123
|
+
): void {
|
|
124
|
+
for (let i = 0; i < this.question.options.length; i++) {
|
|
125
|
+
const opt = this.question.options[i];
|
|
126
|
+
const row = this.props.rows[i];
|
|
127
|
+
if (!opt || !row) continue;
|
|
128
|
+
const start = build.lines.length;
|
|
129
|
+
const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
130
|
+
// Checked and active rows share the accent hue, matching the dialog's selection rhythm.
|
|
131
|
+
const box = row.checked
|
|
132
|
+
? this.theme.fg("accent", CHECKED)
|
|
133
|
+
: this.theme.fg("muted", UNCHECKED);
|
|
134
|
+
const label = truncateToWidth(opt.label, contentWidth, "…");
|
|
135
|
+
const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
136
|
+
const number = String(i + 1).padStart(numberWidth, " ");
|
|
137
|
+
build.lines.push(
|
|
138
|
+
truncateToWidth(
|
|
139
|
+
`${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}${styledLabel}`,
|
|
140
|
+
width,
|
|
141
|
+
"",
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
if (opt.description) {
|
|
145
|
+
for (const segment of wrapTextWithAnsi(opt.description, contentWidth)) {
|
|
146
|
+
build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (row.active) build.focusedRange = [start, build.lines.length];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private appendNextRow(build: MultiSelectBuild, width: number): void {
|
|
154
|
+
const nextStart = build.lines.length;
|
|
155
|
+
const nextPointer = this.props.nextActive
|
|
156
|
+
? this.theme.fg("accent", ACTIVE_POINTER)
|
|
157
|
+
: INACTIVE_POINTER;
|
|
158
|
+
const nextLabel = this.props.nextActive
|
|
159
|
+
? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
|
|
160
|
+
: this.props.nextLabel;
|
|
161
|
+
build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
|
|
162
|
+
if (this.props.nextActive) build.focusedRange = [nextStart, build.lines.length];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
|
|
166
|
+
const other = this.props.other;
|
|
167
|
+
const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
168
|
+
const box = this.theme.fg("muted", UNCHECKED);
|
|
169
|
+
const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
|
|
170
|
+
const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
|
|
171
|
+
const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
|
|
172
|
+
const selectedText = (text: string) => this.theme.fg("accent", this.theme.bold(text));
|
|
173
|
+
|
|
174
|
+
if (other.active && other.inputMode) {
|
|
175
|
+
return renderInlineInputRow({
|
|
176
|
+
buffer: other.inputBuffer,
|
|
177
|
+
cursorOffset: other.inputCursorOffset,
|
|
178
|
+
rowPrefix,
|
|
179
|
+
continuationPrefix,
|
|
180
|
+
contentWidth,
|
|
181
|
+
selectedText,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return wrapTextWithAnsi(other.inputBuffer || ROW_INTENT_META.other.label, contentWidth).map(
|
|
186
|
+
(segment, index) => {
|
|
187
|
+
const line = `${index === 0 ? rowPrefix : continuationPrefix}${segment}`;
|
|
188
|
+
return other.active ? selectedText(line) : line;
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private prefixVisibleWidth(): number {
|
|
194
|
+
// Canonical prefix for OPTION rows: INACTIVE_POINTER + numberWidth digits + NUMBER_SEPARATOR
|
|
195
|
+
// + UNCHECKED + BOX_LABEL_GAP. State-independent because ACTIVE/INACTIVE pointer share
|
|
196
|
+
// visibleWidth, CHECKED/UNCHECKED share visibleWidth, and numberWidth is constant per question.
|
|
197
|
+
// The number column fits `options.length + 1` so the "Type something." row's N+1 number
|
|
198
|
+
// is never clipped. The Next sentinel uses a bare `pointer + "Next"` shape — its width
|
|
199
|
+
// never exceeds this prefix at any reasonable terminal width, so it's safe to leave it
|
|
200
|
+
// out of the canonical computation.
|
|
201
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
202
|
+
return (
|
|
203
|
+
visibleWidth(INACTIVE_POINTER) +
|
|
204
|
+
numberWidth +
|
|
205
|
+
visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { StatefulView } from "../stateful-view.js";
|
|
2
|
+
import {
|
|
3
|
+
WrappingSelect,
|
|
4
|
+
type WrappingSelectItem,
|
|
5
|
+
type WrappingSelectTheme,
|
|
6
|
+
} from "./wrapping-select.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maximum number of option rows visible in the WrappingSelect window. Lifted here from
|
|
10
|
+
* `preview-pane.ts` so the cap travels with the option-list owner.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_VISIBLE_OPTIONS = 10;
|
|
13
|
+
|
|
14
|
+
export interface OptionListViewConfig {
|
|
15
|
+
items: readonly WrappingSelectItem[];
|
|
16
|
+
theme: WrappingSelectTheme;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-tick projection of OptionListView state. After Phase 11b, `inputBuffer`
|
|
21
|
+
* is part of the props bag — the session-owned `inlineInput` (a headless
|
|
22
|
+
* `pi-tui` Editor instance) supplies its current `getText()` here per tick.
|
|
23
|
+
* `OptionListView` is purely props-driven; the imperative buffer surface and
|
|
24
|
+
* read-back getters are gone.
|
|
25
|
+
*/
|
|
26
|
+
export interface OptionListViewProps {
|
|
27
|
+
selectedIndex: number;
|
|
28
|
+
focused: boolean;
|
|
29
|
+
inputBuffer: string;
|
|
30
|
+
inputCursorOffset?: number;
|
|
31
|
+
/** Optional previously-confirmed indicator. Omit when no marker should be drawn. */
|
|
32
|
+
confirmed?: { index: number; labelOverride?: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sole owner of the option list's interactive state. Wraps a single
|
|
37
|
+
* `WrappingSelect`. Implements `StatefulView<OptionListViewProps>`:
|
|
38
|
+
* `setProps` is the only mutator; render output reflects the last props
|
|
39
|
+
* received.
|
|
40
|
+
*/
|
|
41
|
+
export class OptionListView implements StatefulView<OptionListViewProps> {
|
|
42
|
+
private readonly select: WrappingSelect;
|
|
43
|
+
|
|
44
|
+
constructor(config: OptionListViewConfig) {
|
|
45
|
+
this.select = new WrappingSelect(
|
|
46
|
+
config.items,
|
|
47
|
+
Math.min(config.items.length, MAX_VISIBLE_OPTIONS),
|
|
48
|
+
config.theme,
|
|
49
|
+
{
|
|
50
|
+
numberStartOffset: 0,
|
|
51
|
+
totalItemsForNumbering: config.items.length,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setProps(props: OptionListViewProps): void {
|
|
57
|
+
this.select.setSelectedIndex(props.selectedIndex);
|
|
58
|
+
this.select.setFocused(props.focused);
|
|
59
|
+
this.select.setConfirmedIndex(props.confirmed?.index, props.confirmed?.labelOverride);
|
|
60
|
+
this.select.setInputBuffer(props.inputBuffer);
|
|
61
|
+
this.select.setInputCursorOffset(props.inputCursorOffset);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleInput(_data: string): void {}
|
|
65
|
+
|
|
66
|
+
invalidate(): void {
|
|
67
|
+
this.select.invalidate();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
return this.select.render(width);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
focusedItemRowRange(width: number): [number, number] {
|
|
75
|
+
return this.select.focusedItemRowRange(width);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { QuestionData } from "../../../tool/types.js";
|
|
4
|
+
import { stripFenceMarkers } from "./preview-box-renderer.js";
|
|
5
|
+
|
|
6
|
+
/** CC parity in side-by-side layout. */
|
|
7
|
+
export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
|
|
8
|
+
/** Preserves narrow-terminal protection in stacked layout. */
|
|
9
|
+
export const MAX_PREVIEW_HEIGHT_STACKED = 15;
|
|
10
|
+
export const NO_PREVIEW_TEXT = "No preview available";
|
|
11
|
+
/** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
|
|
12
|
+
export const NOTES_AFFORDANCE_OVERHEAD = 2;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-question cache for rendered markdown previews. Width-keyed: switching the
|
|
16
|
+
* inner width invalidates every cached `Markdown`'s render output (pi-tui's
|
|
17
|
+
* `Markdown.render(width)` re-wraps when width changes).
|
|
18
|
+
*
|
|
19
|
+
* Replaces the inline `previewTexts`, `markdownCache`, `cachedWidth` triple from
|
|
20
|
+
* the previous monolithic `preview-pane.ts`. One Markdown per option, lazy on
|
|
21
|
+
* first request, never re-constructed — count semantics frozen by tests.
|
|
22
|
+
*/
|
|
23
|
+
export class MarkdownContentCache {
|
|
24
|
+
private readonly previewTexts: Map<number, string>;
|
|
25
|
+
private readonly markdownCache: Map<number, Markdown>;
|
|
26
|
+
private cachedWidth: number | undefined;
|
|
27
|
+
private readonly theme: Theme;
|
|
28
|
+
private readonly markdownTheme: MarkdownTheme;
|
|
29
|
+
|
|
30
|
+
constructor(question: QuestionData, theme: Theme, markdownTheme: MarkdownTheme) {
|
|
31
|
+
this.theme = theme;
|
|
32
|
+
this.markdownTheme = markdownTheme;
|
|
33
|
+
this.previewTexts = new Map();
|
|
34
|
+
for (let i = 0; i < question.options.length; i++) {
|
|
35
|
+
const raw = question.options[i]?.preview;
|
|
36
|
+
if (raw && raw.length > 0) this.previewTexts.set(i, raw);
|
|
37
|
+
}
|
|
38
|
+
this.markdownCache = new Map();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hasAnyPreview(): boolean {
|
|
42
|
+
return this.previewTexts.size > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(optionIndex: number): boolean {
|
|
46
|
+
return this.previewTexts.has(optionIndex);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute the body lines for a given option at a given inner width. Width changes
|
|
51
|
+
* invalidate the per-Markdown render cache.
|
|
52
|
+
*/
|
|
53
|
+
bodyFor(optionIndex: number, innerWidth: number): string[] {
|
|
54
|
+
if (this.cachedWidth !== innerWidth) {
|
|
55
|
+
for (const md of this.markdownCache.values()) md.invalidate();
|
|
56
|
+
this.cachedWidth = innerWidth;
|
|
57
|
+
}
|
|
58
|
+
const text = this.previewTexts.get(optionIndex);
|
|
59
|
+
if (!text) {
|
|
60
|
+
const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
|
|
61
|
+
const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
|
|
62
|
+
return [placeholder + " ".repeat(pad)];
|
|
63
|
+
}
|
|
64
|
+
let md = this.markdownCache.get(optionIndex);
|
|
65
|
+
if (!md) {
|
|
66
|
+
md = new Markdown(text, 0, 0, this.markdownTheme);
|
|
67
|
+
this.markdownCache.set(optionIndex, md);
|
|
68
|
+
}
|
|
69
|
+
return stripFenceMarkers(md.render(innerWidth));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
for (const md of this.markdownCache.values()) md.invalidate();
|
|
74
|
+
this.cachedWidth = undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { QuestionData } from "../../../tool/types.js";
|
|
4
|
+
import {
|
|
5
|
+
MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
|
|
6
|
+
MAX_PREVIEW_HEIGHT_STACKED,
|
|
7
|
+
MarkdownContentCache,
|
|
8
|
+
NOTES_AFFORDANCE_OVERHEAD,
|
|
9
|
+
} from "./markdown-content-cache.js";
|
|
10
|
+
import {
|
|
11
|
+
BORDER_HORIZONTAL_OVERHEAD,
|
|
12
|
+
BORDER_INNER_PADDING_HORIZONTAL,
|
|
13
|
+
BORDER_VERTICAL_OVERHEAD,
|
|
14
|
+
computeBoxDimensions,
|
|
15
|
+
renderBorderedBox,
|
|
16
|
+
} from "./preview-box-renderer.js";
|
|
17
|
+
import type { PreviewLayoutMode } from "./preview-layout-decider.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Affordance text shown below the bordered preview when focused on a preview-bearing option.
|
|
21
|
+
* Re-exported by `preview-pane.ts` for the existing test surface.
|
|
22
|
+
*/
|
|
23
|
+
export const NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes";
|
|
24
|
+
|
|
25
|
+
/** Content row budget for a layout mode: preview cap minus border + affordance overhead. */
|
|
26
|
+
function contentBudgetFor(mode: PreviewLayoutMode): number {
|
|
27
|
+
const cap =
|
|
28
|
+
mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
|
|
29
|
+
return Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Inner (padding-aware) content width for a total block width. */
|
|
33
|
+
function innerWidthFor(width: number): number {
|
|
34
|
+
return Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreviewBlockRendererConfig {
|
|
38
|
+
question: QuestionData;
|
|
39
|
+
theme: Theme;
|
|
40
|
+
markdownTheme: MarkdownTheme;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Renders the bordered markdown preview block for a single question (one block per render call,
|
|
45
|
+
* for the option at `optionIndex`). Owns a per-question `MarkdownContentCache`.
|
|
46
|
+
*
|
|
47
|
+
* NOT a `Component` — pure render-and-measure helper consumed by `PreviewPane`. The layout mode
|
|
48
|
+
* is threaded as an explicit param (never re-derived from column width post-split).
|
|
49
|
+
*
|
|
50
|
+
* The affordance row is always emitted (visually empty when gated) so the preview block's row
|
|
51
|
+
* count is height-stable across affordance-state transitions.
|
|
52
|
+
*/
|
|
53
|
+
export class PreviewBlockRenderer {
|
|
54
|
+
private readonly theme: Theme;
|
|
55
|
+
private readonly cache: MarkdownContentCache;
|
|
56
|
+
|
|
57
|
+
constructor(config: PreviewBlockRendererConfig) {
|
|
58
|
+
this.theme = config.theme;
|
|
59
|
+
this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hasAnyPreview(): boolean {
|
|
63
|
+
return this.cache.hasAnyPreview();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
has(optionIndex: number): boolean {
|
|
67
|
+
return this.cache.has(optionIndex);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
invalidate(): void {
|
|
71
|
+
this.cache.invalidate();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Height contribution of the preview block: `BORDER_VERTICAL_OVERHEAD + contentRows +
|
|
76
|
+
* NOTES_AFFORDANCE_OVERHEAD`. Always returns the same value as `renderBlock(...).length`
|
|
77
|
+
* — the affordance overhead is constant, not gated by `focused`/`notesVisible`.
|
|
78
|
+
*/
|
|
79
|
+
blockHeight(width: number, optionIndex: number, mode: PreviewLayoutMode): number {
|
|
80
|
+
const contentBudget = contentBudgetFor(mode);
|
|
81
|
+
const innerWidth = innerWidthFor(width);
|
|
82
|
+
const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
|
|
83
|
+
const contentRows = Math.min(rawRows, contentBudget);
|
|
84
|
+
return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render the full preview block at `width`: bordered box + blank separator + affordance row.
|
|
89
|
+
* `focused` and `notesVisible` together gate the affordance text (visible only when the
|
|
90
|
+
* focused option carries a preview AND notes mode is inactive). The affordance row is ALWAYS
|
|
91
|
+
* emitted (as an empty string when gated) so the row count is invariant.
|
|
92
|
+
*/
|
|
93
|
+
renderBlock(
|
|
94
|
+
width: number,
|
|
95
|
+
optionIndex: number,
|
|
96
|
+
mode: PreviewLayoutMode,
|
|
97
|
+
focused: boolean,
|
|
98
|
+
notesVisible: boolean,
|
|
99
|
+
): string[] {
|
|
100
|
+
const contentBudget = contentBudgetFor(mode);
|
|
101
|
+
const maxInnerWidth = innerWidthFor(width);
|
|
102
|
+
|
|
103
|
+
const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
|
|
104
|
+
const truncated = raw.length > contentBudget;
|
|
105
|
+
const hidden = truncated ? raw.length - contentBudget : 0;
|
|
106
|
+
const contentLines = truncated ? raw.slice(0, contentBudget) : raw;
|
|
107
|
+
|
|
108
|
+
const { boxWidth } = computeBoxDimensions(contentLines, maxInnerWidth);
|
|
109
|
+
const colorFn = (s: string) => this.theme.fg("accent", s);
|
|
110
|
+
const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
|
|
111
|
+
|
|
112
|
+
const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
|
|
113
|
+
const affordance = showAffordance ? this.theme.fg("muted", NOTES_AFFORDANCE_TEXT) : "";
|
|
114
|
+
return [...boxedLines, "", affordance];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
const FENCE_MARKER_RE = /^`{3}/;
|
|
5
|
+
|
|
6
|
+
/** Top + bottom border rows consumed by `renderBorderedBox`. */
|
|
7
|
+
export const BORDER_VERTICAL_OVERHEAD = 2;
|
|
8
|
+
/** Left + right vertical bar columns (`│ ... │`) consumed by `renderBorderedBox`. */
|
|
9
|
+
export const BORDER_HORIZONTAL_OVERHEAD = 2;
|
|
10
|
+
/** Inner horizontal padding (1 col) between each border bar and content area. */
|
|
11
|
+
export const BORDER_INNER_PADDING_HORIZONTAL = 1;
|
|
12
|
+
/** Floor for the preview box's inner content width — CC parity (`PreviewBox.minWidth`). */
|
|
13
|
+
export const BOX_MIN_CONTENT_WIDTH = 40;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drops fenced-code-block marker lines (` ``` ` opener/closer) from rendered markdown.
|
|
17
|
+
* pi-tui's Markdown emits literal opening ` ```lang ` and closing ` ``` ` lines around
|
|
18
|
+
* code blocks; this strip leaves only the highlighted code body. Inline code
|
|
19
|
+
* (`codespan`) is unaffected — pi-tui already renders it without backticks.
|
|
20
|
+
*
|
|
21
|
+
* Escapes are stripped with the Node builtin rather than the two hand-rolled
|
|
22
|
+
* regexes this replaced. Those matched SGR colour codes and OSC-8 hyperlinks
|
|
23
|
+
* only, so any other escape a highlighter emitted would sit in front of the
|
|
24
|
+
* backticks and hide a fence marker from the test below. The builtin covers the
|
|
25
|
+
* whole VT vocabulary, including both OSC-8 terminators (BEL and ST).
|
|
26
|
+
*/
|
|
27
|
+
export function stripFenceMarkers(lines: readonly string[]): string[] {
|
|
28
|
+
return lines.filter((line) => !FENCE_MARKER_RE.test(stripVTControlCharacters(line)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wraps `lines` in a 4-sided ASCII border with 1 col of inner horizontal padding.
|
|
33
|
+
* Layout per content row: `│` + ` ` + content padded to `contentInner` + ` ` + `│`,
|
|
34
|
+
* where `contentInner = width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL`.
|
|
35
|
+
* Top/bottom dash runs span corner-to-corner (`width - BORDER_HORIZONTAL_OVERHEAD`). When
|
|
36
|
+
* `hidden > 0`, the bottom-row dash run is replaced with ` ✂ ── N lines hidden ── ` (corners stay).
|
|
37
|
+
*/
|
|
38
|
+
export function renderBorderedBox(
|
|
39
|
+
lines: readonly string[],
|
|
40
|
+
width: number,
|
|
41
|
+
colorFn: (s: string) => string,
|
|
42
|
+
hidden = 0,
|
|
43
|
+
): string[] {
|
|
44
|
+
const dashSpan = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD);
|
|
45
|
+
const contentInner = Math.max(1, dashSpan - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
46
|
+
const pad = " ".repeat(BORDER_INNER_PADDING_HORIZONTAL);
|
|
47
|
+
const top = colorFn(`┌${"─".repeat(dashSpan)}┐`);
|
|
48
|
+
const out: string[] = [top];
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const padded = truncateToWidth(line, contentInner, "", true);
|
|
51
|
+
out.push(`${colorFn("│")}${pad}${padded}${pad}${colorFn("│")}`);
|
|
52
|
+
}
|
|
53
|
+
if (hidden > 0) {
|
|
54
|
+
const indicator = ` ✂ ── ${hidden} lines hidden ── `;
|
|
55
|
+
const space = dashSpan - indicator.length;
|
|
56
|
+
const leftFill = "─".repeat(Math.max(0, Math.floor(space / 2)));
|
|
57
|
+
const rightFill = "─".repeat(Math.max(0, dashSpan - leftFill.length - indicator.length));
|
|
58
|
+
out.push(colorFn(`└${leftFill}${indicator}${rightFill}┘`));
|
|
59
|
+
} else {
|
|
60
|
+
out.push(colorFn(`└${"─".repeat(dashSpan)}┘`));
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compute box dimensions from content lines. Pure of args.
|
|
67
|
+
*
|
|
68
|
+
* CC parity:
|
|
69
|
+
* contentWidth = max(minWidth, widestRenderedLine)
|
|
70
|
+
* boxWidth = min(contentWidth + 4, effectiveMaxWidth)
|
|
71
|
+
*
|
|
72
|
+
* Trailing whitespace is stripped before measuring because pi-tui's
|
|
73
|
+
* `Markdown.render(width)` pads every line to `width`, which would otherwise force
|
|
74
|
+
* the box to fill the whole column allocation.
|
|
75
|
+
*/
|
|
76
|
+
export function computeBoxDimensions(
|
|
77
|
+
contentLines: readonly string[],
|
|
78
|
+
maxInnerWidth: number,
|
|
79
|
+
): { innerWidth: number; boxWidth: number } {
|
|
80
|
+
let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
|
|
81
|
+
for (const line of contentLines) {
|
|
82
|
+
const w = visibleWidth(line.replace(/\s+$/, ""));
|
|
83
|
+
if (w > widest) widest = w;
|
|
84
|
+
}
|
|
85
|
+
const innerWidth = Math.min(widest, maxInnerWidth);
|
|
86
|
+
const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
|
|
87
|
+
return { innerWidth, boxWidth };
|
|
88
|
+
}
|