pi-ask-popup 0.1.0 → 0.2.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/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +54 -21
- package/src/config.ts +118 -24
- package/src/rpc-fallback.ts +85 -28
- package/src/state/build-questionnaire.ts +45 -8
- package/src/state/external-editor.ts +24 -12
- package/src/state/key-router.ts +152 -41
- package/src/state/questionnaire-session.ts +79 -14
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +9 -1
- package/src/state/state-reducer.ts +110 -38
- package/src/tool/response-envelope.ts +71 -22
- package/src/tool/types.ts +28 -4
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +32 -7
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +75 -23
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +52 -15
- package/src/view/components/preview/preview-pane.ts +72 -28
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +110 -37
- package/src/view/dialog-builder.ts +44 -10
- package/src/view/props-adapter.ts +29 -7
- package/src/view/tab-content-strategy.ts +69 -21
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
export const DECLINE_MESSAGE = "User declined to answer questions";
|
|
10
10
|
export const TIMED_OUT_MESSAGE =
|
|
11
11
|
"Questionnaire timed out — the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.";
|
|
12
|
+
export const HOST_ERROR_MESSAGE =
|
|
13
|
+
"The host replied with a value that was never offered, so the questionnaire could not be completed. Nobody declined and nobody answered — do NOT treat this as a rejection. Ask the questions as plain chat text instead.";
|
|
12
14
|
export const ENVELOPE_PREFIX = "User has answered your questions:";
|
|
13
15
|
export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
|
|
14
16
|
/** Opens the segment for a note whose question was never answered. */
|
|
@@ -37,39 +39,69 @@ export function buildQuestionnaireResponse(
|
|
|
37
39
|
params: QuestionParams,
|
|
38
40
|
): ToolResult {
|
|
39
41
|
if (result?.error === "timed_out") {
|
|
40
|
-
|
|
42
|
+
const details: QuestionnaireResult = {
|
|
41
43
|
answers: result.answers,
|
|
42
44
|
cancelled: true,
|
|
43
45
|
error: "timed_out",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
};
|
|
47
|
+
if (result.globalNote && result.globalNote.length > 0) {
|
|
48
|
+
(details as { globalNote: string }).globalNote = result.globalNote;
|
|
49
|
+
}
|
|
50
|
+
if (result.unansweredNotes && result.unansweredNotes.length > 0) {
|
|
51
|
+
(details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
|
|
52
|
+
result.unansweredNotes;
|
|
53
|
+
}
|
|
54
|
+
return buildToolResult(TIMED_OUT_MESSAGE, details);
|
|
55
|
+
}
|
|
56
|
+
if (result?.error === "host_error") {
|
|
57
|
+
// A broken host is not a decision. Sharing the decline text would tell the
|
|
58
|
+
// model the user said no, when the user was never shown a working dialog.
|
|
59
|
+
const details: QuestionnaireResult = {
|
|
60
|
+
answers: result.answers,
|
|
61
|
+
cancelled: true,
|
|
62
|
+
error: "host_error",
|
|
63
|
+
};
|
|
64
|
+
if (result.hostErrorDetail && result.hostErrorDetail.length > 0) {
|
|
65
|
+
(details as { hostErrorDetail: string }).hostErrorDetail = result.hostErrorDetail;
|
|
66
|
+
return buildToolResult(
|
|
67
|
+
`${HOST_ERROR_MESSAGE} (host sent: ${result.hostErrorDetail})`,
|
|
68
|
+
details,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return buildToolResult(HOST_ERROR_MESSAGE, details);
|
|
51
72
|
}
|
|
52
73
|
if (!result || result.cancelled) {
|
|
53
74
|
// The decline text stays canonical even when a global note rides a
|
|
54
75
|
// cancelled result. The note survives in `details`, like partial answers.
|
|
55
|
-
|
|
76
|
+
const details: QuestionnaireResult = {
|
|
56
77
|
answers: result?.answers ?? [],
|
|
57
78
|
cancelled: true,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
};
|
|
80
|
+
if (result?.error) {
|
|
81
|
+
(details as { error: typeof result.error }).error = result.error;
|
|
82
|
+
}
|
|
83
|
+
if (result?.globalNote && result.globalNote.length > 0) {
|
|
84
|
+
(details as { globalNote: string }).globalNote = result.globalNote;
|
|
85
|
+
}
|
|
86
|
+
if (result?.unansweredNotes && result.unansweredNotes.length > 0) {
|
|
87
|
+
(details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
|
|
88
|
+
result.unansweredNotes;
|
|
89
|
+
}
|
|
90
|
+
return buildToolResult(DECLINE_MESSAGE, details);
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
// Indexed once rather than scanned per question. Both sides are keyed by
|
|
94
|
+
// `questionIndex`, which is what the loop below asks for, and it is the same
|
|
95
|
+
// shape `orderedAnswers` uses in the reducer. A first entry wins, so a
|
|
96
|
+
// duplicated index reads as the earlier `find` did.
|
|
97
|
+
const answerByIndex = byQuestionIndex(result.answers);
|
|
98
|
+
const noteByIndex = byQuestionIndex(result.unansweredNotes ?? []);
|
|
99
|
+
|
|
68
100
|
const segments: string[] = [];
|
|
69
101
|
// Iterate the questions rather than the answers so segments always follow the
|
|
70
102
|
// order the model asked in, whatever order the user filled tabs.
|
|
71
103
|
for (let i = 0; i < params.questions.length; i++) {
|
|
72
|
-
const a =
|
|
104
|
+
const a = answerByIndex.get(i);
|
|
73
105
|
if (a) {
|
|
74
106
|
segments.push(buildAnswerSegment(a));
|
|
75
107
|
continue;
|
|
@@ -78,8 +110,10 @@ export function buildQuestionnaireResponse(
|
|
|
78
110
|
// emitted here rather than grouped at the end. Because this loop runs
|
|
79
111
|
// before the "nothing to report" check below, a questionnaire submitted
|
|
80
112
|
// with nothing but such a note counts as answered rather than declined.
|
|
81
|
-
const n =
|
|
82
|
-
if (n)
|
|
113
|
+
const n = noteByIndex.get(i);
|
|
114
|
+
if (n) {
|
|
115
|
+
segments.push(buildUnansweredNoteSegment(n));
|
|
116
|
+
}
|
|
83
117
|
}
|
|
84
118
|
if (result.globalNote && result.globalNote.length > 0) {
|
|
85
119
|
// Raw multiline echo, no reformatting, trailing period matching the shape
|
|
@@ -92,14 +126,29 @@ export function buildQuestionnaireResponse(
|
|
|
92
126
|
return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
|
|
93
127
|
}
|
|
94
128
|
|
|
129
|
+
/** First entry per `questionIndex` wins, which is what `Array.find` did. */
|
|
130
|
+
function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
|
|
131
|
+
const out = new Map<number, T>();
|
|
132
|
+
for (const item of items) {
|
|
133
|
+
if (!out.has(item.questionIndex)) {
|
|
134
|
+
out.set(item.questionIndex, item);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
95
140
|
/**
|
|
96
141
|
* One answer as an envelope segment: `"question"="answer"`, optionally followed
|
|
97
142
|
* by the preview the user was looking at and the note they wrote.
|
|
98
143
|
*/
|
|
99
144
|
export function buildAnswerSegment(a: QuestionAnswer): string {
|
|
100
145
|
const parts: string[] = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
|
|
101
|
-
if (a.preview && a.preview.length > 0)
|
|
102
|
-
|
|
146
|
+
if (a.preview && a.preview.length > 0) {
|
|
147
|
+
parts.push(`selected preview: ${a.preview}`);
|
|
148
|
+
}
|
|
149
|
+
if (a.notes && a.notes.length > 0) {
|
|
150
|
+
parts.push(`user notes: ${a.notes}`);
|
|
151
|
+
}
|
|
103
152
|
return `${parts.join(". ")}.`;
|
|
104
153
|
}
|
|
105
154
|
|
package/src/tool/types.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
2
|
import { LABELS_BY_KIND, ROW_INTENT_META } from "../state/row-intent.js";
|
|
3
3
|
|
|
4
|
+
type JsonValue =
|
|
5
|
+
| string
|
|
6
|
+
| number
|
|
7
|
+
| boolean
|
|
8
|
+
| null
|
|
9
|
+
| JsonValue[]
|
|
10
|
+
| { readonly [key: string]: JsonValue };
|
|
11
|
+
|
|
4
12
|
export const MAX_QUESTIONS = 4;
|
|
5
13
|
export const MIN_OPTIONS = 2;
|
|
6
14
|
export const MAX_OPTIONS = 4;
|
|
@@ -106,7 +114,10 @@ export type QuestionParams = Static<typeof QuestionParamsSchema>;
|
|
|
106
114
|
* - `custom` — the user typed free text in the "Type something." row.
|
|
107
115
|
* `answer` is the text, or null when they committed nothing.
|
|
108
116
|
* - `multi` — the user committed multi-select choices. `selected` carries the
|
|
109
|
-
* chosen labels and `answer` is null.
|
|
117
|
+
* chosen labels and `answer` is null. Text typed on the "Type something." row
|
|
118
|
+
* appears in `selected` too, as its own trimmed entry: on a multi-select
|
|
119
|
+
* question that row counts as chosen whenever it holds text, so an answer can
|
|
120
|
+
* be several authored labels plus one thing the user wrote.
|
|
110
121
|
*/
|
|
111
122
|
export interface QuestionAnswer {
|
|
112
123
|
questionIndex: number;
|
|
@@ -154,7 +165,8 @@ export type QuestionnaireError =
|
|
|
154
165
|
| "reserved_label"
|
|
155
166
|
| "session_load_failed"
|
|
156
167
|
| "stale_module_cache"
|
|
157
|
-
| "timed_out"
|
|
168
|
+
| "timed_out"
|
|
169
|
+
| "host_error";
|
|
158
170
|
|
|
159
171
|
export interface QuestionnaireResult {
|
|
160
172
|
answers: QuestionAnswer[];
|
|
@@ -184,10 +196,22 @@ export interface QuestionnaireResult {
|
|
|
184
196
|
*/
|
|
185
197
|
unansweredNotes?: UnansweredNote[];
|
|
186
198
|
error?: QuestionnaireError;
|
|
199
|
+
/**
|
|
200
|
+
* What the host actually sent, on a `host_error` result. Quoted into the
|
|
201
|
+
* envelope so whoever reads the transcript can see which value was rejected
|
|
202
|
+
* rather than guessing at the malfunction.
|
|
203
|
+
*
|
|
204
|
+
* Same conditional-spread contract as `globalNote`: present only alongside
|
|
205
|
+
* `error: "host_error"`, never assigned `undefined`.
|
|
206
|
+
*/
|
|
207
|
+
hostErrorDetail?: string;
|
|
187
208
|
}
|
|
188
209
|
|
|
189
210
|
export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
|
|
190
|
-
if (!value || typeof value !== "object")
|
|
191
|
-
|
|
211
|
+
if (!value || typeof value !== "object") {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
// SAFETY: boundary check for QuestionnaireResult; value is validated as object with required fields before use.
|
|
215
|
+
const v = value as Record<string, JsonValue>;
|
|
192
216
|
return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
|
|
193
217
|
}
|
|
@@ -44,7 +44,9 @@ export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding
|
|
|
44
44
|
export function perTabBinding<P>(spec: PerTabBinding<P>): BoundPerTabBinding {
|
|
45
45
|
return {
|
|
46
46
|
apply: (state, ctx) => {
|
|
47
|
-
if (spec.predicate && !spec.predicate(state, ctx))
|
|
47
|
+
if (spec.predicate && !spec.predicate(state, ctx)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
48
50
|
spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
|
|
49
51
|
},
|
|
50
52
|
};
|
|
@@ -27,7 +27,9 @@ export interface RenderInlineInputOptions {
|
|
|
27
27
|
* Mirrors the original wrapping-select.resolveOffset exactly.
|
|
28
28
|
*/
|
|
29
29
|
function resolveCursorOffset(buffer: string, requested: number | undefined): number {
|
|
30
|
-
if (requested !== undefined && requested >= 0 && requested <= buffer.length)
|
|
30
|
+
if (requested !== undefined && requested >= 0 && requested <= buffer.length) {
|
|
31
|
+
return requested;
|
|
32
|
+
}
|
|
31
33
|
return buffer.length;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -20,6 +20,13 @@ export const MULTI_SUBMIT_LABEL = "Submit";
|
|
|
20
20
|
export interface MultiSelectOtherRowProps {
|
|
21
21
|
/** The "Type something." row is the focused row (optionIndex === options.length). */
|
|
22
22
|
active: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The typed text counts as a selection, which on this row means only that
|
|
25
|
+
* there is some. Drawn with the same ✔ as an option: the row is as chosen as
|
|
26
|
+
* a ticked box, and the box used to be painted permanently empty while the
|
|
27
|
+
* text under it was on its way to the model.
|
|
28
|
+
*/
|
|
29
|
+
checked: boolean;
|
|
23
30
|
/** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
|
|
24
31
|
inputMode: boolean;
|
|
25
32
|
/** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
|
|
@@ -66,7 +73,13 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
66
73
|
) {
|
|
67
74
|
this.props = {
|
|
68
75
|
rows: [],
|
|
69
|
-
other: {
|
|
76
|
+
other: {
|
|
77
|
+
active: false,
|
|
78
|
+
checked: false,
|
|
79
|
+
inputMode: false,
|
|
80
|
+
inputBuffer: "",
|
|
81
|
+
inputCursorOffset: undefined,
|
|
82
|
+
},
|
|
70
83
|
nextActive: false,
|
|
71
84
|
nextLabel: ROW_INTENT_META.next.label,
|
|
72
85
|
};
|
|
@@ -96,7 +109,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
private layout(width: number): MultiSelectLayout {
|
|
99
|
-
if (this.cachedLayout?.width === width)
|
|
112
|
+
if (this.cachedLayout?.width === width) {
|
|
113
|
+
return this.cachedLayout.value;
|
|
114
|
+
}
|
|
100
115
|
|
|
101
116
|
const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
|
|
102
117
|
const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
|
|
@@ -106,7 +121,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
106
121
|
|
|
107
122
|
const otherStart = build.lines.length;
|
|
108
123
|
build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
|
|
109
|
-
if (this.props.other.active)
|
|
124
|
+
if (this.props.other.active) {
|
|
125
|
+
build.focusedRange = [otherStart, build.lines.length];
|
|
126
|
+
}
|
|
110
127
|
|
|
111
128
|
this.appendNextRow(build, width);
|
|
112
129
|
|
|
@@ -124,7 +141,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
124
141
|
for (let i = 0; i < this.question.options.length; i++) {
|
|
125
142
|
const opt = this.question.options[i];
|
|
126
143
|
const row = this.props.rows[i];
|
|
127
|
-
if (!opt || !row)
|
|
144
|
+
if (!opt || !row) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
128
147
|
const start = build.lines.length;
|
|
129
148
|
const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
130
149
|
// Checked and active rows share the accent hue, matching the dialog's selection rhythm.
|
|
@@ -146,7 +165,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
146
165
|
build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
|
-
if (row.active)
|
|
168
|
+
if (row.active) {
|
|
169
|
+
build.focusedRange = [start, build.lines.length];
|
|
170
|
+
}
|
|
150
171
|
}
|
|
151
172
|
}
|
|
152
173
|
|
|
@@ -159,13 +180,17 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
159
180
|
? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
|
|
160
181
|
: this.props.nextLabel;
|
|
161
182
|
build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
|
|
162
|
-
if (this.props.nextActive)
|
|
183
|
+
if (this.props.nextActive) {
|
|
184
|
+
build.focusedRange = [nextStart, build.lines.length];
|
|
185
|
+
}
|
|
163
186
|
}
|
|
164
187
|
|
|
165
188
|
private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
|
|
166
189
|
const other = this.props.other;
|
|
167
190
|
const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
168
|
-
const box =
|
|
191
|
+
const box = other.checked
|
|
192
|
+
? this.theme.fg("accent", CHECKED)
|
|
193
|
+
: this.theme.fg("muted", UNCHECKED);
|
|
169
194
|
const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
|
|
170
195
|
const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
|
|
171
196
|
const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
|
|
@@ -71,6 +71,11 @@ export class OptionListView implements StatefulView<OptionListViewProps> {
|
|
|
71
71
|
return this.select.render(width);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Row count at `width`, equal to `render(width).length`, without building the rows. */
|
|
75
|
+
measureHeight(width: number): number {
|
|
76
|
+
return this.select.measureHeight(width);
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
focusedItemRowRange(width: number): [number, number] {
|
|
75
80
|
return this.select.focusedItemRowRange(width);
|
|
76
81
|
}
|
|
@@ -3,37 +3,65 @@ import { Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-t
|
|
|
3
3
|
import type { QuestionData } from "../../../tool/types.js";
|
|
4
4
|
import { stripFenceMarkers } from "./preview-box-renderer.js";
|
|
5
5
|
|
|
6
|
+
export type MarkdownFactory = (text: string, markdownTheme: MarkdownTheme) => Markdown;
|
|
7
|
+
|
|
6
8
|
/** CC parity in side-by-side layout. */
|
|
7
9
|
export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
|
|
8
10
|
/** Preserves narrow-terminal protection in stacked layout. */
|
|
9
11
|
export const MAX_PREVIEW_HEIGHT_STACKED = 15;
|
|
10
12
|
export const NO_PREVIEW_TEXT = "No preview available";
|
|
13
|
+
/** Fallback body line when a preview's markdown fails to render (untrusted input boundary). */
|
|
14
|
+
export const PREVIEW_RENDER_FAILED_TEXT = "Preview failed to render";
|
|
11
15
|
/** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
|
|
12
16
|
export const NOTES_AFFORDANCE_OVERHEAD = 2;
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
19
|
+
* One entry per option that carries a preview. `md` is built once and kept for
|
|
20
|
+
* the life of the cache; `lines` are the stripped rows it last produced, and
|
|
21
|
+
* `width` is the inner width they were produced at.
|
|
22
|
+
*/
|
|
23
|
+
interface PreviewEntry {
|
|
24
|
+
md: ReturnType<MarkdownFactory>;
|
|
25
|
+
width: number | undefined;
|
|
26
|
+
lines: string[] | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per-question cache for rendered markdown previews, keyed per option.
|
|
18
31
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
32
|
+
* Width used to be tracked for the cache as a whole: any change invalidated
|
|
33
|
+
* every `Markdown` it held, so a resize drag re-wrapped all four options one by
|
|
34
|
+
* one through the `maxNaturalHeight` loop even though the frame only measured
|
|
35
|
+
* some of them. Each option now remembers the width it was rendered at, so a
|
|
36
|
+
* width flip costs a re-render of the options that frame actually asks for and
|
|
37
|
+
* nothing else. It also holds the stripped rows, which spares the second strip
|
|
38
|
+
* when `blockHeight` and `renderBlock` ask for the same option in one frame.
|
|
39
|
+
*
|
|
40
|
+
* One Markdown per option, lazy on first request, never re-constructed — count
|
|
41
|
+
* semantics frozen by tests.
|
|
22
42
|
*/
|
|
23
43
|
export class MarkdownContentCache {
|
|
24
44
|
private readonly previewTexts: Map<number, string>;
|
|
25
|
-
private readonly markdownCache: Map<number,
|
|
26
|
-
private cachedWidth: number | undefined;
|
|
45
|
+
private readonly markdownCache: Map<number, PreviewEntry>;
|
|
27
46
|
private readonly theme: Theme;
|
|
28
47
|
private readonly markdownTheme: MarkdownTheme;
|
|
48
|
+
private readonly markdownFactory: MarkdownFactory;
|
|
29
49
|
|
|
30
|
-
constructor(
|
|
50
|
+
constructor(
|
|
51
|
+
question: QuestionData,
|
|
52
|
+
theme: Theme,
|
|
53
|
+
markdownTheme: MarkdownTheme,
|
|
54
|
+
markdownFactory: MarkdownFactory = (text, mt) => new Markdown(text, 0, 0, mt),
|
|
55
|
+
) {
|
|
31
56
|
this.theme = theme;
|
|
32
57
|
this.markdownTheme = markdownTheme;
|
|
58
|
+
this.markdownFactory = markdownFactory;
|
|
33
59
|
this.previewTexts = new Map();
|
|
34
60
|
for (let i = 0; i < question.options.length; i++) {
|
|
35
61
|
const raw = question.options[i]?.preview;
|
|
36
|
-
if (raw && raw.length > 0)
|
|
62
|
+
if (raw && raw.length > 0) {
|
|
63
|
+
this.previewTexts.set(i, raw);
|
|
64
|
+
}
|
|
37
65
|
}
|
|
38
66
|
this.markdownCache = new Map();
|
|
39
67
|
}
|
|
@@ -47,30 +75,54 @@ export class MarkdownContentCache {
|
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
78
|
+
* Body lines for one option at one inner width. A repeat request at the width
|
|
79
|
+
* the option was last rendered at returns the stored rows.
|
|
80
|
+
*
|
|
81
|
+
* Untrusted boundary: `preview` is model-authored markdown and this is the only
|
|
82
|
+
* place it reaches the render path. A `Markdown` that throws (malformed input,
|
|
83
|
+
* width edge cases) must not take the overlay down with it, so the render is
|
|
84
|
+
* wrapped and a single dim fallback line is returned instead. There is no
|
|
85
|
+
* notify channel from the view layer — the fallback line IS the diagnostic.
|
|
86
|
+
* A failed render stores nothing, so the next frame tries again.
|
|
52
87
|
*/
|
|
53
88
|
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
89
|
const text = this.previewTexts.get(optionIndex);
|
|
59
90
|
if (!text) {
|
|
60
91
|
const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
|
|
61
92
|
const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
|
|
62
93
|
return [placeholder + " ".repeat(pad)];
|
|
63
94
|
}
|
|
64
|
-
let
|
|
65
|
-
if (!
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
let entry = this.markdownCache.get(optionIndex);
|
|
96
|
+
if (!entry) {
|
|
97
|
+
entry = {
|
|
98
|
+
md: this.markdownFactory(text, this.markdownTheme),
|
|
99
|
+
width: undefined,
|
|
100
|
+
lines: undefined,
|
|
101
|
+
};
|
|
102
|
+
this.markdownCache.set(optionIndex, entry);
|
|
103
|
+
}
|
|
104
|
+
if (entry.lines !== undefined && entry.width === innerWidth) {
|
|
105
|
+
// A copy: the rows travel into the box renderer and out to the pane, and
|
|
106
|
+
// the cache is the only thing that may hold the originals.
|
|
107
|
+
return [...entry.lines];
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const lines = stripFenceMarkers(entry.md.render(innerWidth));
|
|
111
|
+
entry.width = innerWidth;
|
|
112
|
+
entry.lines = lines;
|
|
113
|
+
return [...lines];
|
|
114
|
+
} catch {
|
|
115
|
+
entry.width = undefined;
|
|
116
|
+
entry.lines = undefined;
|
|
117
|
+
return [this.theme.fg("dim", PREVIEW_RENDER_FAILED_TEXT)];
|
|
68
118
|
}
|
|
69
|
-
return stripFenceMarkers(md.render(innerWidth));
|
|
70
119
|
}
|
|
71
120
|
|
|
72
121
|
invalidate(): void {
|
|
73
|
-
for (const
|
|
74
|
-
|
|
122
|
+
for (const entry of this.markdownCache.values()) {
|
|
123
|
+
entry.md.invalidate();
|
|
124
|
+
entry.width = undefined;
|
|
125
|
+
entry.lines = undefined;
|
|
126
|
+
}
|
|
75
127
|
}
|
|
76
128
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
|
|
6
6
|
MAX_PREVIEW_HEIGHT_STACKED,
|
|
7
7
|
MarkdownContentCache,
|
|
8
|
+
type MarkdownFactory,
|
|
8
9
|
NOTES_AFFORDANCE_OVERHEAD,
|
|
9
10
|
} from "./markdown-content-cache.js";
|
|
10
11
|
import {
|
|
@@ -38,6 +39,7 @@ export interface PreviewBlockRendererConfig {
|
|
|
38
39
|
question: QuestionData;
|
|
39
40
|
theme: Theme;
|
|
40
41
|
markdownTheme: MarkdownTheme;
|
|
42
|
+
markdownFactory?: MarkdownFactory;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -56,7 +58,12 @@ export class PreviewBlockRenderer {
|
|
|
56
58
|
|
|
57
59
|
constructor(config: PreviewBlockRendererConfig) {
|
|
58
60
|
this.theme = config.theme;
|
|
59
|
-
this.cache = new MarkdownContentCache(
|
|
61
|
+
this.cache = new MarkdownContentCache(
|
|
62
|
+
config.question,
|
|
63
|
+
config.theme,
|
|
64
|
+
config.markdownTheme,
|
|
65
|
+
config.markdownFactory,
|
|
66
|
+
);
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
hasAnyPreview(): boolean {
|
|
@@ -73,14 +73,13 @@ export function renderBorderedBox(
|
|
|
73
73
|
* `Markdown.render(width)` pads every line to `width`, which would otherwise force
|
|
74
74
|
* the box to fill the whole column allocation.
|
|
75
75
|
*/
|
|
76
|
-
export function computeBoxDimensions(
|
|
77
|
-
contentLines: readonly string[],
|
|
78
|
-
maxInnerWidth: number,
|
|
79
|
-
): { innerWidth: number; boxWidth: number } {
|
|
76
|
+
export function computeBoxDimensions(contentLines: readonly string[], maxInnerWidth: number) {
|
|
80
77
|
let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
|
|
81
78
|
for (const line of contentLines) {
|
|
82
79
|
const w = visibleWidth(line.replace(/\s+$/, ""));
|
|
83
|
-
if (w > widest)
|
|
80
|
+
if (w > widest) {
|
|
81
|
+
widest = w;
|
|
82
|
+
}
|
|
84
83
|
}
|
|
85
84
|
const innerWidth = Math.min(widest, maxInnerWidth);
|
|
86
85
|
const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
|
|
@@ -61,7 +61,9 @@ export function adaptiveLeftWidth(
|
|
|
61
61
|
let maxLabel = 0;
|
|
62
62
|
for (const item of items) {
|
|
63
63
|
const w = visibleWidth(item.label);
|
|
64
|
-
if (w > maxLabel)
|
|
64
|
+
if (w > maxLabel) {
|
|
65
|
+
maxLabel = w;
|
|
66
|
+
}
|
|
65
67
|
}
|
|
66
68
|
const desired = maxLabel + prefixW + confirmedOverhead;
|
|
67
69
|
const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
|
|
@@ -88,7 +90,9 @@ export function crossTabMaxLeftWidth(
|
|
|
88
90
|
const items = itemsByTab[i] ?? [];
|
|
89
91
|
const totalForNumbering = items.length;
|
|
90
92
|
const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
|
|
91
|
-
if (tabWidth > max)
|
|
93
|
+
if (tabWidth > max) {
|
|
94
|
+
max = tabWidth;
|
|
95
|
+
}
|
|
92
96
|
}
|
|
93
97
|
return max;
|
|
94
98
|
}
|
|
@@ -108,10 +112,14 @@ export function previewSourceWidth(question: QuestionData): number {
|
|
|
108
112
|
let max = 0;
|
|
109
113
|
for (const option of question.options) {
|
|
110
114
|
const text = option.preview;
|
|
111
|
-
if (!text)
|
|
115
|
+
if (!text) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
112
118
|
for (const line of text.split("\n")) {
|
|
113
119
|
const w = visibleWidth(line);
|
|
114
|
-
if (w > max)
|
|
120
|
+
if (w > max) {
|
|
121
|
+
max = w;
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
return max;
|
|
@@ -138,7 +146,9 @@ export function crossTabPreviewBudget(
|
|
|
138
146
|
BORDER_HORIZONTAL_OVERHEAD +
|
|
139
147
|
2 * BORDER_INNER_PADDING_HORIZONTAL +
|
|
140
148
|
PREVIEW_PADDING_LEFT;
|
|
141
|
-
if (budget > max)
|
|
149
|
+
if (budget > max) {
|
|
150
|
+
max = budget;
|
|
151
|
+
}
|
|
142
152
|
}
|
|
143
153
|
return max;
|
|
144
154
|
}
|
|
@@ -187,16 +197,45 @@ export function crossTabLeftWidthWithDonation(
|
|
|
187
197
|
return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
|
|
188
198
|
}
|
|
189
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Widths kept before the memo below is dropped wholesale. A frame asks for one;
|
|
202
|
+
* the rest of the entries are old terminal sizes from a resize drag.
|
|
203
|
+
*/
|
|
204
|
+
const PANE_WIDTH_MEMO_MAX_ENTRIES = 16;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Memoize a pane-width computation. For `crossTabLeftWidthWithDonation` and
|
|
208
|
+
* `crossTabPreviewBudget`, whose other inputs are fixed for the life of a
|
|
209
|
+
* questionnaire: both scan every tab, every option and every preview source
|
|
210
|
+
* line, and the pane width only changes on resize, yet the callers ask four to
|
|
211
|
+
* five times a frame (`focusedItemRowRange`, `naturalHeight`,
|
|
212
|
+
* `maxNaturalHeight`, `renderSideBySide`).
|
|
213
|
+
*/
|
|
214
|
+
export function memoizeByPaneWidth(
|
|
215
|
+
compute: (paneWidth: number) => number,
|
|
216
|
+
): (paneWidth: number) => number {
|
|
217
|
+
const cache = new Map<number, number>();
|
|
218
|
+
return (paneWidth: number): number => {
|
|
219
|
+
const hit = cache.get(paneWidth);
|
|
220
|
+
if (hit !== undefined) {
|
|
221
|
+
return hit;
|
|
222
|
+
}
|
|
223
|
+
const value = compute(paneWidth);
|
|
224
|
+
if (cache.size >= PANE_WIDTH_MEMO_MAX_ENTRIES) {
|
|
225
|
+
cache.clear();
|
|
226
|
+
}
|
|
227
|
+
cache.set(paneWidth, value);
|
|
228
|
+
return value;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
190
232
|
/**
|
|
191
233
|
* Width allocation for side-by-side mode.
|
|
192
234
|
* `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
|
|
193
235
|
* cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
|
|
194
236
|
* extreme inputs.
|
|
195
237
|
*/
|
|
196
|
-
export function columnWidths(
|
|
197
|
-
paneWidth: number,
|
|
198
|
-
adaptiveLeft: number,
|
|
199
|
-
): { leftWidth: number; rightWidth: number; gap: number } {
|
|
238
|
+
export function columnWidths(paneWidth: number, adaptiveLeft: number) {
|
|
200
239
|
const gap = PREVIEW_COLUMN_GAP;
|
|
201
240
|
const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
|
|
202
241
|
const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
|
|
@@ -208,12 +247,10 @@ export function columnWidths(
|
|
|
208
247
|
* `render()`. Stacked uses the full pane width for both; side-by-side splits via
|
|
209
248
|
* `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
|
|
210
249
|
*/
|
|
211
|
-
export function bodyWidths(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
): { optionsWidth: number; previewWidth: number } {
|
|
216
|
-
if (mode === "stacked") return { optionsWidth: paneWidth, previewWidth: paneWidth };
|
|
250
|
+
export function bodyWidths(paneWidth: number, mode: PreviewLayoutMode, adaptiveLeft: number) {
|
|
251
|
+
if (mode === "stacked") {
|
|
252
|
+
return { optionsWidth: paneWidth, previewWidth: paneWidth };
|
|
253
|
+
}
|
|
217
254
|
const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
|
|
218
255
|
return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
|
|
219
256
|
}
|